mirror of
https://github.com/apple/container.git
synced 2026-11-05 05:23:40 +00:00
Adds container cluster example. (#2433)
This commit is contained in:
1 parent
804209b165
commit
a448e95da2
21 files changed
+991
No files matched your search
@@ -0,0 +1 @@
|
||||
.gateway/
|
||||
@@ -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 <nodes-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 <node-ip>: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 <worker-1-ip>:31080;
|
||||
server <worker-2-ip>: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 <nodes-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
|
||||
```
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
flask
|
||||
flask-cors
|
||||
valkey
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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://<node-ip>: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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 <kube-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.<gateway> and api.<gateway> 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
|
||||
@@ -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/"
|
||||
@@ -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 <kube-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
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Shared Notepad</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="app">
|
||||
<header>
|
||||
<h1>Shared Notepad</h1>
|
||||
<p>React · Flask · Valkey</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
placeholder="Your name (optional)"
|
||||
value={author}
|
||||
onChange={e => setAuthor(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Write a note..."
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={posting}>
|
||||
{posting ? 'Posting...' : 'Post Note'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="notes">
|
||||
{notes.length === 0 ? (
|
||||
<p className="empty">No notes yet. Be the first to post.</p>
|
||||
) : (
|
||||
notes.map((note, i) => (
|
||||
<div key={i} className="note">
|
||||
<div className="note-header">
|
||||
<strong>{note.author}</strong>
|
||||
<span>{new Date(note.timestamp).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<p>{note.text}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './App.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in new issue
Block a user