Implement Canary Release
Qualify a canary deterministically, shift traffic by relative weight, and verify rollback.
Applies to:API7 Gateway 3.10.x
Roles:Platform engineer
Goal
Route requests with X-Canary: true to a controlled canary upstream, then progressively shift unqualified traffic by relative weight and prove an exact rollback to stable.
Define application readiness, error-rate, latency, and business-metric gates before a real rollout. Weighted routing is probabilistic, so a small sample diagnoses configuration but cannot prove an exact percentage.
Before you begin
export API7_ADMIN_URL="https://localhost:7443"
export API7_GATEWAY_GROUP_ID="default" # only the group the quickstart creates; otherwise copy the gateway group's UUID from Gateway Groups in the Dashboard
read -rp "Gateway group name for ADC (if used): " API7_GATEWAY_GROUP_NAME && export API7_GATEWAY_GROUP_NAME
export GATEWAY_URL="http://127.0.0.1:9080"
read -rp "Host or DNS name the gateway uses to reach this machine: " RELEASE_UPSTREAM_HOST && export RELEASE_UPSTREAM_HOST
read -rsp "Dashboard token: " API7_ADMIN_KEY && export API7_ADMIN_KEY && echo-
Confirm the selected group is ready:
curl -ksS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}\n' "${API7_ADMIN_URL}/api/gateway_groups" -H "X-API-KEY: ${API7_ADMIN_KEY}" curl -ksS --connect-timeout 3 --max-time 10 "${API7_ADMIN_URL}/api/gateway_groups/${API7_GATEWAY_GROUP_ID}/instances" -H "X-API-KEY: ${API7_ADMIN_KEY}" | grep -o '"status": *"Healthy"' | wc -lExpected:
200, then1or more. -
Python 3,
curl,jq,awk,grep, and GNUenvsubstare available. The gateway can reach${RELEASE_UPSTREAM_HOST}on ports18088and18089. -
Reserve files and ports:
for file in canary-upstream.py stable.pid canary.pid stable.log canary.log canary-adc.yaml.tmpl canary-adc.yaml canary-rollback-adc.yaml.tmpl canary-rollback-adc.yaml canary-cleanup.yaml canary-sample.txt; do test ! -e "${file}" || { echo "Stop: ${file} already exists"; exit 1; }; done python3 - <<'PY' import socket sockets = [] try: for port in (18088, 18089): probe = socket.socket() probe.bind(("0.0.0.0", port)) sockets.append(probe) finally: for probe in sockets: probe.close() print("canary upstream ports are free") PY -
For ADC, confirm label-selector support:
adc --version adc sync --help | grep -- '--label-selector'ADC resolves
--gateway-groupby display name; the compatibility Admin API uses the group's UUID.
Preflight both fixed Admin API IDs:
curl -ksS --connect-timeout 3 --max-time 10 -w '\nservice: %{http_code}\n' "${API7_ADMIN_URL}/apisix/admin/services/canary-guide-service-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
curl -ksS --connect-timeout 3 --max-time 10 -w '\nroute: %{http_code}\n' "${API7_ADMIN_URL}/apisix/admin/routes/canary-guide-route-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"Expected: both are absent. A 200 containing a resource means the ID is in use; stop or change every occurrence because PUT replaces that ID. URL IDs deliberately differ from names.
Steps
Step 1: Start controlled upstreams
cat > canary-upstream.py <<'PY'
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
port = int(os.environ["PORT"])
release = os.environ["RELEASE"]
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body = ("release=%s\n" % release).encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass
ThreadingHTTPServer(("0.0.0.0", port), Handler).serve_forever()
PY
PORT=18088 RELEASE=stable python3 canary-upstream.py > stable.log 2>&1 & echo $! > stable.pid
PORT=18089 RELEASE=canary python3 canary-upstream.py > canary.log 2>&1 & echo $! > canary.pid
test "$(curl -fsS --connect-timeout 3 --max-time 5 http://127.0.0.1:18088/)" = "release=stable"
test "$(curl -fsS --connect-timeout 3 --max-time 5 http://127.0.0.1:18089/)" = "release=canary"Step 2: Define complete traffic operations
Choose one tab and continue in the same shell; later steps call the functions defined here.
jq -n --arg host "${RELEASE_UPSTREAM_HOST}" '{name:"canary-guide-service",labels:{guide:"canary-release"},upstream:{name:"default",type:"roundrobin",scheme:"http",nodes:[{host:$host,port:18088,weight:100}]}}' \
| curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/services/canary-guide-service-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT -H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-
apply_canary() {
local canary_weight="$1" stable_weight="$2"
jq -n --arg host "${RELEASE_UPSTREAM_HOST}" --argjson canary "${canary_weight}" --argjson stable "${stable_weight}" '{name:"canary-guide-route",labels:{guide:"canary-release"},paths:["/release/canary"],methods:["GET"],service_id:"canary-guide-service-id",plugins:{"traffic-split":{rules:[{match:[{vars:[["http_x_canary","==","true"]]}],weighted_upstreams:[{weight:100,upstream:{type:"roundrobin",scheme:"http",nodes:[{host:$host,port:18089,weight:100}]}}]},{weighted_upstreams:[{weight:$canary,upstream:{type:"roundrobin",scheme:"http",nodes:[{host:$host,port:18089,weight:100}]}},{weight:$stable}]}]}}}' \
| curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/routes/canary-guide-route-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT -H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-
}
rollback_canary() {
jq -n --arg host "${RELEASE_UPSTREAM_HOST}" '{name:"canary-guide-route",labels:{guide:"canary-release"},paths:["/release/canary"],methods:["GET"],service_id:"canary-guide-service-id",plugins:{"traffic-split":{rules:[{weighted_upstreams:[{weight:0,upstream:{type:"roundrobin",scheme:"http",nodes:[{host:$host,port:18089,weight:100}]}},{weight:100}]}]}}}' \
| curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/routes/canary-guide-route-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT -H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-
}
apply_canary 0 100services:
- name: canary-guide-service
labels: {guide: canary-release}
upstream:
name: default
scheme: http
nodes:
- {host: "${RELEASE_UPSTREAM_HOST}", port: 18088, weight: 100}
routes:
- name: canary-guide-route
labels: {guide: canary-release}
uris: [/release/canary]
methods: [GET]
plugins:
traffic-split:
rules:
- match:
- vars:
- [http_x_canary, "==", "true"]
weighted_upstreams:
- weight: 100
upstream:
type: roundrobin
scheme: http
nodes:
- {host: "${RELEASE_UPSTREAM_HOST}", port: 18089, weight: 100}
- weighted_upstreams:
- weight: ${CANARY_WEIGHT}
upstream:
type: roundrobin
scheme: http
nodes:
- {host: "${RELEASE_UPSTREAM_HOST}", port: 18089, weight: 100}
- weight: ${STABLE_WEIGHT}services:
- name: canary-guide-service
labels: {guide: canary-release}
upstream:
name: default
scheme: http
nodes:
- {host: "${RELEASE_UPSTREAM_HOST}", port: 18088, weight: 100}
routes:
- name: canary-guide-route
labels: {guide: canary-release}
uris: [/release/canary]
methods: [GET]
plugins:
traffic-split:
rules:
- weighted_upstreams:
- weight: 0
upstream:
type: roundrobin
scheme: http
nodes:
- {host: "${RELEASE_UPSTREAM_HOST}", port: 18089, weight: 100}
- weight: 100apply_canary() {
export CANARY_WEIGHT="$1"
export STABLE_WEIGHT="$2"
envsubst '${RELEASE_UPSTREAM_HOST} ${CANARY_WEIGHT} ${STABLE_WEIGHT}' < canary-adc.yaml.tmpl > canary-adc.yaml
adc lint -f canary-adc.yaml
adc validate -f canary-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=canary-release
adc diff -f canary-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=canary-release
local adc_confirm
read -rp "Apply only this label-scoped diff? Type yes: " adc_confirm
test "${adc_confirm}" = "yes"
adc sync -f canary-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=canary-release
}
rollback_canary() {
envsubst '${RELEASE_UPSTREAM_HOST}' < canary-rollback-adc.yaml.tmpl > canary-rollback-adc.yaml
adc lint -f canary-rollback-adc.yaml
adc validate -f canary-rollback-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=canary-release
adc diff -f canary-rollback-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=canary-release
local adc_confirm
read -rp "Apply only this label-scoped diff? Type yes: " adc_confirm
test "${adc_confirm}" = "yes"
adc sync -f canary-rollback-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=canary-release
}
apply_canary 0 100Continue only if the diff affects this labeled service and route. ADC names are display names, not backend IDs.
Rules run in array order and the first matching rule wins. Keep the conditional rule first and the unconditional weighted rule last. In each weighted list, the item without upstream or upstream_id selects the stable service upstream. Weights are nonnegative integers and act as relative proportions.
Step 3: Prove deterministic qualification
for request in {1..10}; do
test "$(curl -fsS --connect-timeout 3 --max-time 10 "${GATEWAY_URL}/release/canary")" = "release=stable"
test "$(curl -fsS --connect-timeout 3 --max-time 10 "${GATEWAY_URL}/release/canary" -H 'X-Canary: true')" = "release=canary"
doneX-Canary is only a reproducible qualification signal for this guide. Use an authenticated, trusted condition for a production rollout.
Step 4: Shift unqualified traffic progressively
observe_canary() {
: > canary-sample.txt
for request in $(seq 1 200); do
curl -fsS --connect-timeout 3 --max-time 10 "${GATEWAY_URL}/release/canary" >> canary-sample.txt
done
stable_count="$(awk '$0 == "release=stable" {n++} END {print n+0}' canary-sample.txt)"
canary_count="$(awk '$0 == "release=canary" {n++} END {print n+0}' canary-sample.txt)"
test "$((stable_count + canary_count))" -eq 200
test "${stable_count}" -gt 0
test "${canary_count}" -gt 0
printf 'observed stable=%s canary=%s of 200\n' "${stable_count}" "${canary_count}"
}
apply_canary 10 90
observe_canaryInspect your release gates. If they pass, continue:
apply_canary 50 50
observe_canaryInspect the gates again. Promote only when they pass:
apply_canary 100 0
for request in {1..10}; do
test "$(curl -fsS --connect-timeout 3 --max-time 10 "${GATEWAY_URL}/release/canary")" = "release=canary"
doneThe observed counts need not equal the configured ratio. At 0/100 and 100/0, exact release-body assertions are appropriate.
Validate
Confirm exactly one owned route exists:
curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/routes?gateway_group_id=${API7_GATEWAY_GROUP_ID}&service_id=canary-guide-service-id&search=canary-guide-route&page=1&page_size=100" -H "X-API-KEY: ${API7_ADMIN_KEY}" \
| jq -e '[.list[] | select(.name == "canary-guide-route" and .labels.guide == "canary-release")] | length == 1'This checks ownership and uniqueness; it does not infer an ADC backend ID from the display name. Route priority does not control the order of traffic-split.rules.
Roll back / Clean up
Apply a complete known-good route that removes the qualification rule and assigns all traffic to stable:
rollback_canary
for request in {1..10}; do
test "$(curl -fsS --connect-timeout 3 --max-time 10 "${GATEWAY_URL}/release/canary")" = "release=stable"
test "$(curl -fsS --connect-timeout 3 --max-time 10 "${GATEWAY_URL}/release/canary" -H 'X-Canary: true')" = "release=stable"
doneKeep the rolled-back route if stable should continue serving. Otherwise choose the matching cleanup tab.
curl -ksS --connect-timeout 3 --max-time 15 -X DELETE "${API7_ADMIN_URL}/apisix/admin/routes/canary-guide-route-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
curl -ksS --connect-timeout 3 --max-time 15 -X DELETE "${API7_ADMIN_URL}/apisix/admin/services/canary-guide-service-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"services: []adc lint -f canary-cleanup.yaml
adc diff -f canary-cleanup.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=canary-release
adc sync -f canary-cleanup.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=canary-releasefor file in stable.pid canary.pid; do
if test -f "${file}"; then
pid="$(cat "${file}")"
if kill -0 "${pid}" 2>/dev/null && ps -p "${pid}" -o command= | grep -Fq 'python3 canary-upstream.py'; then kill "${pid}"; fi
fi
done
rm -f canary-upstream.py stable.pid canary.pid stable.log canary.log canary-adc.yaml.tmpl canary-adc.yaml canary-rollback-adc.yaml.tmpl canary-rollback-adc.yaml canary-cleanup.yaml canary-sample.txt
unset CANARY_WEIGHT STABLE_WEIGHTTroubleshoot
- Qualified requests reach stable: confirm the header value is exactly
true, the conditional rule is first, and the latest complete route reached the selected group. - A weighted sample contains only one release: confirm both weights are positive and both upstreams are reachable, then collect a larger diagnostic sample.
- The gateway returns
502or504: test both ports from the gateway network and verifyRELEASE_UPSTREAM_HOST. - ADC proposes unrelated changes: stop and verify the group name, selector, labels, and complete desired state.
- Application metrics fail: call
rollback_canaryimmediately and investigate the application.