From a448e95da266b4a3c901891fc6319e6496ac1012 Mon Sep 17 00:00:00 2001 From: J Logan Date: Tue, 27 Oct 2026 08:44:52 -0700 Subject: [PATCH] Adds `container cluster` example. (#2433) --- examples/container-cluster-gateway/.gitignore | 1 + examples/container-cluster-gateway/README.md | 238 ++++++++++++++++++ .../container-cluster-gateway/api/Dockerfile | 6 + examples/container-cluster-gateway/api/app.py | 38 +++ .../api/requirements.txt | 3 + .../deployment/api.yaml | 40 +++ .../deployment/valkey.yaml | 30 +++ .../deployment/web.yaml | 64 +++++ .../ingress/ingress.yaml | 36 +++ .../scripts/build.sh | 26 ++ .../scripts/deploy-gateway.sh | 155 ++++++++++++ .../scripts/deploy.sh | 32 +++ .../scripts/remove-gateway.sh | 43 ++++ .../container-cluster-gateway/web/Dockerfile | 10 + .../container-cluster-gateway/web/index.html | 12 + .../container-cluster-gateway/web/nginx.conf | 14 ++ .../web/package.json | 18 ++ .../container-cluster-gateway/web/src/App.css | 133 ++++++++++ .../container-cluster-gateway/web/src/App.jsx | 76 ++++++ .../web/src/main.jsx | 10 + .../web/vite.config.js | 6 + 21 files changed, 991 insertions(+) create mode 100644 examples/container-cluster-gateway/.gitignore create mode 100644 examples/container-cluster-gateway/README.md create mode 100644 examples/container-cluster-gateway/api/Dockerfile create mode 100644 examples/container-cluster-gateway/api/app.py create mode 100644 examples/container-cluster-gateway/api/requirements.txt create mode 100644 examples/container-cluster-gateway/deployment/api.yaml create mode 100644 examples/container-cluster-gateway/deployment/valkey.yaml create mode 100644 examples/container-cluster-gateway/deployment/web.yaml create mode 100644 examples/container-cluster-gateway/ingress/ingress.yaml create mode 100644 examples/container-cluster-gateway/scripts/build.sh create mode 100644 examples/container-cluster-gateway/scripts/deploy-gateway.sh create mode 100644 examples/container-cluster-gateway/scripts/deploy.sh create mode 100644 examples/container-cluster-gateway/scripts/remove-gateway.sh create mode 100644 examples/container-cluster-gateway/web/Dockerfile create mode 100644 examples/container-cluster-gateway/web/index.html create mode 100644 examples/container-cluster-gateway/web/nginx.conf create mode 100644 examples/container-cluster-gateway/web/package.json create mode 100644 examples/container-cluster-gateway/web/src/App.css create mode 100644 examples/container-cluster-gateway/web/src/App.jsx create mode 100644 examples/container-cluster-gateway/web/src/main.jsx create mode 100644 examples/container-cluster-gateway/web/vite.config.js diff --git a/examples/container-cluster-gateway/.gitignore b/examples/container-cluster-gateway/.gitignore new file mode 100644 index 00000000..7c6ebed5 --- /dev/null +++ b/examples/container-cluster-gateway/.gitignore @@ -0,0 +1 @@ +.gateway/ diff --git a/examples/container-cluster-gateway/README.md b/examples/container-cluster-gateway/README.md new file mode 100644 index 00000000..54db2068 --- /dev/null +++ b/examples/container-cluster-gateway/README.md @@ -0,0 +1,238 @@ +# Example: Run a three-tier app on a container cluster behind a gateway + +This example shows you how to use `container cluster` to run a small three-tier web application on a local Kubernetes cluster, and how to reach it from your Mac's browser by name through a gateway container. + +The app is a shared notepad. Anyone who can reach the page can post a short note with their name. Notes are stored in [Valkey](https://valkey.io/) and served through a [Flask](https://flask.palletsprojects.com/) API, with a [React](https://react.dev/) frontend built with [Vite](https://vitejs.dev/) and served by [nginx](https://nginx.org/). The frontend polls for new notes every few seconds, so multiple browser windows stay in sync. + +## Prerequisites + +Install and start before running the demo: + +- Apple `container` release 1.6.1 or later +- [`kubectl`](https://kubernetes.io/docs/tasks/tools/) +- The `test` DNS domain, configured as the default domain and registered with macOS, as described in [Set up DNS-based container names](../../docs/networking.md#set-up-dns-based-container-names) +- [Helm](https://helm.sh/) + +## Architecture + +``` + ┌──────────┐ + │ Browser │ http://notepad.gateway.test + └────┬─────┘ + │ + ┌────┴───────────────────────────┐ + │ gateway nginx container │ network alias .gateway + └────┬───────────────────────────┘ + │ NodePort 31080 on the worker nodes + ┌────┴───────────────────────────┐ + │ Traefik ingress controller │ routes by Host header + └────┬───────────────────────────┘ + │ + Ingress rules ──► web, api Services + │ + └─► api ──► valkey +``` + +A cluster's nodes share one container network, and the pods reach each other through Kubernetes Services. The gateway is an nginx container on that same network. It is started with a network alias that has a leading dot: + +```console +% container run -d --name gateway --network ,alias=.gateway ... +``` + +The alias makes every name below `gateway.test` resolve to the gateway, so `notepad.gateway.test` and `api.gateway.test` both reach it. The gateway forwards requests to Traefik on the worker nodes, and the Ingress rules in `ingress/ingress.yaml` use the `Host` header to choose between the `web` and `api` Services. Adding another name takes only another Ingress rule, with no change to DNS. + +## Set up + +Follow these steps to start the cluster and gateway, and build and deploy the application. + +### 1. Build the images + +```console +% container build -t demo-web:latest web +% container build -t demo-api:latest api +``` + +`./scripts/build.sh` runs both commands. + +### 2. Create the cluster + +```console +% container cluster create --workers 2 +``` + +This pulls the Kubernetes node image on first use, starts a control plane node and two worker nodes, waits for the nodes to be Ready, and merges the cluster into `~/.kube/config`. The default `cluster-dev` cluster gets a kubectl context of the same name. To use another name, pass `--name` to `container cluster create` and `--context` or the cluster name to the scripts below. + +### 3. Load the images + +Load the images into the containerd of every node: + +```console +% container cluster load-image --name cluster-dev demo-api:latest +% container cluster load-image --name cluster-dev demo-web:latest +``` + +The manifests refer to the images as `docker.io/library/demo-api:latest` and `docker.io/library/demo-web:latest`, which is the name that `container build` gives them. + +### 4. Apply the manifests + +```console +% kubectl --context cluster-dev apply -f deployment/ +``` + +`./scripts/deploy.sh [cluster-name]` runs the image loading and this command. + +### 5. Create the gateway + +```console +% ./scripts/deploy-gateway.sh +``` + +The script installs Traefik as the ingress controller, applies the Ingress rules, finds the container network of the worker nodes, writes the gateway's nginx configuration under `.gateway/`, and starts the `gateway` container. When it finishes, it prints the app's address. + +The script reads two environment variables when its defaults are wrong: + +- `GATEWAY_NETWORK` is the container network that the cluster nodes are on. The default is found from the address of a worker node. +- `TRAEFIK_CHART_VERSION` pins the Traefik Helm chart. The default is the latest. + +## Setup summary + +Everything above, in one place, with the commands behind the scripts. Run it from this directory: + +Containers and images, with `container`: + +```console +# Build the images +container build -t demo-web:latest web +container build -t demo-api:latest api + +# Create the cluster: one control plane node and two workers +container cluster create --workers 2 + +# Load the images into every node +container cluster load-image --name cluster-dev demo-api:latest +container cluster load-image --name cluster-dev demo-web:latest +``` + +Manifests, ingress and node addresses, with `kubectl` and `helm`: + +```console +# Apply the app's manifests +kubectl --context cluster-dev apply -f deployment/ + +# Install Traefik as the ingress controller, on NodePort 31080 of every node +helm upgrade --install traefik traefik \ + --repo https://traefik.github.io/charts \ + --kube-context cluster-dev --namespace traefik --create-namespace \ + --set service.type=NodePort \ + --set ports.web.nodePort=31080 \ + --set ports.websecure.nodePort=31443 \ + --set deployment.replicas=2 \ + --wait + +# Route notepad.gateway.test and api.gateway.test to the web and api Services +kubectl --context cluster-dev apply -f ingress/ingress.yaml + +# Find the worker nodes' addresses +kubectl --context cluster-dev get nodes --selector '!node-role.kubernetes.io/control-plane' \ + --output 'jsonpath={range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{"\n"}{end}' +``` + +The gateway, with `container`: + +```console +# Find the container network that the nodes are on +container inspect $(container list --quiet) | jq -r '.[].status.networks[]? | .network + " " + .ipv4Address' + +# Write the gateway's nginx configuration, with one "server :31080;" line +# per worker node, then start the gateway on the nodes' network +mkdir -p .gateway/gateway +cat > .gateway/gateway/default.conf <<'CONF' +upstream cluster_ingress { + server :31080; + server :31080; +} + +server { + listen 80 default_server; + + location / { + proxy_pass http://cluster_ingress; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +CONF + +container run -d --name gateway \ + --network ,alias=.gateway \ + --mount type=bind,source="$PWD/.gateway/gateway",target=/etc/nginx/conf.d,readonly \ + nginx:alpine +``` + +`./scripts/build.sh`, `./scripts/deploy.sh` and `./scripts/deploy-gateway.sh` run the build, load-and-apply, and gateway commands. The gateway script finds the node addresses and the network for you. + +## Examine what was deployed + +Use the `container` CLI to see the containers and network behind the cluster: + +```console +# The cluster +container cluster list + +# The control plane and worker node containers, and the gateway container +container list + +# The network that the nodes and the gateway share +container network list + +# The gateway's addresses and its .gateway alias +container inspect gateway | jq '.[0].status.networks' + +# The images that were built +container image list | grep demo- +``` + +Use `kubectl` to see what runs inside the cluster: + +```console +# The nodes, with their container network addresses +kubectl --context cluster-dev get nodes -o wide + +# The app's pods and which worker node each runs on +kubectl --context cluster-dev get pods -o wide + +# The Services that connect web, api and valkey +kubectl --context cluster-dev get services + +# The Ingress rules that route notepad.gateway.test and api.gateway.test +kubectl --context cluster-dev get ingress + +# The Traefik ingress controller +kubectl --context cluster-dev get pods --namespace traefik +``` + +## Try the application and API + +1. Open `http://notepad.gateway.test` in a browser. +2. Duplicate the tab into a new window and arrange the two windows side by side. +3. Enter a name and post a note in the first window, then post a note from the second window. Each note appears in both windows within a few seconds. +4. Look at the notes on the API server: open `http://api.gateway.test/api/notes` in the browser, or run: + + ```console + % curl -s http://api.gateway.test/api/notes | jq + ``` + +## Clean up + +Remove the gateway, the ingress rules and Traefik: + +```console +% ./scripts/remove-gateway.sh +``` + +Stop and delete the cluster, including its worker nodes: + +```console +% container cluster delete --name cluster-dev +``` diff --git a/examples/container-cluster-gateway/api/Dockerfile b/examples/container-cluster-gateway/api/Dockerfile new file mode 100644 index 00000000..44708ae8 --- /dev/null +++ b/examples/container-cluster-gateway/api/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["python", "app.py"] diff --git a/examples/container-cluster-gateway/api/app.py b/examples/container-cluster-gateway/api/app.py new file mode 100644 index 00000000..67fdbba3 --- /dev/null +++ b/examples/container-cluster-gateway/api/app.py @@ -0,0 +1,38 @@ +import json +import os +from datetime import datetime, timezone + +from flask import Flask, jsonify, request +from flask_cors import CORS +import valkey + +app = Flask(__name__) +CORS(app) + +kv = valkey.Valkey( + host=os.getenv('VALKEY_HOST', 'localhost'), + port=6379, + decode_responses=True, +) + + +@app.route('/api/notes', methods=['GET']) +def get_notes(): + raw = kv.lrange('notes', 0, -1) + return jsonify([json.loads(n) for n in raw]) + + +@app.route('/api/notes', methods=['POST']) +def add_note(): + data = request.get_json() + note = { + 'author': data.get('author') or 'Anonymous', + 'text': data.get('text', '').strip(), + 'timestamp': datetime.now(timezone.utc).isoformat(), + } + kv.lpush('notes', json.dumps(note)) + return jsonify(note), 201 + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000) diff --git a/examples/container-cluster-gateway/api/requirements.txt b/examples/container-cluster-gateway/api/requirements.txt new file mode 100644 index 00000000..376e35a7 --- /dev/null +++ b/examples/container-cluster-gateway/api/requirements.txt @@ -0,0 +1,3 @@ +flask +flask-cors +valkey diff --git a/examples/container-cluster-gateway/deployment/api.yaml b/examples/container-cluster-gateway/deployment/api.yaml new file mode 100644 index 00000000..228dd946 --- /dev/null +++ b/examples/container-cluster-gateway/deployment/api.yaml @@ -0,0 +1,40 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api +spec: + replicas: 1 + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + containers: + - name: api + image: docker.io/library/demo-api:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5000 + env: + - name: VALKEY_HOST + value: valkey + readinessProbe: + httpGet: + path: /api/notes + port: 5000 + initialDelaySeconds: 3 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: api +spec: + selector: + app: api + ports: + - port: 5000 + targetPort: 5000 diff --git a/examples/container-cluster-gateway/deployment/valkey.yaml b/examples/container-cluster-gateway/deployment/valkey.yaml new file mode 100644 index 00000000..6cd2d86b --- /dev/null +++ b/examples/container-cluster-gateway/deployment/valkey.yaml @@ -0,0 +1,30 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: valkey +spec: + replicas: 1 + selector: + matchLabels: + app: valkey + template: + metadata: + labels: + app: valkey + spec: + containers: + - name: valkey + image: valkey/valkey:8 + ports: + - containerPort: 6379 +--- +apiVersion: v1 +kind: Service +metadata: + name: valkey +spec: + selector: + app: valkey + ports: + - port: 6379 + targetPort: 6379 diff --git a/examples/container-cluster-gateway/deployment/web.yaml b/examples/container-cluster-gateway/deployment/web.yaml new file mode 100644 index 00000000..29e22f72 --- /dev/null +++ b/examples/container-cluster-gateway/deployment/web.yaml @@ -0,0 +1,64 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: web-nginx-conf +data: + default.conf: | + server { + listen 80; + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://api:5000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web +spec: + replicas: 1 + selector: + matchLabels: + app: web + template: + metadata: + labels: + app: web + spec: + containers: + - name: web + image: docker.io/library/demo-web:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + volumeMounts: + - name: nginx-conf + mountPath: /etc/nginx/conf.d/default.conf + subPath: default.conf + volumes: + - name: nginx-conf + configMap: + name: web-nginx-conf +--- +# NodePort 30080: accessible at http://:30080 on any node, without port-forward. +# Find the node IPs with: kubectl get nodes -o wide +apiVersion: v1 +kind: Service +metadata: + name: web +spec: + type: NodePort + selector: + app: web + ports: + - port: 80 + targetPort: 80 + nodePort: 30080 diff --git a/examples/container-cluster-gateway/ingress/ingress.yaml b/examples/container-cluster-gateway/ingress/ingress.yaml new file mode 100644 index 00000000..ce2461c9 --- /dev/null +++ b/examples/container-cluster-gateway/ingress/ingress.yaml @@ -0,0 +1,36 @@ +# Routes requests that reach the cluster's ingress controller by host name. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notepad +spec: + ingressClassName: traefik + rules: + - host: notepad.gateway.test + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: web + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notepad-api +spec: + ingressClassName: traefik + rules: + - host: api.gateway.test + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: api + port: + number: 5000 diff --git a/examples/container-cluster-gateway/scripts/build.sh b/examples/container-cluster-gateway/scripts/build.sh new file mode 100644 index 00000000..ed4bddab --- /dev/null +++ b/examples/container-cluster-gateway/scripts/build.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Copyright © 2026 Apple Inc. and the container project authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +EXAMPLE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +run() { + echo "==> RUN: $*" + "$@" +} + +run container build -t demo-web:latest "$EXAMPLE_ROOT/web" +run container build -t demo-api:latest "$EXAMPLE_ROOT/api" diff --git a/examples/container-cluster-gateway/scripts/deploy-gateway.sh b/examples/container-cluster-gateway/scripts/deploy-gateway.sh new file mode 100644 index 00000000..c3389d9e --- /dev/null +++ b/examples/container-cluster-gateway/scripts/deploy-gateway.sh @@ -0,0 +1,155 @@ +#!/bin/bash +# Copyright © 2026 Apple Inc. and the container project authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# +# Puts a gateway in front of a local Kubernetes cluster, and routes to the app by host name. +# +# browser -> gateway container -> NodePort on the worker nodes -> Traefik -> Ingress -> Service +# +# The gateway is an nginx container on the cluster's container network. Its network alias +# ".gateway" makes every name below gateway.test resolve to it, so notepad.gateway.test and +# api.gateway.test both go to the gateway, and Traefik picks the Service from the Host header. +# +# Works for clusters made with "container cluster create". Run it after deploy.sh. +# +# Usage: deploy-gateway.sh [--context ] (default context: cluster-dev) +# +# Environment: +# GATEWAY_NETWORK container network of the cluster nodes (default: found from a worker node) +# TRAEFIK_CHART_VERSION Traefik chart version to install (default: the latest) +set -e + +EXAMPLE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CONTEXT="cluster-dev" +GATEWAY="gateway" +# The gateway's names are below this host. It assumes the default DNS domain is "test". +GATEWAY_HOST="gateway.test" +INGRESS_HTTP_PORT=31080 +INGRESS_HTTPS_PORT=31443 + +while [[ $# -gt 0 ]]; do + case $1 in + --context) CONTEXT="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +run() { + echo "==> RUN: $*" + "$@" +} + +KUBECTL=(kubectl --context "$CONTEXT") + +# Worker nodes, or all nodes if the cluster has no dedicated workers. +node_ips() { + "${KUBECTL[@]}" get nodes "$@" \ + --output 'jsonpath={range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{"\n"}{end}' +} +WORKER_SELECTOR='!node-role.kubernetes.io/control-plane' +NODE_IPS="$(node_ips --selector "$WORKER_SELECTOR")" +if [[ -z "$NODE_IPS" ]]; then + NODE_IPS="$(node_ips)" +fi +echo "==> Gateway upstream nodes: $(echo $NODE_IPS)" + +# The gateway has to be on the container network that the nodes are on. Find the network by the +# address of a node, which does not depend on how the cluster tool names its nodes. +if [[ -z "$GATEWAY_NETWORK" ]]; then + FIRST_NODE_IP="$(echo "$NODE_IPS" | head -n 1)" + GATEWAY_NETWORK="$(container inspect $(container list --quiet) | + jq -r --arg ip "$FIRST_NODE_IP" '[.[].status.networks[]? | select(.ipv4Address | startswith($ip + "/")) | .network][0] // empty')" + if [[ -z "$GATEWAY_NETWORK" ]]; then + echo "Could not find the container network of the node at $FIRST_NODE_IP. Set GATEWAY_NETWORK." + exit 1 + fi +fi +echo "==> Gateway network: $GATEWAY_NETWORK" + +# Install Traefik as the ingress controller, reachable on a NodePort of every node. +TRAEFIK_VERSION=() +if [[ -n "$TRAEFIK_CHART_VERSION" ]]; then + TRAEFIK_VERSION=(--version "$TRAEFIK_CHART_VERSION") +fi +run helm upgrade --install traefik traefik \ + --repo https://traefik.github.io/charts "${TRAEFIK_VERSION[@]}" \ + --kube-context "$CONTEXT" --namespace traefik --create-namespace \ + --set service.type=NodePort \ + --set ports.web.nodePort="$INGRESS_HTTP_PORT" \ + --set ports.websecure.nodePort="$INGRESS_HTTPS_PORT" \ + --set deployment.replicas=2 \ + --wait + +# Route notepad. and api. to the app's Services. +run "${KUBECTL[@]}" apply -f "$EXAMPLE_ROOT/ingress/ingress.yaml" + +# Generate the gateway's nginx configuration. It balances across the nodes' ingress NodePort. +CONF_DIR="$EXAMPLE_ROOT/.gateway/$GATEWAY" +mkdir -p "$CONF_DIR" +{ + echo "upstream cluster_ingress {" + for ip in $NODE_IPS; do + echo " server ${ip}:${INGRESS_HTTP_PORT};" + done + cat <<'CONF' +} + +server { + listen 80 default_server; + + location / { + proxy_pass http://cluster_ingress; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +CONF +} >"$CONF_DIR/default.conf" + +# The alias has a leading dot, so every name below the gateway's name resolves to it, +# and it gets the default domain, so .gateway means .gateway.test. +run container stop "$GATEWAY" 2>/dev/null || true +run container delete "$GATEWAY" 2>/dev/null || true +run container run -d --name "$GATEWAY" \ + --network "${GATEWAY_NETWORK},alias=.${GATEWAY}" \ + --mount "type=bind,source=${CONF_DIR},target=/etc/nginx/conf.d,readonly" \ + nginx:alpine + +GATEWAY_IP="$(container inspect "$GATEWAY" | jq -r '.[0].status.networks[0].ipv4Address' | cut -d/ -f1)" + +# Traefik can take a few seconds to pick up the Ingress rules, and until it does it answers 404. +ATTEMPTS=30 +echo "==> Waiting for http://notepad.${GATEWAY_HOST} through the gateway at ${GATEWAY_IP}" +for attempt in $(seq 1 "$ATTEMPTS"); do + status="$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 \ + -H "Host: notepad.${GATEWAY_HOST}" "http://${GATEWAY_IP}/" || true)" + if [[ "$status" == 2* ]]; then + echo + echo "Run these commands from your terminal to access the web application and message API:" + echo + echo " open http://notepad.${GATEWAY_HOST}" + echo " curl -s http://api.${GATEWAY_HOST}/api/notes | jq" + exit 0 + fi + if [[ "$status" == "000" ]]; then + status="no answer" + fi + echo " not ready yet (${status}), attempt ${attempt} of ${ATTEMPTS}" + sleep 2 +done +echo "The app did not answer through the gateway (last result: ${status})." +echo "Check 'kubectl --context ${CONTEXT} get pods -A' and 'kubectl --context ${CONTEXT} get ingress'." +exit 1 diff --git a/examples/container-cluster-gateway/scripts/deploy.sh b/examples/container-cluster-gateway/scripts/deploy.sh new file mode 100644 index 00000000..85538361 --- /dev/null +++ b/examples/container-cluster-gateway/scripts/deploy.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Copyright © 2026 Apple Inc. and the container project authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +# The cluster name is also the kubectl context that "container cluster create" adds to ~/.kube/config. +CLUSTER="${1:-cluster-dev}" +EXAMPLE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +run() { + echo "==> RUN: $*" + "$@" +} + +# Loads each image into every node of the cluster. "container build" names the images +# docker.io/library/demo-*:latest, which is the name that the manifests in deployment/ use. +run container cluster load-image --name "$CLUSTER" demo-api:latest +run container cluster load-image --name "$CLUSTER" demo-web:latest + +run kubectl --context "$CLUSTER" apply -f "$EXAMPLE_ROOT/deployment/" diff --git a/examples/container-cluster-gateway/scripts/remove-gateway.sh b/examples/container-cluster-gateway/scripts/remove-gateway.sh new file mode 100644 index 00000000..1d70163c --- /dev/null +++ b/examples/container-cluster-gateway/scripts/remove-gateway.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Copyright © 2026 Apple Inc. and the container project authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# +# Removes what deploy-gateway.sh created: the gateway container, the ingress rules and Traefik. +# +# Usage: remove-gateway.sh [--context ] +set -e + +EXAMPLE_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CONTEXT="cluster-dev" +GATEWAY="gateway" + +while [[ $# -gt 0 ]]; do + case $1 in + --context) CONTEXT="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +run() { + echo "==> RUN: $*" + "$@" +} + +run container stop "$GATEWAY" 2>/dev/null || true +run container delete "$GATEWAY" 2>/dev/null || true +run rm -rf "$EXAMPLE_ROOT/.gateway/$GATEWAY" + +run kubectl --context "$CONTEXT" delete ingress notepad notepad-api --ignore-not-found +run helm uninstall traefik --kube-context "$CONTEXT" --namespace traefik 2>/dev/null || true diff --git a/examples/container-cluster-gateway/web/Dockerfile b/examples/container-cluster-gateway/web/Dockerfile new file mode 100644 index 00000000..356ff5f6 --- /dev/null +++ b/examples/container-cluster-gateway/web/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/examples/container-cluster-gateway/web/index.html b/examples/container-cluster-gateway/web/index.html new file mode 100644 index 00000000..5aec5a55 --- /dev/null +++ b/examples/container-cluster-gateway/web/index.html @@ -0,0 +1,12 @@ + + + + + + Shared Notepad + + +
+ + + diff --git a/examples/container-cluster-gateway/web/nginx.conf b/examples/container-cluster-gateway/web/nginx.conf new file mode 100644 index 00000000..12d7b036 --- /dev/null +++ b/examples/container-cluster-gateway/web/nginx.conf @@ -0,0 +1,14 @@ +server { + listen 80; + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://api.test:5000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git a/examples/container-cluster-gateway/web/package.json b/examples/container-cluster-gateway/web/package.json new file mode 100644 index 00000000..95ab8555 --- /dev/null +++ b/examples/container-cluster-gateway/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "shared-notepad", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.0.0" + } +} diff --git a/examples/container-cluster-gateway/web/src/App.css b/examples/container-cluster-gateway/web/src/App.css new file mode 100644 index 00000000..24dca7ff --- /dev/null +++ b/examples/container-cluster-gateway/web/src/App.css @@ -0,0 +1,133 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #f0f2f5; + color: #333; +} + +.app { + max-width: 680px; + margin: 0 auto; + padding: 2rem 1rem; +} + +header { + text-align: center; + margin-bottom: 2rem; +} + +header h1 { + font-size: 2rem; + font-weight: 700; + color: #1a1a1a; +} + +header p { + margin-top: 0.25rem; + font-size: 0.85rem; + color: #888; + letter-spacing: 0.03em; +} + +form { + background: white; + border-radius: 10px; + padding: 1.25rem; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + margin-bottom: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +input, +textarea { + width: 100%; + padding: 0.6rem 0.8rem; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 0.95rem; + font-family: inherit; + outline: none; + transition: border-color 0.15s; +} + +input:focus, +textarea:focus { + border-color: #4a90d9; +} + +textarea { + resize: vertical; + min-height: 80px; +} + +button { + align-self: flex-end; + padding: 0.55rem 1.4rem; + background: #4a90d9; + color: white; + border: none; + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; + transition: background 0.15s; +} + +button:hover:not(:disabled) { + background: #357abd; +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.notes { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.note { + background: white; + border-radius: 10px; + padding: 1rem 1.25rem; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +.note-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.4rem; +} + +.note-header strong { + font-size: 0.9rem; + color: #1a1a1a; +} + +.note-header span { + font-size: 0.8rem; + color: #aaa; +} + +.note p { + font-size: 0.95rem; + line-height: 1.55; + color: #444; + white-space: pre-wrap; +} + +.empty { + text-align: center; + color: #aaa; + padding: 2rem 0; + font-size: 0.9rem; +} diff --git a/examples/container-cluster-gateway/web/src/App.jsx b/examples/container-cluster-gateway/web/src/App.jsx new file mode 100644 index 00000000..a41b523b --- /dev/null +++ b/examples/container-cluster-gateway/web/src/App.jsx @@ -0,0 +1,76 @@ +import { useState, useEffect } from 'react' + +export default function App() { + const [notes, setNotes] = useState([]) + const [author, setAuthor] = useState('') + const [text, setText] = useState('') + const [posting, setPosting] = useState(false) + + const fetchNotes = () => + fetch('/api/notes') + .then(r => r.json()) + .then(setNotes) + .catch(() => {}) + + useEffect(() => { + fetchNotes() + const id = setInterval(fetchNotes, 3000) + return () => clearInterval(id) + }, []) + + const handleSubmit = async e => { + e.preventDefault() + if (!text.trim()) return + setPosting(true) + await fetch('/api/notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ author: author.trim() || 'Anonymous', text: text.trim() }), + }) + setText('') + await fetchNotes() + setPosting(false) + } + + return ( +
+
+

Shared Notepad

+

React · Flask · Valkey

+
+ +
+ setAuthor(e.target.value)} + /> +