Configure Proxy Mirror
Mirror complete and sampled HTTP traffic, then verify the mirrored method and body at a controlled receiver.
Applies to:API7 Gateway 3.10.x
Roles:Platform engineer
Goal
Create two independent HTTP routes. /mirror/all mirrors every POST, including its request body, to a controlled receiver. /mirror/sampled mirrors each GET independently with probability 0.1. Verify the main response and the receiver's observations separately.
Mirrored traffic can contain credentials, personal data, and other sensitive headers or bodies. Use an isolated receiver with access controls and retention appropriate for the copied data.
Before you begin
Set the control-plane, data-plane, and receiver variables:
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: " MIRROR_RECEIVER_HOST && export MIRROR_RECEIVER_HOST
export MIRROR_RECEIVER_URL="http://${MIRROR_RECEIVER_HOST}:18080"
read -rsp "Dashboard token: " API7_ADMIN_KEY && export API7_ADMIN_KEY && echo-
Confirm that the control plane and selected gateway group are 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. -
The gateway can resolve and reach
httpbin.org:443and${MIRROR_RECEIVER_HOST}:18080. The main upstream is a public demonstration service, so its availability and behavior are outside API7 Gateway's control. -
Python 3,
curl,jq, and GNUenvsubstare available. -
Local TCP port
18080and the files used by this procedure are free:for file in mirror-receiver.py mirror-receiver.pid mirror-observations.jsonl mirror-main-response.json proxy-mirror-adc.yaml.tmpl proxy-mirror-adc.yaml proxy-mirror-cleanup.yaml; do test ! -e "${file}" || { echo "Stop: ${file} already exists"; exit 1; }; done python3 - <<'PY' import socket with socket.socket() as probe: probe.bind(("0.0.0.0", 18080)) print("TCP port 18080 is free") PY -
Review the gateway's
plugin_attr.proxy-mirror.timeoutvalues. Mirroring uses subrequests, so a slow or unreachable receiver can delay the original request until the configured connect, send, or read timeout. See Proxy Mirror Configuration (opens in Plugin Hub docs). -
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. Enter the display name of the group identified byAPI7_GATEWAY_GROUP_ID.
This guide creates one service and two routes. Check every fixed Admin API identifier before using PUT:
curl -ksS --connect-timeout 3 --max-time 10 -w '\nservice: %{http_code}\n' "${API7_ADMIN_URL}/apisix/admin/services/proxy-mirror-guide-service-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
for id in proxy-mirror-all-id proxy-mirror-sampled-id; do
curl -ksS --connect-timeout 3 --max-time 10 -w "\nroute ${id}: %{http_code}\n" "${API7_ADMIN_URL}/apisix/admin/routes/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
doneExpected: every resource is absent. A 200 response containing a resource means the ID is in use; stop or change every occurrence. Correct authentication, group, and request errors until absence is unambiguous because PUT replaces a resource at the same ID. The Admin API URL IDs deliberately differ from the name fields below. ADC also treats a display name and backend ID as separate fields; do not infer one from the other. For ADC, continue only if its diff shows creates for exactly the labeled resources in this guide.
Steps
Step 1: Start an observable mirror receiver
Create a receiver that records the method, path, headers, and body of each mirrored request, then returns 418 so the later check can prove that its response does not replace the main upstream response:
cat > mirror-receiver.py <<'PY'
import json
import os
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
log_path = os.environ["MIRROR_LOG"]
write_lock = threading.Lock()
class Handler(BaseHTTPRequestHandler):
def record(self):
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length).decode("utf-8", errors="replace")
entry = {
"method": self.command,
"path": self.path,
"content_type": self.headers.get("Content-Type"),
"body": body,
}
with write_lock, open(log_path, "a", encoding="utf-8") as log:
log.write(json.dumps(entry, sort_keys=True) + "\n")
self.send_response(418)
self.end_headers()
self.wfile.write(b"mirror-only response")
def do_GET(self):
if self.path == "/ready":
self.send_response(204)
self.end_headers()
return
self.record()
def do_POST(self):
self.record()
def log_message(self, format, *args):
pass
ThreadingHTTPServer(("0.0.0.0", 18080), Handler).serve_forever()
PY
export MIRROR_LOG="$(pwd)/mirror-observations.jsonl"
: > "${MIRROR_LOG}"
python3 mirror-receiver.py &
echo $! > mirror-receiver.pid
curl -sS --connect-timeout 3 --max-time 5 -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/readyExpected: 204. Keep the receiver running through validation. MIRROR_RECEIVER_URL must contain only the scheme, host, and port; the plugin's path supplies the receiver path.
Step 2: Create the service and routes with the Admin API
Choose this step or step 3; do not apply both.
curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/services/proxy-mirror-guide-service-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" \
-d '{
"name":"proxy-mirror-guide-service","labels":{"guide":"proxy-mirror"},
"upstream":{"name":"default","type":"roundrobin","scheme":"https","pass_host":"node","nodes":[{"host":"httpbin.org","port":443,"weight":100}]}
}'
jq -n --arg mirror_host "${MIRROR_RECEIVER_URL}" '{name:"proxy-mirror-all",labels:{guide:"proxy-mirror"},paths:["/mirror/all"],methods:["POST"],service_id:"proxy-mirror-guide-service-id",plugins:{"proxy-rewrite":{uri:"/anything/mirror-main"},"proxy-mirror":{host:$mirror_host,path:"/mirror-receiver/all",sample_ratio:1}}}' \
| curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/routes/proxy-mirror-all-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-
jq -n --arg mirror_host "${MIRROR_RECEIVER_URL}" '{name:"proxy-mirror-sampled",labels:{guide:"proxy-mirror"},paths:["/mirror/sampled"],methods:["GET"],service_id:"proxy-mirror-guide-service-id",plugins:{"proxy-rewrite":{uri:"/anything/mirror-sampled"},"proxy-mirror":{host:$mirror_host,path:"/mirror-receiver/sampled",sample_ratio:0.1}}}' \
| curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/routes/proxy-mirror-sampled-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-What changed: one route mirrors every POST to /mirror-receiver/all; the other independently selects each request with probability 0.1 and sends selected requests to /mirror-receiver/sampled. The receiver response is ignored as response content, but receiver latency can still affect the main request.
Step 3: Apply the complete desired state with ADC
Skip this alternative if you completed step 2. Save one template containing the service and both routes:
services:
- name: proxy-mirror-guide-service
labels: {guide: proxy-mirror}
upstream:
name: default
scheme: https
pass_host: node
nodes:
- {host: httpbin.org, port: 443, weight: 100}
routes:
- name: proxy-mirror-all
labels: {guide: proxy-mirror}
uris: [/mirror/all]
methods: [POST]
plugins:
proxy-rewrite: {uri: /anything/mirror-main}
proxy-mirror:
host: "${MIRROR_RECEIVER_URL}"
path: /mirror-receiver/all
sample_ratio: 1
- name: proxy-mirror-sampled
labels: {guide: proxy-mirror}
uris: [/mirror/sampled]
methods: [GET]
plugins:
proxy-rewrite: {uri: /anything/mirror-sampled}
proxy-mirror:
host: "${MIRROR_RECEIVER_URL}"
path: /mirror-receiver/sampled
sample_ratio: 0.1Render only the receiver URL, then lint, validate, and inspect the selected diff:
envsubst '${MIRROR_RECEIVER_URL}' < proxy-mirror-adc.yaml.tmpl > proxy-mirror-adc.yaml
adc lint -f proxy-mirror-adc.yaml
adc validate -f proxy-mirror-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=proxy-mirror
adc diff -f proxy-mirror-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=proxy-mirrorExpected: validation succeeds and the diff creates only one labeled service and its two labeled routes. Apply only that reviewed diff:
adc sync -f proxy-mirror-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=proxy-mirrorValidate
Clear the receiver log, send a unique JSON body through the 100-percent route, and require the main upstream's 200 response:
: > mirror-observations.jsonl
MIRROR_MARKER="mirror-body-$(date +%s)"
MAIN_STATUS=$(curl -sS --connect-timeout 3 --max-time 15 -o mirror-main-response.json -w '%{http_code}' "${GATEWAY_URL}/mirror/all" \
-H 'Content-Type: application/json' -X POST -d "{\"marker\":\"${MIRROR_MARKER}\"}")
test "${MAIN_STATUS}" = 200
jq -e --arg marker "${MIRROR_MARKER}" '.json.marker == $marker' mirror-main-response.jsonThe receiver deliberately answered 418; the client still received httpbin's 200 and echoed body. Now require a receiver record with the same method, path, and JSON body:
for attempt in {1..10}; do
jq -e --arg marker "${MIRROR_MARKER}" 'select(.method == "POST" and .path == "/mirror-receiver/all" and ((.body | fromjson).marker == $marker))' mirror-observations.jsonl >/dev/null 2>&1 && break
sleep 0.5
done
jq -e --arg marker "${MIRROR_MARKER}" 'select(.method == "POST" and .path == "/mirror-receiver/all" and .content_type == "application/json" and ((.body | fromjson).marker == $marker))' mirror-observations.jsonlExpected: the final command prints the matching receiver record. This receiver evidence proves that the mirror got the original POST method and body; the main response alone cannot prove delivery to the mirror.
Observe sampling over 40 independent requests:
SAMPLE_RUN="sample-$(date +%s)"
for number in {1..40}; do
curl -sS --connect-timeout 3 --max-time 15 -o /dev/null "${GATEWAY_URL}/mirror/sampled?run=${SAMPLE_RUN}&number=${number}"
done
sleep 2
SAMPLED_COUNT=$(jq -r --arg run "run=${SAMPLE_RUN}" 'select(.path | contains($run)) | 1' mirror-observations.jsonl | wc -l | tr -d ' ')
printf 'mirrored %s of 40 requests\n' "${SAMPLED_COUNT}"
test "${SAMPLED_COUNT}" -ge 0 && test "${SAMPLED_COUNT}" -le 40The observed count will vary; 0.1 is an independent probability for each request, not a promise that exactly four of forty requests are mirrored. Use sustained traffic and an appropriate statistical test when validating a production sampling distribution. The accepted configuration range is 0.00001 through 1; remove or disable the plugin when no requests should be mirrored.
Roll back / Clean up
For the Admin API path, delete routes before their service:
for id in proxy-mirror-sampled-id proxy-mirror-all-id; do
curl -ksS --connect-timeout 3 --max-time 15 -X DELETE "${API7_ADMIN_URL}/apisix/admin/routes/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
done
curl -ksS --connect-timeout 3 --max-time 15 -X DELETE "${API7_ADMIN_URL}/apisix/admin/services/proxy-mirror-guide-service-id?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"For ADC, save an empty selected state and review the deletion diff before syncing it:
services: []adc lint -f proxy-mirror-cleanup.yaml
adc diff -f proxy-mirror-cleanup.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=proxy-mirror
adc sync -f proxy-mirror-cleanup.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=proxy-mirrorStop the local receiver and remove only this guide's files:
if kill -0 "$(cat mirror-receiver.pid)" 2>/dev/null; then kill "$(cat mirror-receiver.pid)"; fi
rm -f mirror-receiver.py mirror-receiver.pid mirror-observations.jsonl mirror-main-response.json proxy-mirror-adc.yaml.tmpl proxy-mirror-adc.yaml proxy-mirror-cleanup.yaml
unset MIRROR_LOG MIRROR_RECEIVER_URLExpected: the reviewed ADC diff affects only resources labeled guide=proxy-mirror, or the Admin API removes the two fixed-ID routes and their service. The local receiver is no longer running.
Troubleshoot
- The main route works but no receiver record appears: test
${MIRROR_RECEIVER_HOST}:18080from the gateway network namespace.127.0.0.1there refers to the gateway itself, not necessarily the machine running the receiver. - The plugin rejects
host: supply a scheme plus hostname or IP and optional port, with no path or query. Configure the receiver path separately withpath. - The main request is slow when the receiver is unavailable: mirroring uses a subrequest. Set appropriate static connect, send, and read timeouts for the receiver and measure their effect; ignored response content does not mean zero latency.
- The receiver has a POST with an empty body: confirm that the client sent
Content-Lengthand that no earlier request-body plugin consumed or replaced the body. The gateway's NGINX mirror module reads and mirrors request bodies by default. - A sampling run does not produce exactly 10 percent: sampling is random per request. Increase the observation window and evaluate a distribution instead of requiring an exact small-sample count.
- ADC proposes unrelated changes: stop and verify the gateway-group display name,
guide=proxy-mirrorselector, labels, rendered receiver URL, and complete desired-state file.