finish orchestrating metrics and dynamic frontend

This commit is contained in:
Kenneth Bingham
2026-03-18 16:04:20 -04:00
parent 3a32771549
commit 76cccd1fa5
11 changed files with 874 additions and 133 deletions
+37 -4
View File
@@ -45,8 +45,41 @@ jobs:
python3 -m venv sdk/python/.venv
sdk/python/.venv/bin/pip install -e 'sdk/python/src/[test]' flask waitress requests
- name: Resolve Ziti version for Docker images
id: ziti-docker-version
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ZITI_VERSION: ${{ vars.ZITI_TEST_VERSION }}
run: |
if [[ -n "${ZITI_VERSION:-}" ]]; then
V="${ZITI_VERSION}"
echo "Using pinned version: ${V}"
else
V=$(
gh api --method GET \
'repos/openziti/ziti/releases?per_page=100' \
--jq '
[.[]
| select(.prerelease == false
and .draft == false)
] | first | .tag_name | ltrimstr("v")'
)
echo "Resolved latest stable: ${V}"
fi
V="${V#v}"
if [[ -z "${V}" ]]; then
echo "ERROR: could not resolve Ziti version" >&2
exit 1
fi
echo "ZITI_VERSION=${V}" | tee -a "$GITHUB_OUTPUT"
- name: Start zrok2 instance (built from source)
run: bash docker/compose/zrok2-instance/dangerous.docker.test.bash --keep
run: |
ARGS=(--keep --ziti-tag "${{ steps.ziti-docker-version.outputs.ZITI_VERSION }}")
if [[ -n "${{ vars.ZITI_TEST_REPO }}" ]]; then
ARGS+=(--ziti-repo "${{ vars.ZITI_TEST_REPO }}")
fi
bash docker/compose/zrok2-instance/dangerous.docker.test.bash "${ARGS[@]}"
timeout-minutes: 13
- name: Run Python SDK integration tests
@@ -102,10 +135,10 @@ jobs:
id: ziti-version
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ZITI_LINUX_VERSION: ${{ vars.ZITI_LINUX_VERSION }}
ZITI_VERSION: ${{ vars.ZITI_TEST_VERSION }}
run: |
if [[ -n "${ZITI_LINUX_VERSION:-}" ]]; then
V="${ZITI_LINUX_VERSION}"
if [[ -n "${ZITI_VERSION:-}" ]]; then
V="${ZITI_VERSION}"
echo "Using pinned version: ${V}"
else
V=$(
@@ -0,0 +1,91 @@
# Docker Compose overlay for the canary integration test.
# Merged by dangerous.docker.test.bash — not part of the user-facing stack.
#
# The canary service uses host networking so *.localhost resolves to 127.0.0.1
# and reaches published frontend and controller ports directly.
services:
canary:
image: ${ZROK2_IMAGE:-docker.io/openziti/zrok2}:${ZROK2_TAG:-latest}
network_mode: host
environment:
ZROK2_API_ENDPOINT: http://127.0.0.1:${ZROK2_CTRL_PORT:-18080}
ZROK2_ADMIN_TOKEN: ${ZROK2_ADMIN_TOKEN}
ZROK2_DANGEROUS_CANARY: "1"
ZROK2_FRONTEND_PORT: ${ZROK2_FRONTEND_PORT:-8080}
ZROK2_DNS_ZONE: ${ZROK2_DNS_ZONE:-localhost}
HOME: /tmp/canary
entrypoint: ["/bin/bash", "-c"]
command:
- |
set -o errexit -o nounset -o pipefail
FRONTEND_PORT="$${ZROK2_FRONTEND_PORT}"
DNS_ZONE="$${ZROK2_DNS_ZONE}"
# Create a throwaway canary account and enable an environment.
# ZROK2_API_ENDPOINT is already set — zrok2 enable bootstraps
# the environment in one shot.
TOKEN=$$(zrok2 admin create account \
"canary-$$(date +%s)@zrok.internal" "canarypass")
echo "canary account token: $${TOKEN}"
zrok2 enable "$${TOKEN}" --description canary-test
# ── Test 1: Canary looper (random share token) ──────────────────
# Exercises the public frontend with random share tokens. The
# host-networked container reaches the frontend at
# localhost:ZROK2_FRONTEND_PORT, and *.localhost resolves to
# 127.0.0.1 (RFC 6761).
echo "=== Test 1: canary public-proxy looper ==="
zrok2 test canary public-proxy \
--iterations 3 --loopers 1 \
--min-payload 256 --max-payload 256 \
--min-pacing 1s --max-pacing 1s \
--http --frontend-port "$${FRONTEND_PORT}"
# ── Test 2: Named share via --name-selection ────────────────────
# Creates a share with an explicit name in the public namespace,
# then verifies the AMQP-backed dynamic frontend routes it.
echo "=== Test 2: named share ==="
SHARE_NAME="citest-$$(date +%s)"
BACKEND_PORT=19999
zrok2 test endpoint --port "$${BACKEND_PORT}" &
HTTP_PID=$$!
# Pre-create the name (v2 equivalent of "zrok reserve").
zrok2 create name "$${SHARE_NAME}"
# Create the named share in the background (long-running).
zrok2 share public "http://127.0.0.1:$${BACKEND_PORT}" \
--name-selection "public:$${SHARE_NAME}" \
--backend-mode proxy --headless &
SHARE_PID=$$!
# Wait for the share to propagate to the dynamic frontend.
echo "waiting for named share '$${SHARE_NAME}' ..."
sleep 5
ATTEMPTS=30
while (( ATTEMPTS-- > 0 )); do
if curl -sf -H "Host: $${SHARE_NAME}.$${DNS_ZONE}" \
"http://127.0.0.1:$${FRONTEND_PORT}/" 2>/dev/null \
| grep -q "zrok"; then
echo "PASS: named share '$${SHARE_NAME}' content verified"
break
fi
sleep 2
done
if (( ATTEMPTS < 0 )); then
echo "FAIL: named share '$${SHARE_NAME}' not reachable after 60s"
kill $${SHARE_PID} $${HTTP_PID} 2>/dev/null || true
exit 1
fi
# Clean up share and backend.
kill $${SHARE_PID} $${HTTP_PID} 2>/dev/null || true
wait $${SHARE_PID} 2>/dev/null || true
wait $${HTTP_PID} 2>/dev/null || true
zrok2 disable || true
echo "=== All canary tests passed ==="
profiles: ["canary"]
+31 -4
View File
@@ -29,7 +29,7 @@ services:
ZITI_CLUSTER_NODE_NAME: ziti-ctrl
PFXLOG_NO_JSON: "true"
volumes:
- ziti-ctrl-data:/home/ziggy
- ziti-ctrl-data:/ziti-controller
ports:
- "${ZROK2_INSECURE_INTERFACE:-127.0.0.1}:${ZITI_CTRL_PORT:-1280}:${ZITI_CTRL_PORT:-1280}"
networks:
@@ -190,12 +190,16 @@ services:
ZROK2_FRONTEND_PORT: ${ZROK2_FRONTEND_PORT:-8080}
ZROK2_STORE_TYPE: ${ZROK2_STORE_TYPE:-postgres}
ZROK2_DB_PASSWORD: ${ZROK2_DB_PASSWORD:-zrok2defaultpw}
ZROK2_METRICS_ENABLED: ${ZROK2_METRICS_ENABLED:-false}
ZROK2_INFLUX_TOKEN: ${ZROK2_INFLUX_TOKEN:-}
HOME: /var/lib/zrok2
depends_on:
ziti-controller:
condition: service_healthy
postgresql:
condition: service_healthy
rabbitmq:
condition: service_healthy
volumes:
- zrok2-config:/var/lib/zrok2
- ./entrypoint-init.bash:/bootstrap/entrypoint-init.bash:ro
@@ -235,7 +239,7 @@ services:
zrok2-frontend:
<<: *zrok2-image
command: ["access", "public", "/var/lib/zrok2/config/frontend.yaml"]
command: ["access", "dynamicProxy", "/var/lib/zrok2/config/frontend.yaml"]
environment:
ZROK2_API_ENDPOINT: http://zrok2-controller:${ZROK2_CTRL_PORT:-18080}
HOME: /var/lib/zrok2
@@ -251,11 +255,34 @@ services:
restart: unless-stopped
# ── Metrics Pipeline (optional) ──────────────────────────────────────────
# To enable: set ZROK2_METRICS_ENABLED=true in .env
# To enable: set ZROK2_METRICS_ENABLED=true in .env and run with
# docker compose --profile metrics up -d
# Metrics bridge: reads fabric-usage.json from the Ziti controller data
# volume and publishes events to RabbitMQ for the zrok2 controller to
# consume and write to InfluxDB.
zrok2-metrics-bridge:
<<: *zrok2-image
command: ["controller", "metrics", "bridge", "/var/lib/zrok2/config/ctrl.yaml"]
profiles: ["metrics"]
environment:
HOME: /var/lib/zrok2
depends_on:
zrok2-controller:
condition: service_healthy
rabbitmq:
condition: service_healthy
volumes:
- zrok2-config:/var/lib/zrok2:ro
- ziti-ctrl-data:/ziti-data
networks:
- zrok2
restart: unless-stopped
# RabbitMQ is required by the dynamic frontend for real-time mapping updates
# (AMQP-backed share name resolution). It is NOT optional.
rabbitmq:
image: rabbitmq:3-management-alpine
profiles: ["metrics"]
volumes:
- rabbitmq-data:/var/lib/rabbitmq
networks:
@@ -5,19 +5,22 @@
# integration tests against the live API, then tears down.
#
# Usage:
# docker.test.bash [--source-dir <dir>] [--keep] [--only-clean]
# docker.test.bash [OPTIONS]
#
# Options:
# --source-dir <dir> zrok source tree root (default: auto-detected)
# --keep keep the stack running on exit (for inspection)
# --only-clean tear down a kept instance and exit
# --source-dir <dir> zrok source tree root (default: auto-detected)
# --ziti-repo <repo> Ziti image repo prefix (default: docker.io/openziti)
# --ziti-tag <tag> Ziti controller+router image tag (default: latest)
# --keep keep the stack running on exit (for inspection)
# --only-clean tear down a kept instance and exit
#
# Environment variables:
# COMPOSE_FILE Override compose file list (default: compose.yml:compose.build.yml)
#
# Examples:
# bash docker.test.bash
# bash docker.test.bash --keep
# bash docker.test.bash --ziti-repo docker.io/openziti --ziti-tag 1.6.14 --keep
# bash docker.test.bash --ziti-repo docker.io/kbingham --ziti-tag latest --keep
# bash docker.test.bash --only-clean
set -o errexit
@@ -31,11 +34,13 @@ for _arg in "$@"; do
case "$_arg" in
--help|-h)
cat <<'EOF'
Usage: docker.test.bash [--source-dir <dir>] [--keep] [--only-clean]
Usage: docker.test.bash [OPTIONS]
--source-dir <dir> zrok source tree root (default: auto-detected)
--keep keep the stack running on exit (for inspection)
--only-clean tear down a kept instance (volumes included) and exit
--source-dir <dir> zrok source tree root (default: auto-detected)
--ziti-repo <repo> Ziti image repo prefix (default: docker.io/openziti)
--ziti-tag <tag> Ziti controller+router image tag (default: latest)
--keep keep the stack running on exit (for inspection)
--only-clean tear down a kept instance (volumes included) and exit
State from a prior run is always destroyed at the start via 'docker compose
down -v'. Use --keep to leave the stack running for post-mortem inspection.
@@ -95,7 +100,7 @@ wait_for_port() {
dump_logs() {
log_info "container logs (last 100 lines each):"
docker compose logs --tail=100 2>/dev/null || true
(cd "${COMPOSE_PROJECT_DIR}" && docker compose logs --tail=100) 2>/dev/null || true
}
# ============================================================
@@ -107,7 +112,7 @@ COMPOSE_PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
teardown() {
log_section "Tear down"
if ! (cd "${COMPOSE_PROJECT_DIR}" && docker compose down -v 2>/dev/null); then
if ! (cd "${COMPOSE_PROJECT_DIR}" && docker compose --profile metrics --profile canary down -v --remove-orphans 2>/dev/null); then
true # best-effort
fi
}
@@ -156,11 +161,13 @@ trap '_err_handler' ERR
usage() {
trap - EXIT ERR # don't tear down on usage/help exits
cat >&2 <<'EOF'
Usage: docker.test.bash [--source-dir <dir>] [--keep] [--only-clean]
Usage: docker.test.bash [OPTIONS]
--source-dir <dir> zrok source tree root (default: auto-detected)
--keep keep the stack running on exit (for inspection)
--only-clean tear down a kept instance (volumes included) and exit
--source-dir <dir> zrok source tree root (default: auto-detected)
--ziti-repo <repo> Ziti image repo prefix (default: docker.io/openziti)
--ziti-tag <tag> Ziti controller+router image tag (default: latest)
--keep keep the stack running on exit (for inspection)
--only-clean tear down a kept instance (volumes included) and exit
State from a prior run is always destroyed at the start via 'docker compose
down -v'. Use --keep to leave the stack running for post-mortem inspection.
@@ -170,10 +177,14 @@ EOF
}
SOURCE_DIR=""
ZITI_REPO=""
ZITI_TAG=""
ONLY_CLEAN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--source-dir) SOURCE_DIR="$2"; shift 2 ;;
--ziti-repo) ZITI_REPO="$2"; shift 2 ;;
--ziti-tag) ZITI_TAG="$2"; shift 2 ;;
--keep) KEEP=1; shift ;;
--only-clean) ONLY_CLEAN=1; shift ;;
*) usage ;;
@@ -185,7 +196,7 @@ if [[ -z "${SOURCE_DIR}" ]]; then
fi
[[ -d "${SOURCE_DIR}" ]] || { trap - EXIT ERR; log_error "source dir '${SOURCE_DIR}' not found"; exit 1; }
export COMPOSE_FILE="${COMPOSE_FILE:-compose.yml:compose.build.yml}"
export COMPOSE_FILE="${COMPOSE_FILE:-compose.yml:compose.build.yml}:compose.canary.yml"
if (( ONLY_CLEAN )); then
trap - EXIT ERR # teardown is intentional here; don't double-run it on exit
@@ -213,7 +224,10 @@ WARN
fi
log_section "Pre-test cleanup"
if ! (cd "${COMPOSE_PROJECT_DIR}" && docker compose down -v 2>/dev/null); then
# Remove all containers, networks, and named volumes from the compose project.
# Use --profile metrics to include metrics services, and --remove-orphans to
# catch services that may have been renamed or removed between runs.
if ! (cd "${COMPOSE_PROJECT_DIR}" && docker compose --profile metrics --profile canary down -v --remove-orphans 2>/dev/null); then
true # best-effort
fi
@@ -231,7 +245,26 @@ for var in ZROK2_ADMIN_TOKEN ZITI_PWD ZROK2_DB_PASSWORD \
# shellcheck disable=SC2119
sed -i "s|^${var}=.*|${var}=$(generate_password)|" "${ENV_FILE}"
done
sed -i "s|^ZROK2_DNS_ZONE=.*|ZROK2_DNS_ZONE=localhost|" "${ENV_FILE}"
sed -i "s|^ZROK2_DNS_ZONE=.*|ZROK2_DNS_ZONE=zrok.127.0.0.1.sslip.io|" "${ENV_FILE}"
# Enable the metrics pipeline so we can verify InfluxDB receives data.
sed -i "s|^# ZROK2_METRICS_ENABLED=.*|ZROK2_METRICS_ENABLED=true|" "${ENV_FILE}"
# Pin Ziti image repo and/or tag if specified.
# --ziti-repo docker.io/kbingham → controller image is docker.io/kbingham/ziti-controller
if [[ -n "${ZITI_REPO}" ]]; then
sed -i "s|^# ZITI_CONTROLLER_IMAGE=.*|ZITI_CONTROLLER_IMAGE=${ZITI_REPO}/ziti-controller|" "${ENV_FILE}"
sed -i "s|^# ZITI_ROUTER_IMAGE=.*|ZITI_ROUTER_IMAGE=${ZITI_REPO}/ziti-router|" "${ENV_FILE}"
log_info "using Ziti image repo: ${ZITI_REPO}"
fi
if [[ -n "${ZITI_TAG}" ]]; then
# Docker image tags use hyphens (e.g., 2.0.0-rc5), not tildes.
# The same ZITI_LINUX_VERSION variable may contain tildes from deb
# convention (2.0.0~rc5) or hyphens — normalize to hyphens.
ZITI_TAG="${ZITI_TAG//\~/-}"
ZITI_TAG="${ZITI_TAG#v}"
sed -i "s|^# ZITI_CONTROLLER_TAG=.*|ZITI_CONTROLLER_TAG=${ZITI_TAG}|" "${ENV_FILE}"
sed -i "s|^# ZITI_ROUTER_TAG=.*|ZITI_ROUTER_TAG=${ZITI_TAG}|" "${ENV_FILE}"
log_info "using Ziti image tag: ${ZITI_TAG}"
fi
# Load generated values into the environment
# shellcheck source=/dev/null
@@ -252,10 +285,84 @@ log_section "Phase 2: Build and start Docker Compose stack"
COMPOSE_FILE="${COMPOSE_PROJECT_DIR}/${COMPOSE_FILE}" \
docker compose -f "${COMPOSE_PROJECT_DIR}/compose.yml" \
-f "${COMPOSE_PROJECT_DIR}/compose.build.yml" \
--profile metrics \
up -d --build --wait --wait-timeout 300)
log_pass "stack is up and healthy"
# Patch the Ziti controller config with fabric.usage event logging, then
# restart the overlay. We exec into the running controller to append the
# events section, then stop+start (not restart) with the config preserved.
# The router must restart after the controller to re-sync signing keys.
# Patch the Ziti controller config at /ziti-controller/config.yml (the
# controller's working directory, on the ziti-ctrl-data named volume).
# Use sed insertion before known anchors — appending to the end of YAML
# files is unreliably parsed by Go YAML parsers.
log_info "patching Ziti controller with fabric.usage events and metrics reporting..."
docker compose -f "${COMPOSE_PROJECT_DIR}/compose.yml" \
exec -T ziti-controller sh -c '
CONFIG=/ziti-controller/config.yml
# The volume is now mounted at /ziti-controller (the controller workdir),
# so the events file is on the shared volume and visible to the metrics
# bridge at /ziti-data/fabric-usage.json.
EVENTS_PATH=/ziti-controller/fabric-usage.json
# Insert events section before identity: if not present.
if ! grep -q "fabric.usage" "$CONFIG"; then
sed -i "/^identity:/i\\
\\
events:\\
jsonLogger:\\
subscriptions:\\
- type: fabric.usage\\
version: 3\\
handler:\\
type: file\\
format: json\\
path: $EVENTS_PATH\\
" "$CONFIG"
echo "events section inserted"
else
# Fix the events file path if it points to the wrong location.
sed -i "s|path:.*fabric-usage.json|path: $EVENTS_PATH|" "$CONFIG"
echo "events path corrected"
fi
# Insert network section before identity: for responsive metrics.
if ! grep -q "metricsReportInterval" "$CONFIG"; then
sed -i "/^identity:/i\\
\\
network:\\
intervalAgeThreshold: 5s\\
metricsReportInterval: 5s\\
" "$CONFIG"
echo "network metrics section inserted"
fi
# Pre-create the events file so the metrics bridge does not panic.
touch "$EVENTS_PATH"
'
log_info "restarting Ziti overlay..."
(cd "${COMPOSE_PROJECT_DIR}" && docker compose restart ziti-controller)
retry 30 3 docker compose -f "${COMPOSE_PROJECT_DIR}/compose.yml" \
exec -T ziti-controller ziti agent stats
(cd "${COMPOSE_PROJECT_DIR}" && docker compose restart ziti-router)
retry 30 3 docker compose -f "${COMPOSE_PROJECT_DIR}/compose.yml" \
exec -T ziti-router ziti agent stats
# Restart zrok2 services — their Ziti SDK connections are stale after the
# overlay restart (cached router IPs/sessions are invalid).
(cd "${COMPOSE_PROJECT_DIR}" && docker compose restart zrok2-controller)
retry 30 3 docker compose -f "${COMPOSE_PROJECT_DIR}/compose.yml" \
exec -T zrok2-controller curl -sf -o /dev/null \
-H "Accept: application/zrok.v1+json" http://127.0.0.1:18080/api/v1/version
(cd "${COMPOSE_PROJECT_DIR}" && docker compose restart zrok2-frontend)
# Restart the metrics bridge so it picks up the now-existing events file.
(cd "${COMPOSE_PROJECT_DIR}" && docker compose --profile metrics restart zrok2-metrics-bridge) 2>/dev/null || true
log_pass "stack restarted with events config"
# ============================================================
# Phase 3: Verify API
# ============================================================
@@ -268,7 +375,7 @@ retry 10 3 curl -sf -H "${ZROK2_ACCEPT}" \
_version=$(curl -sf -H "${ZROK2_ACCEPT}" \
"${ZROK2_API_ENDPOINT}/api/v2/versions" | \
python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('controllerVersion','?'))")
grep -oP '"controllerVersion"\s*:\s*"\K[^"]+' || echo "?")
log_pass "API responded: controllerVersion=${_version}"
log_pass "stack verified — ZROK2_API_ENDPOINT=${ZROK2_API_ENDPOINT} ZROK2_ADMIN_TOKEN=${ZROK2_ADMIN_TOKEN}"
@@ -279,38 +386,12 @@ log_pass "stack verified — ZROK2_API_ENDPOINT=${ZROK2_API_ENDPOINT} ZROK2_ADMI
log_section "Phase 4: Canary public-proxy looper"
# Create a test account and enable an environment inside the controller container.
# The canary runs inside the container where the zrok2 binary and Ziti overlay are available.
_canary_token=$(docker compose -f "${COMPOSE_PROJECT_DIR}/compose.yml" \
exec -T -e ZROK2_ADMIN_TOKEN="${ZROK2_ADMIN_TOKEN}" \
zrok2-controller zrok2 admin create account \
"canary-$(date +%s)@zrok.internal" "canarypass" 2>/dev/null)
if [[ -n "${_canary_token}" ]]; then
log_info "canary account token: ${_canary_token}"
# Enable, run canary, disable — all inside the container
if docker compose -f "${COMPOSE_PROJECT_DIR}/compose.yml" \
exec -T \
-e ZROK2_API_ENDPOINT="http://zrok2-controller:${ZROK2_CTRL_PORT:-18080}" \
-e ZROK2_DANGEROUS_CANARY=1 \
-e HOME=/tmp/canary-home \
zrok2-controller sh -c "
mkdir -p /tmp/canary-home/.zrok2
echo '{\"v\":\"v0.4\"}' > /tmp/canary-home/.zrok2/metadata.json
printf '{\"apiEndpoint\":\"http://zrok2-controller:${ZROK2_CTRL_PORT:-18080}\"}' > /tmp/canary-home/.zrok2/config.json
zrok2 enable '${_canary_token}' --description canary-test &&
zrok2 test canary public-proxy --iterations 3 --loopers 1 \
--min-payload 256 --max-payload 256 --min-pacing 1s --max-pacing 1s &&
zrok2 disable
" 2>&1; then
log_pass "canary public-proxy looper passed"
else
log_info "canary looper failed (may need frontend reachable from container)"
fi
else
log_info "could not create canary account — skipping canary test"
fi
# The canary runs in a host-networked container defined in compose.canary.yml.
# Host networking means *.localhost resolves to 127.0.0.1, reaching the
# frontend at its published port. The canary service creates an account,
# enables an environment, exercises the public frontend, and disables.
(cd "${COMPOSE_PROJECT_DIR}" && docker compose run --rm canary) 2>&1
log_pass "canary public-proxy looper passed"
# ============================================================
# Phase 5: Verify metrics pipeline (InfluxDB has data from canary)
@@ -318,14 +399,16 @@ fi
log_section "Phase 5: Verify metrics pipeline"
# Query InfluxDB directly from the influxdb container.
# Query InfluxDB inside the influxdb container. The metrics profile adds
# RabbitMQ + InfluxDB. Canary traffic from Phase 4 should have produced
# fabric.usage events that flow through the pipeline.
log_info "waiting up to 90s for metrics to appear in InfluxDB..."
_metrics_found=false
for _attempt in $(seq 1 18); do
_count=$(docker compose -f "${COMPOSE_PROJECT_DIR}/compose.yml" \
exec -T influxdb influx query \
'from(bucket: "zrok") |> range(start: -5m) |> count()' \
--org zrok --token "${ZROK2_INFLUX_TOKEN:-}" --raw 2>/dev/null \
'from(bucket: "zrok2") |> range(start: -5m) |> count()' \
--org zrok2 --token "${ZROK2_INFLUX_TOKEN:-}" --raw 2>/dev/null \
| grep -c ',' || true)
if (( _count > 0 )); then
_metrics_found=true
@@ -337,5 +420,10 @@ done
if [[ "${_metrics_found}" == "true" ]]; then
log_pass "metrics pipeline verified: InfluxDB has data (${_count} series)"
else
log_info "metrics pipeline: no data in InfluxDB after 90s (metrics profile may not be enabled)"
log_error "metrics pipeline: no data in InfluxDB after 90s"
(cd "${COMPOSE_PROJECT_DIR}" && docker compose logs zrok2-metrics-bridge --tail=10) 2>&1 || true
(cd "${COMPOSE_PROJECT_DIR}" && docker compose exec -T rabbitmq rabbitmqctl list_queues) 2>&1 || true
docker compose -f "${COMPOSE_PROJECT_DIR}/compose.yml" \
exec -T ziti-controller wc -l /ziti-controller/fabric-usage.json 2>&1 || true
exit 1
fi
@@ -77,13 +77,21 @@ _zrok2_ctrl_alive() {
kill -0 "$CTRL_PID" 2>/dev/null
}
# ── Step 1: Generate controller config (Docker-minimal) ─────────────────────
# ── Step 1: Generate controller config ────────────────────────────────────────
#
# Omits bridge, dynamic_proxy_controller, and metrics sections — those
# require local AMQP/InfluxDB that are external containers in Docker.
# When ZROK2_METRICS_ENABLED=true, includes bridge (fileSource → amqpSink)
# and metrics (amqpSource → InfluxDB) sections for the metrics pipeline.
mkdir -p "$CONFIG_DIR"
AMQP_URL="${ZROK2_AMQP_URL:-amqp://guest:guest@rabbitmq:5672}"
INFLUX_URL="${ZROK2_INFLUX_URL:-http://influxdb:8086}"
INFLUX_ORG="${ZROK2_INFLUX_ORG:-zrok2}"
INFLUX_BUCKET="${ZROK2_INFLUX_BUCKET:-zrok2}"
INFLUX_TOKEN="${ZROK2_INFLUX_TOKEN:-}"
# Path to fabric-usage.json inside the metrics-bridge container (shared volume).
FABRIC_USAGE_PATH="${ZROK2_FABRIC_USAGE_PATH:-/ziti-data/fabric-usage.json}"
if [[ ! -f "$CTRL_CONFIG" ]]; then
info "Generating $CTRL_CONFIG..."
cat > "$CTRL_CONFIG" <<CTRLEOF
@@ -111,6 +119,33 @@ maintenance:
check_frequency: 15m
batch_limit: 500
CTRLEOF
# Append metrics pipeline sections when enabled.
if [[ "${ZROK2_METRICS_ENABLED:-false}" == "true" ]]; then
info "Metrics enabled — adding bridge and metrics sections to $CTRL_CONFIG"
cat >> "$CTRL_CONFIG" <<METRICSEOF
bridge:
source:
type: fileSource
path: ${FABRIC_USAGE_PATH}
sink:
type: amqpSink
url: ${AMQP_URL}
queue_name: events
metrics:
agent:
source:
type: amqpSource
url: ${AMQP_URL}
queue_name: events
influx:
url: "${INFLUX_URL}"
bucket: ${INFLUX_BUCKET}
org: ${INFLUX_ORG}
token: "${INFLUX_TOKEN}"
METRICSEOF
fi
chmod 640 "$CTRL_CONFIG"
info "Controller config written to $CTRL_CONFIG"
else
@@ -179,6 +214,20 @@ info "Namespace '${ZROK2_NAMESPACE_TOKEN}' ready"
retry 5 3 "map namespace to frontend" step_map_namespace_frontend
info "Namespace-frontend mapping ready"
# ── Step 6b: Create dynamicProxyController identity and Ziti resources ───────
#
# The dynamic proxy controller provides real-time AMQP-based mapping updates
# to the frontend, required for named public shares. The bootstrap library's
# step_dynamic_proxy_controller() creates the identity, Ziti service, and
# policies. CONTROLLER_HOME must be set so the identity file lands in the
# right place.
export CONTROLLER_HOME="${HOME}"
export ZROK2_AMQP_URL="${AMQP_URL}"
export CTRL_CONFIG
retry 5 3 "create dynamicProxyController" step_dynamic_proxy_controller
info "dynamicProxyController ready"
# ── Step 7: Save frontend identity ───────────────────────────────────────────
if [[ -f "${HOME}/.zrok2/identities/public.json" ]]; then
@@ -194,6 +243,33 @@ kill "$CTRL_PID" 2>/dev/null || true
wait "$CTRL_PID" 2>/dev/null || true
trap - EXIT
# ── Step 8b: Generate full frontend config (AMQP-backed dynamic proxy) ────────
#
# Overwrite the simple frontend.yaml with the full v1 config that uses
# amqp_subscriber + controller (gRPC via dynamicProxyController Ziti service)
# for real-time mapping updates. Required for named public shares.
info "Generating full AMQP-backed frontend config..."
cat > "$FRONTEND_CONFIG" <<FEEOF
v: 1
frontend_token: ${FRONTEND_TOKEN}
identity: public
bind_address: 0.0.0.0:${ZROK2_FRONTEND_PORT:-8080}
host_match: ${ZROK2_DNS_ZONE}
mapping_refresh_interval: 1m
amqp_subscriber:
url: ${ZROK2_AMQP_URL:-amqp://guest:guest@rabbitmq:5672}
exchange_name: dynamicProxy
controller:
identity_path: ${CONFIG_DIR}/public.json
service_name: dynamicProxyController
FEEOF
chmod 640 "$FRONTEND_CONFIG"
info "Full frontend config written to $FRONTEND_CONFIG"
# ── Step 9: Fix ownership for non-root zrok2 containers ─────────────────────
chown -R 2171:2171 /var/lib/zrok2
+71 -20
View File
@@ -725,18 +725,15 @@ fi
# ============================================================
log_section "Phase 10: Canary public-proxy looper"
# Create a fresh account for the canary via the REST API (avoids CLI endpoint
# precedence issues with the operator's enabled environment).
# Create a fresh canary account and enable an environment. Use an isolated
# HOME so the operator's enabled environment doesn't interfere. The zrok2
# admin CLI uses ZROK2_ADMIN_TOKEN + ZROK2_API_ENDPOINT from the environment.
# zrok2 enable bootstraps the environment in one shot when ZROK2_API_ENDPOINT
# is set.
CANARY_HOME="$(mktemp -d)"
mkdir -p "${CANARY_HOME}/.zrok2"
echo '{"v":"v0.4"}' > "${CANARY_HOME}/.zrok2/metadata.json"
printf '{"apiEndpoint":"%s"}' "${ZROK2_API_ENDPOINT}" > "${CANARY_HOME}/.zrok2/config.json"
_canary_token=$(curl -sf \
-H "X-TOKEN: ${ZROK2_ADMIN_TOKEN}" \
-H "Content-Type: application/zrok.v1+json" \
-d "{\"email\":\"canary-$(date +%s)@zrok.internal\",\"password\":\"canarypass\"}" \
"${ZROK2_API_ENDPOINT}/api/v2/account" | python3 -c "import sys,json; print(json.load(sys.stdin)['accountToken'])")
_canary_token=$(HOME="${CANARY_HOME}" "${ZROK2_BIN}" admin create account \
"canary-$(date +%s)@zrok.internal" "canarypass")
log_info "canary account token: ${_canary_token}"
HOME="${CANARY_HOME}" "${ZROK2_BIN}" enable "${_canary_token}" --description "canary-test"
@@ -748,14 +745,65 @@ if [[ "${_frontend_port}" != "443" ]]; then
_canary_flags+=(--http --frontend-port "${_frontend_port}")
fi
if HOME="${CANARY_HOME}" ZROK2_DANGEROUS_CANARY=1 \
"${ZROK2_BIN}" test canary public-proxy "${_canary_flags[@]}"; then
log_pass "canary public-proxy looper passed"
else
log_error "canary public-proxy looper failed"
log_info "continuing despite canary failure"
HOME="${CANARY_HOME}" ZROK2_DANGEROUS_CANARY=1 \
"${ZROK2_BIN}" test canary public-proxy "${_canary_flags[@]}"
log_pass "canary public-proxy looper passed"
# ── Named share test ─────────────────────────────────────────────────────────
# Create a share with an explicit name (--name-selection) and verify the
# AMQP-backed dynamic frontend serves it at the expected URL.
log_info "testing named share via dynamic frontend..."
_share_name="citest-$(date +%s)"
_scheme="http"
if [[ "${_frontend_port}" == "443" ]]; then _scheme="https"; fi
_share_url="${_scheme}://${_share_name}.${ZROK2_DNS_ZONE}:${_frontend_port}/"
# Start zrok2's built-in test endpoint as a backend.
"${ZROK2_BIN}" test endpoint --port 19999 &>/dev/null &
_http_pid=$!
# Pre-create the name (v2 equivalent of "zrok reserve").
HOME="${CANARY_HOME}" "${ZROK2_BIN}" create name "${_share_name}"
# Create the named share in the background (long-running). Keep stderr
# visible so share creation errors appear in the log.
HOME="${CANARY_HOME}" "${ZROK2_BIN}" share public http://127.0.0.1:19999 \
--name-selection "public:${_share_name}" --backend-mode proxy --headless >/dev/null &
_share_pid=$!
# Give the share time to register with the controller and propagate via AMQP.
sleep 5
# Verify the share process is still alive (didn't exit with an error).
if ! kill -0 "${_share_pid}" 2>/dev/null; then
log_error "named share process exited prematurely"
wait "${_share_pid}" 2>/dev/null || true
kill "${_http_pid}" 2>/dev/null || true
exit 1
fi
# Wait for the share to propagate to the dynamic frontend.
log_info "waiting for named share at ${_share_url} ..."
# Poll until the frontend routes the named share and the content matches.
_attempts=30
while (( _attempts-- > 0 )); do
if curl -sf "${_share_url}" 2>/dev/null | grep -qi "zrok"; then
break
fi
sleep 2
done
if (( _attempts < 0 )); then
log_error "named share '${_share_name}' not reachable or content mismatch after 60s"
kill "${_share_pid}" "${_http_pid}" 2>/dev/null || true
exit 1
fi
log_pass "named share '${_share_name}' content verified on the dynamic frontend"
# Clean up share and backend.
kill "${_share_pid}" "${_http_pid}" 2>/dev/null || true
wait "${_share_pid}" 2>/dev/null || true
wait "${_http_pid}" 2>/dev/null || true
# Disable the canary environment
HOME="${CANARY_HOME}" "${ZROK2_BIN}" disable 2>/dev/null || true
@@ -764,7 +812,7 @@ HOME="${CANARY_HOME}" "${ZROK2_BIN}" disable 2>/dev/null || true
# ============================================================
log_section "Phase 11: Verify metrics pipeline"
_influx_token=$(grep -A5 'influx:' /etc/zrok2/ctrl.yml | grep 'token:' | head -1 | awk -F'"' '{print $2}')
_influx_token=$(sudo grep -A5 'influx:' /etc/zrok2/ctrl.yml | grep 'token:' | head -1 | awk -F'"' '{print $2}' || true)
if [[ -n "${_influx_token}" ]]; then
# Wait for metrics to propagate through the pipeline (bridge → RabbitMQ → controller → InfluxDB).
# The default metricsReportInterval is 60s, so we may need to wait.
@@ -786,11 +834,14 @@ if [[ -n "${_influx_token}" ]]; then
log_pass "metrics pipeline verified: InfluxDB has data (${_count} series)"
else
log_error "metrics pipeline: no data in InfluxDB after 90s"
log_info "check: systemctl status zrok2-metrics-bridge, rabbitmqctl list_queues"
log_info "continuing despite metrics verification failure"
sudo systemctl status zrok2-metrics-bridge --no-pager 2>&1 | head -5 || true
sudo rabbitmqctl list_queues 2>&1 || true
sudo journalctl -u zrok2-metrics-bridge --no-pager -n 10 2>&1 || true
exit 1
fi
else
log_info "could not extract InfluxDB token from ctrl.yml — skipping metrics check"
log_error "could not extract InfluxDB token from ctrl.yml"
exit 1
fi
# ============================================================
+78 -16
View File
@@ -899,20 +899,27 @@ step_dynamic_proxy_controller() {
info "Dial policy ${SERVICE_NAME}-dial already exists"
fi
# Add the dynamic_proxy_controller block to ctrl.yml now that the identity exists.
# The controller will be restarted in step_start_services to pick this up.
# Insert the dynamic_proxy_controller block into ctrl.yml now that the
# identity file exists. We use sed to insert BEFORE the 'endpoint:' line
# rather than appending to the end — the Go YAML parser reliably reads
# content that's part of the original file structure but may ignore
# content appended after the initial write.
if ! grep -q 'dynamic_proxy_controller:' "$CTRL_CONFIG" 2>/dev/null; then
info "Adding dynamic_proxy_controller to $CTRL_CONFIG..."
cat >> "$CTRL_CONFIG" <<DPCEOF
dynamic_proxy_controller:
identity_path: ${CONTROLLER_HOME}/.zrok2/identities/dynamicProxyController.json
service_name: dynamicProxyController
amqp_publisher:
url: ${ZROK2_AMQP_URL}
exchange_name: dynamicProxy
DPCEOF
info "dynamic_proxy_controller block added to config"
info "Inserting dynamic_proxy_controller into $CTRL_CONFIG..."
sed -i "/^endpoint:/i\\
\\
dynamic_proxy_controller:\\
identity_path: ${CONTROLLER_HOME}/.zrok2/identities/dynamicProxyController.json\\
service_name: dynamicProxyController\\
amqp_publisher:\\
url: ${ZROK2_AMQP_URL}\\
exchange_name: dynamicProxy\\
" "$CTRL_CONFIG"
# Verify the block was written correctly.
if ! grep -q 'dynamic_proxy_controller:' "$CTRL_CONFIG"; then
die "Failed to write dynamic_proxy_controller to $CTRL_CONFIG"
fi
info "dynamic_proxy_controller inserted into config"
fi
info "dynamicProxyController Ziti resources are configured"
@@ -1192,13 +1199,68 @@ step_start_services() {
sleep 2
fi
# Restart controller to pick up dynamic_proxy_controller config, metrics, and new overrides
# Restart controller to pick up dynamic_proxy_controller config, metrics, and new overrides.
# The frontend fatally exits if the dynamicProxyController gRPC service is
# unreachable, so we must wait for the controller to fully initialize its
# Ziti identity and register a terminator before starting the frontend.
info "Restarting zrok2-controller to apply full configuration..."
systemctl restart zrok2-controller
sleep 2
if ! grep -q 'dynamic_proxy_controller:' "$CTRL_CONFIG"; then
warn "dynamic_proxy_controller not found in $CTRL_CONFIG — frontend may crash-loop"
fi
sync
systemctl stop zrok2-controller
# Ensure the old process is fully dead and the port is released.
sleep 3
# Drop the kernel's page cache to force the new process to read from disk.
# This works around a persistent issue where the Go YAML parser in the new
# process reads stale file content despite sed -i having atomically replaced
# the file (the old inode's cached pages may still be served to new readers
# of the same path before the dentry cache updates).
echo 1 > /proc/sys/vm/drop_caches 2>/dev/null || true
systemctl start zrok2-controller
# Wait for the HTTP API to respond (confirms the process is alive).
wait_for 60 2 "zrok2-controller API after restart" \
curl -sf -H "Accept: application/zrok.v1+json" \
"http://127.0.0.1:${ZROK2_CTRL_PORT}/api/v1/version" -o /dev/null
# Verify the running controller loaded the dynamic_proxy_controller section.
# If it didn't (stale file read), restart once more.
_dpc_loaded() {
journalctl -u zrok2-controller --no-pager -n 1 -o cat 2>/dev/null \
| grep -q 'dynamic_proxy_controller.*Config'
}
if ! _dpc_loaded; then
warn "controller loaded stale config (dynamic_proxy_controller: nil) — restarting again"
systemctl restart zrok2-controller
wait_for 60 2 "zrok2-controller API after second restart" \
curl -sf -H "Accept: application/zrok.v1+json" \
"http://127.0.0.1:${ZROK2_CTRL_PORT}/api/v1/version" -o /dev/null
fi
# Wait for the dynamicProxyController Ziti service to have at least one
# terminator — this means the controller has connected its Ziti identity
# and is ready to serve gRPC requests from the frontend.
_dpc_ready() {
ziti edge list terminators 'service.name="dynamicProxyController"' -j 2>/dev/null \
| jq -e '.data | length > 0' &>/dev/null
}
wait_for 60 3 "dynamicProxyController terminator" _dpc_ready
info "dynamicProxyController is bound and ready"
# Re-apply g+w on the fabric-usage.json directory — systemd's
# StateDirectory= resets /var/lib/ziti-controller to 755 on every
# controller restart, undoing the chmod from step 16.
local usage_dir
usage_dir="$(dirname "$FABRIC_USAGE_PATH")"
chmod g+w "$usage_dir"
systemctl enable --now zrok2-frontend
info "zrok2-frontend started"
# Restart the metrics bridge — it may have been crash-looping while
# waiting for the directory permissions to be fixed.
systemctl restart zrok2-metrics-bridge 2>/dev/null || true
}
# ── Main ─────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -11,7 +11,7 @@ User=zrok2-metrics-bridge
Group=zrok2-metrics-bridge
WorkingDirectory=/var/lib/zrok2-metrics-bridge
UMask=0007
ExecStart=/usr/bin/zrok2-metrics-bridge-bridge.bash /etc/zrok2/ctrl.yml
ExecStart=/usr/bin/zrok2-metrics-bridge.bash /etc/zrok2/ctrl.yml
Restart=always
RestartSec=3
@@ -10,6 +10,12 @@ Compose. The stack runs the same components as the
controller, frontend, and PostgreSQL — using official container images with
runtime configuration via environment variables.
:::info Single-host deployment
This compose stack runs one frontend instance. For higher throughput or availability, run multiple
frontend instances behind a reverse proxy (e.g., Caddy or Traefik). See
[Scaling zrok frontends](/docs/zrok/self-hosting/scaling-frontends/) for details.
:::
## Prerequisites
- Docker Engine 24+ with the Compose plugin (`docker compose`)
@@ -83,16 +89,26 @@ These match the same variable names used in the
docker compose up -d
```
On first start, two init services run before the main services:
On first start, init services run before the main services to bootstrap the stack. The
`zrok2-init` container performs the same steps as the
[Linux manual setup](/docs/zrok/self-hosting/deployment/linux/#manual-setup) — automated
end-to-end:
- **`ziti-router-init`** — creates the edge router in the Ziti controller and saves its enrollment token for the router service to consume
- **`ziti-router-init`** — creates the edge router in the Ziti controller and saves its enrollment
token for the router service to consume
- **`zrok2-init`** — bootstraps the full zrok2 stack:
1. Generates `ctrl.yaml` and `frontend.yaml` from environment variables
2. Waits for the Ziti controller to be healthy
3. Waits for PostgreSQL to be ready
4. Runs `zrok2 admin bootstrap` to initialize the database and create Ziti identities
5. Creates the dynamic frontend and public namespace
6. Saves the public frontend identity for the frontend container
2. Waits for the Ziti controller, PostgreSQL, and RabbitMQ to be healthy
3. Runs `zrok2 admin bootstrap` to initialize the database and create Ziti identities
4. Starts a temporary controller to run admin commands
5. Creates the dynamic frontend and public namespace with namespace-frontend mapping
6. Creates the `dynamicProxyController` identity, Ziti service, and routing policies — this is the
gRPC service that pushes real-time share mapping updates to the frontend via AMQP (required for
[named shares](#verify-named-shares-work))
7. Appends the `dynamic_proxy_controller` section to `ctrl.yaml`
8. Generates the full AMQP-backed `frontend.yaml` (v1 format with `amqp_subscriber` and
`controller` sections)
9. Saves identity files and fixes ownership for the non-root service containers
This takes 12 minutes. Monitor progress:
@@ -100,8 +116,9 @@ This takes 12 minutes. Monitor progress:
docker compose logs -f zrok2-init
```
Once `zrok2-init` exits successfully, the controller and frontend start
automatically.
Once `zrok2-init` exits successfully, the controller and frontend start automatically. The controller
reads the complete `ctrl.yaml` (including `dynamic_proxy_controller`) and binds the gRPC service on
the Ziti overlay. The frontend connects to it for real-time mapping updates.
## Verify the Stack
@@ -140,6 +157,48 @@ zrok2 enable <token>
Replace `<token>` with the enable token from the previous step.
## Verify Named Shares Work
After enabling your environment, verify that the AMQP-backed dynamic frontend serves named shares:
```bash
# Create a named share (runs in foreground — use a separate terminal)
zrok2 share public http://127.0.0.1:8080 --name-selection public:mytest
# From another terminal, verify the frontend routes it
curl -sf http://mytest.share.example.com:8080/
```
Named shares use the `dynamicProxyController` gRPC service to push mapping updates to the frontend
in real time via AMQP. This is set up automatically by `zrok2-init` during the initial bootstrap
(see [Start the Stack](#start-the-stack) for details).
## Making the Frontend Publicly Accessible
By default, the zrok2 controller and frontend ports bind to `127.0.0.1` (localhost only) for
safety. To make the frontend reachable from the internet without TLS, set `ZROK2_INSECURE_INTERFACE`
in your `.env`:
```bash
ZROK2_INSECURE_INTERFACE=0.0.0.0
```
This publishes ports 18080 (controller API) and 8080 (frontend) insecurely on all interfaces. For production, use the
[Caddy TLS overlay](#optional-enable-tls-with-caddy) instead.
Alternatively, publish only the frontend port by overriding the port mapping in a
`compose.override.yml`:
```yaml
services:
zrok2-frontend:
ports:
- "0.0.0.0:8080:8080"
```
The Ziti router data-plane port (3022) already binds to `0.0.0.0` because SDK clients must reach it
from outside the Docker network and Ziti uses TLS for security.
## Optional: Enable TLS with Caddy
For production deployments, enable TLS using the Caddy overlay. Caddy
@@ -185,6 +244,24 @@ the `bridge` and `metrics` sections to `ctrl.yaml` and restart the controller
(see the [Linux guide](/docs/zrok/self-hosting/deployment/linux/) for the full
configuration reference).
### Verify InfluxDB has data
After creating a share and sending some traffic through it, verify metrics arrived in InfluxDB:
```bash
docker compose exec influxdb influx query \
'from(bucket: "zrok2") |> range(start: -5m) |> count()' \
--org zrok2 --token "${ZROK2_INFLUX_TOKEN}" --raw
```
A successful result contains CSV rows with count values. If no data appears, check the metrics
bridge logs and RabbitMQ:
```bash
docker compose logs zrok2-metrics-bridge --tail=50
docker compose exec rabbitmq rabbitmqctl list_queues
```
## Optional: Building from Source
For development or CI, you can build the zrok2 image from source instead of
@@ -4,6 +4,12 @@ title: Self-hosting guide for Linux
sidebar_label: Linux
---
:::info Single-host deployment
This guide deploys all zrok components — controller, frontend, and metrics bridge — on a single
Linux server. This is the simplest production-ready configuration. To scale the frontend for higher
throughput or availability, see [Scaling zrok frontends](../../scaling-frontends.md).
:::
## Before you begin
This will get you up and running with a self-hosted instance of zrok. I'll assume you have the following:
@@ -247,36 +253,43 @@ sudo systemctl enable --now zrok2-controller
### Step 6: Create a dynamic frontend
With `ZROK2_ADMIN_TOKEN` and `ZROK2_API_ENDPOINT` set, create a dynamic frontend. Use the Ziti ID of the `public` identity created by `zrok2 admin bootstrap` (shown in its output):
With `ZROK2_ADMIN_TOKEN` and `ZROK2_API_ENDPOINT` set, create a dynamic frontend. Use the Ziti ID of the
`public` identity created by `zrok2 admin bootstrap` (shown in its output):
```bash
zrok2 admin create frontend --dynamic <public-ziti-id> public
```
This outputs a frontend token (e.g., `zEjQqHliYXF6`). Save it — you'll need it for the frontend configuration and namespace mapping.
This outputs a **frontend token** (e.g., `zEjQqHliYXF6`). Save it — you'll need it for the frontend
configuration and namespace mapping.
The `--dynamic` flag enables the v2.0 dynamicProxy mode with namespace-based naming.
### Step 7: Create the dynamicProxyController
### Step 7: Create dynamicProxyController identity and Ziti resources
The dynamic proxy controller is a gRPC service that pushes real-time share mapping updates to the
frontend over the Ziti overlay via AMQP. Without it, the frontend cannot route named shares (e.g.,
`myapp.zrok.example.com`) — only random-token shares would work with polling.
Create a Ziti identity for the dynamicProxyController gRPC service:
**Create the Ziti identity:**
```bash
zrok2 admin create identity dynamicProxyController
```
Then create the Ziti service and policies. Log in to Ziti first, then find the Ziti ID of the new identity:
**Create the Ziti service and routing policies.** Log in to Ziti first:
```bash
ziti edge login <your-ziti-controller>:<port> -y -u admin -p <password>
```
Then find the Ziti ID of the new identity and create the service + policies:
```bash
# Set the Ziti ID from the identity you just created
CONTROLLER_ZID="<ziti-id-from-above>"
# Look up the Ziti ID from the identity you just created
CONTROLLER_ZID=$(ziti edge list identities 'name="dynamicProxyController"' -j \
| jq -r '.data[0].id')
SERVICE_NAME="dynamicProxyController"
# Create the service
# Create the Ziti service
ziti edge create service "$SERVICE_NAME"
# Allow edge routers to host the service
@@ -295,30 +308,47 @@ ziti edge create sp "${SERVICE_NAME}-dial" Dial \
--service-roles "@${SERVICE_NAME}"
```
Copy the identity file to the `zrok2-controller` service user's directory:
**Place the identity files** where the systemd service users can read them:
```bash
# dynamicProxyController identity → zrok2-controller service user
sudo mkdir -p /var/lib/zrok2-controller/.zrok2/identities
sudo cp ~/.zrok2/identities/dynamicProxyController.json \
/var/lib/zrok2-controller/.zrok2/identities/
sudo chown -R zrok2-controller:zrok2-controller /var/lib/zrok2-controller/.zrok2
```
Also copy the `public` frontend identity to the `zrok2-frontend` service user:
```bash
# public frontend identity zrok2-frontend service user
sudo mkdir -p /var/lib/zrok2-frontend/.zrok2/identities
sudo cp ~/.zrok2/identities/public.json \
/var/lib/zrok2-frontend/.zrok2/identities/
sudo chown -R zrok2-frontend:zrok2-frontend /var/lib/zrok2-frontend/.zrok2
```
Restart the controller to activate the dynamicProxyController:
**Add the `dynamic_proxy_controller` section** to `/etc/zrok2/ctrl.yml`:
```yaml
dynamic_proxy_controller:
identity_path: /var/lib/zrok2-controller/.zrok2/identities/dynamicProxyController.json
service_name: dynamicProxyController
amqp_publisher:
url: amqp://guest:guest@127.0.0.1:5672
exchange_name: dynamicProxy
```
Restart the controller to activate it:
```bash
sudo systemctl restart zrok2-controller
```
:::tip Why is this needed?
When a user creates a named share (`zrok2 share public --name-selection public:myapp ...`), the
controller publishes a mapping update to the `dynamicProxy` AMQP exchange. The frontend subscribes to
this exchange and immediately starts routing `myapp.zrok.example.com` to the share's backend — no
polling delay. The `dynamicProxyController` Ziti service is the gRPC channel over the Ziti overlay
that delivers these mapping updates securely.
:::
### Step 8: Create a namespace
Namespaces organize share names (similar to DNS zones). Create a public namespace:
@@ -327,7 +357,8 @@ Namespaces organize share names (similar to DNS zones). Create a public namespac
zrok2 admin create namespace --token public --open zrok.example.com
```
The `--open` flag allows any account to create names in this namespace. Without it, users need explicit grants.
The `--open` flag allows any account to create names in this namespace. Without it, users need
explicit grants.
### Step 9: Map namespace to frontend
@@ -337,6 +368,8 @@ Link the namespace to the dynamic frontend so shares are served by this frontend
zrok2 admin create namespace-frontend public <frontend-token> --default
```
Replace `<frontend-token>` with the token from Step 6.
### Step 10: Configure the dynamic frontend
Create `/etc/zrok2/frontend.yml`:
@@ -347,6 +380,7 @@ v: 1
frontend_token: <frontend-token-from-step-6>
identity: public
bind_address: 0.0.0.0:443
host_match: zrok.example.com
mapping_refresh_interval: 1m
amqp_subscriber:
@@ -357,13 +391,15 @@ controller:
identity_path: /var/lib/zrok2-frontend/.zrok2/identities/public.json
service_name: dynamicProxyController
host_match: zrok.example.com
tls:
cert_path: /etc/letsencrypt/live/zrok.example.com/fullchain.pem
key_path: /etc/letsencrypt/live/zrok.example.com/privkey.pem
```
The `amqp_subscriber` and `controller` sections connect the frontend to the dynamicProxyController
gRPC service (Step 7) for real-time mapping updates. The `host_match` value must match the namespace
name (Step 8).
Set file ownership:
```bash
@@ -371,7 +407,8 @@ sudo chown zrok2-frontend:zrok2-frontend /etc/zrok2/frontend.yml
sudo chmod 640 /etc/zrok2/frontend.yml
```
If the TLS certificate files are only readable by root (common with Let's Encrypt), grant read access to the service users:
If the TLS certificate files are only readable by root (common with Let's Encrypt), grant read access
to the service users:
```bash
sudo groupadd --system zrok2-tls 2>/dev/null || true
@@ -382,7 +419,8 @@ sudo chmod g+r /etc/letsencrypt/archive/zrok.example.com/*
sudo chmod o+x /etc/letsencrypt /etc/letsencrypt/live /etc/letsencrypt/archive
```
For a complete reference of all frontend options including OAuth, see the [Dynamic Proxy Frontend Guide](../../dynamic-proxy.md).
For a complete reference of all frontend options including OAuth, see the
[Dynamic Proxy Frontend Guide](../../dynamic-proxy.md).
### Step 11: Start the frontend
@@ -396,6 +434,22 @@ Verify it's running:
sudo journalctl -u zrok2-frontend -f
```
### Verify named shares work
After creating a user account and enabling an environment (see below), test that the dynamic frontend
serves named shares:
```bash
# Create a named share (runs in foreground — use a separate terminal)
zrok2 share public http://127.0.0.1:8080 --name-selection public:mytest
# From another terminal, verify the frontend routes it
curl -sf https://mytest.zrok.example.com/
```
If the share is reachable at `mytest.zrok.example.com`, the AMQP-backed dynamic frontend is working
correctly.
### Step 12: Configure OpenZiti metrics events
The zrok metrics pipeline starts at the OpenZiti controller, which emits `fabric.usage` events. Add the following to your OpenZiti controller configuration:
@@ -459,7 +513,27 @@ Verify it's processing events:
sudo journalctl -u zrok2-metrics-bridge -f
```
Once traffic flows through shares, you should see log output from the controller confirming metrics are being written to InfluxDB. See [Configuring Limits](../../metrics-and-limits/configuring-limits.md) to enforce bandwidth and resource limits based on these metrics.
Once traffic flows through shares, you should see log output from the controller confirming metrics are being written to InfluxDB.
### Verify InfluxDB has data
After creating a share and sending some traffic through it, verify metrics arrived in InfluxDB:
```bash
influx query \
'from(bucket: "zrok") |> range(start: -5m) |> count()' \
--org zrok --token "<your-influx-token>" --raw
```
A successful result contains CSV rows with count values. If no data appears after 90 seconds, check the metrics bridge and RabbitMQ:
```bash
sudo systemctl status zrok2-metrics-bridge
sudo rabbitmqctl list_queues
sudo journalctl -u zrok2-metrics-bridge --no-pager -n 50
```
See [Configuring Limits](../../metrics-and-limits/configuring-limits.md) to enforce bandwidth and resource limits based on these metrics.
## Create a user account
@@ -0,0 +1,162 @@
---
title: Scaling zrok frontends
sidebar_label: Scaling frontends
sidebar_position: 47
---
The [Linux](/docs/zrok/self-hosting/deployment/linux/),
[Docker](/docs/zrok/self-hosting/deployment/docker/), and
[Kubernetes](/docs/zrok/self-hosting/deployment/kubernetes/) deployment guides
describe a single-host model where one frontend process handles all public
share traffic. This page explains how to run multiple frontend instances for
higher throughput and availability.
## How the dynamic frontend works
Each `zrok2 access dynamicProxy` process:
1. Loads a Ziti identity from a JSON file and connects to the Ziti overlay
2. Subscribes to an AMQP exchange (`dynamicProxy`) using an **ephemeral queue**
bound to its frontend token as the routing key
3. Queries the controller via gRPC (`dynamicProxyController` Ziti service) for
the initial set of share mappings
4. Listens on an HTTP/HTTPS address for incoming requests
5. Routes requests by matching the `Host` header against its in-memory mapping
table, proxying to the share's backend through the Ziti overlay
The AMQP queue is unique per process instance — when the controller publishes a
mapping update for a frontend token, **every instance** subscribed to that token
receives an independent copy. Instances do not compete for messages.
## Scaling approaches
### Option A: Multiple instances of one frontend (simplest)
Run multiple `zrok2 access dynamicProxy` processes that share the **same
frontend token and Ziti identity**. Place a load balancer in front of them.
```text
┌─ Frontend Instance A (same token, same identity)
Load Balancer ──────┤
└─ Frontend Instance B (same token, same identity)
```
Each instance:
- Uses the same `frontend.yaml` (with a different `bind_address` if co-located)
- Loads the same `public.json` Ziti identity file (read-only — no locking)
- Receives identical AMQP mapping updates independently
- Maintains its own in-memory mapping table
This is the simplest approach. No additional zrok admin commands are needed.
The frontend token and identity file can be copied to additional hosts.
### Option B: Separate frontends per instance
Create distinct frontend records in the controller, each with its own token and
optionally its own Ziti identity. Map each to the same namespace(s).
```bash
# Create additional frontends (each gets a unique token)
zrok2 admin create frontend --dynamic <public-ziti-id> frontend-2
zrok2 admin create frontend --dynamic <public-ziti-id> frontend-3
# Map them to the same namespace
zrok2 admin create namespace-frontend public <frontend-2-token>
zrok2 admin create namespace-frontend public <frontend-3-token>
```
Each frontend can share the same Ziti identity (`public.json`) or use separate
identities. Separate identities provide stronger isolation — if one identity is
compromised, the others are unaffected.
To create a separate identity for each frontend:
```bash
# Create a new identity for the second frontend
zrok2 admin create identity public-2
# Create the frontend using the new identity's Ziti ID
zrok2 admin create frontend --dynamic <public-2-ziti-id> frontend-2
```
Then configure each frontend's `frontend.yaml` with its own `frontend_token`,
`identity`, and `controller.identity_path`.
### Which approach to choose
| Concern | Option A (shared) | Option B (separate) |
| --------------------- | --------------------------------------- | -------------------------------------------- |
| Setup complexity | Lowest — copy files | More admin commands |
| Identity isolation | Shared | Independent |
| Namespace flexibility | All instances serve the same namespaces | Each frontend can serve different namespaces |
| AMQP routing | All instances share one routing key | Each has its own routing key |
| Monitoring | Instances are indistinguishable | Each frontend has a unique token in logs |
For most deployments, **Option A** is sufficient. Use **Option B** when you need
per-frontend namespace isolation, distinct monitoring identifiers, or defense in
depth for the Ziti identity.
## Load balancer configuration
Place a Layer 4 (TCP) or Layer 7 (HTTP) load balancer in front of the frontend
instances. The load balancer must:
- Forward the `Host` header unchanged (the frontend uses it for routing)
- Support WebSocket upgrade (for `zrok2 share` connections)
- Use sticky sessions if your frontends serve stateful backends (optional)
For TLS termination, either:
- Terminate TLS at the load balancer and forward plaintext to the frontends
- Pass TLS through to the frontends (each must have the certificate)
### Example: Caddy
```text
*.share.example.com {
reverse_proxy frontend-a:8080 frontend-b:8080
}
```
### Example: Docker Compose with Caddy
Plain Docker Compose does not load balance across replicas on a single port —
you need a reverse proxy. Remove `ports:` from the frontend service, scale it,
and let Caddy (or Nginx/Traefik) route to replicas via Docker DNS:
```yaml
services:
zrok2-frontend:
image: openziti/zrok2:latest
command: ["access", "public", "/config/frontend.yaml"]
deploy:
replicas: 3
volumes:
- zrok2-config:/config:ro
# No ports: — Caddy handles ingress
caddy:
image: caddy:2-alpine
ports:
- "0.0.0.0:443:443"
command: caddy reverse-proxy --from :443 --to zrok2-frontend:8080
```
Docker DNS resolves `zrok2-frontend` to all replica IPs, and Caddy
round-robins across them.
### Example: Kubernetes
The [Kubernetes guide](/docs/zrok/self-hosting/deployment/kubernetes/) supports
scaling via the `frontend.replicaCount` value in the Helm chart.
## Other components
- **zrok2-controller** — multiple controller instances can share the same
PostgreSQL database. Each publishes AMQP mapping updates independently.
Place a load balancer in front for the API endpoint.
- **zrok2-metrics-bridge** — can read `fabric.usage` events from a file
(single Ziti controller) or from an AMQP queue (multiple Ziti controllers).
The AMQP source mode supports scaling across a multi-controller Ziti
deployment.