Configure TCP and UDP Proxying
Proxy a MySQL TCP service and a UDP echo service through API7 Gateway stream routes.
Applies to:API7 Gateway 3.10.x
Roles:Platform engineer
Goal
Expose a MySQL server on TCP port 2000 and an echo server on UDP port 2001 through API7 Gateway. The two stream routes use different listeners and upstream schemes, so both Layer 4 protocols are exercised.
This guide also applies limit-conn (opens in Plugin Hub docs) to the TCP route by client address. The local upstreams are for evaluation only.
Before you begin
Set the control-plane and stream endpoint 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
read -rp "Gateway host for TCP and UDP clients: " STREAM_GATEWAY_HOST && export STREAM_GATEWAY_HOST
export TCP_STREAM_PORT="2000"
export UDP_STREAM_PORT="2001"
read -rp "Host or DNS name the gateway uses to reach the sample upstreams: " STREAM_UPSTREAM_HOST && export STREAM_UPSTREAM_HOST
read -rsp "Dashboard token: " API7_ADMIN_KEY && export API7_ADMIN_KEY && echoFor a Docker Desktop gateway and upstreams published on the same host, STREAM_GATEWAY_HOST is usually 127.0.0.1 and STREAM_UPSTREAM_HOST is host.docker.internal. For another deployment, use addresses reachable from the client and gateway respectively.
-
Confirm that the control plane and 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. -
Docker, Python 3, a MySQL client,
curl,jq, and GNUenvsubstare available. The gateway can reach${STREAM_UPSTREAM_HOST}:3306over TCP and${STREAM_UPSTREAM_HOST}:3001over UDP. -
Ports
3306/TCPand3001/UDP, the container nameapi7-stream-mysql, and the local files used below are free:if docker container inspect api7-stream-mysql >/dev/null 2>&1; then echo 'Stop: container api7-stream-mysql already exists'; exit 1; fi if docker container ls --format '{{.Ports}}' | grep -Eq '(^|, |:)3306->'; then echo 'Stop: a Docker container already publishes local port 3306'; exit 1; fi for file in udp-echo.py udp-echo.pid udp-echo.log stream-adc.yaml.tmpl stream-adc.yaml stream-cleanup.yaml; do test ! -e "${file}" || { echo "Stop: ${file} already exists"; exit 1; }; done python3 - <<'PY' import socket with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: probe.bind(("0.0.0.0", 3001)) print("UDP port 3001 is free") PY -
The gateway already listens on and exposes
2000/TCPand2001/UDP. Admin API and ADC resources do not add runtime listeners. Merge this fragment into the existing gateway configuration through your deployment method, preserve every other setting, publish both protocols, and wait for the gateway rollout:config.yaml fragment apisix: stream_proxy: only: false tcp: - 2000 udp: - 2001For Kubernetes installations using API7 Gateway chart
gateway-3.10.3, the equivalent values are:values override for gateway-3.10.3 gateway: stream: enabled: true only: false tcp: - addr: 2000 udp: - addr: 2001Follow the backup, exact-version upgrade, rollout, and rendered ConfigMap checks in Helm Chart. If the installed chart has another version, inspect that version's values and templates first. For a container deployment, both container listeners also need host or load-balancer mappings; TCP and UDP mappings are distinct.
-
For ADC, confirm label-selector support:
adc --version adc sync --help | grep -- '--label-selector'ADC resolves
--gateway-groupby display name. Enter the display name for the same group whose UUID is inAPI7_GATEWAY_GROUP_ID.
This guide creates two services and two stream routes. Confirm that none of their fixed IDs already exists:
for id in stream-tcp-service stream-udp-service; do
curl -ksS --connect-timeout 3 --max-time 10 -w "\nservice ${id}: %{http_code}\n" "${API7_ADMIN_URL}/apisix/admin/services/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
done
for id in stream-tcp-route stream-udp-route; do
curl -ksS --connect-timeout 3 --max-time 10 -w "\nroute ${id}: %{http_code}\n" "${API7_ADMIN_URL}/apisix/admin/stream_routes/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
doneExpected: every resource is absent. A 200 means the ID is in use; stop or change every occurrence because PUT replaces a resource at that ID. For ADC, continue only if the diff creates exactly these labeled resources without updating or deleting existing objects.
Steps
Step 1: Start the sample upstreams
Start MySQL and wait up to 60 seconds for it to accept connections:
docker run -d --name api7-stream-mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password mysql:8.4 --mysql-native-password=ON
for attempt in {1..30}; do
docker exec -e MYSQL_PWD=password api7-stream-mysql mysqladmin ping --connect-timeout=2 -h 127.0.0.1 -uroot --silent && break
sleep 2
done
docker exec -e MYSQL_PWD=password api7-stream-mysql mysqladmin ping --connect-timeout=2 -h 127.0.0.1 -uroot --silentCreate and start a UDP echo server:
cat > udp-echo.py <<'PY'
import socket
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server:
server.bind(("0.0.0.0", 3001))
while True:
data, address = server.recvfrom(65535)
server.sendto(data, address)
PY
python3 udp-echo.py > udp-echo.log 2>&1 &
echo $! > udp-echo.pid
kill -0 "$(cat udp-echo.pid)"Verify both upstreams directly from the host:
export MYSQL_PWD="password"
mysql --connect-timeout=5 --protocol=TCP -h 127.0.0.1 -P 3306 -u root -Nse "SELECT 'mysql upstream ready';"
unset MYSQL_PWD
python3 - <<'PY'
import socket
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
client.settimeout(5)
client.sendto(b"udp upstream ready", ("127.0.0.1", 3001))
data, _ = client.recvfrom(65535)
print(data.decode())
PYExpected: mysql upstream ready, then udp upstream ready.
What changed: two local servers now provide separate TCP and UDP targets.
Step 2: Create the stream services and routes with the Admin API
Choose this step or step 3; do not apply both.
jq -n --arg host "${STREAM_UPSTREAM_HOST}" '{name:"stream-tcp-service",type:"stream",labels:{guide:"tcp-udp-proxy"},upstream:{scheme:"tcp",nodes:[{host:$host,port:3306,weight:100}]}}' \
| curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/services/stream-tcp-service?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-
curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/stream_routes/stream-tcp-route?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" \
-d '{"name":"stream-tcp-route","labels":{"guide":"tcp-udp-proxy"},"server_port":2000,"service_id":"stream-tcp-service","plugins":{"limit-conn":{"conn":100,"burst":50,"default_conn_delay":0.1,"key":"remote_addr","key_type":"var"}}}'
jq -n --arg host "${STREAM_UPSTREAM_HOST}" '{name:"stream-udp-service",type:"stream",labels:{guide:"tcp-udp-proxy"},upstream:{scheme:"udp",nodes:[{host:$host,port:3001,weight:100}]}}' \
| curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/services/stream-udp-service?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-
curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/apisix/admin/stream_routes/stream-udp-route?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" \
-d '{"name":"stream-udp-route","labels":{"guide":"tcp-udp-proxy"},"server_port":2001,"service_id":"stream-udp-service"}'server_port selects the gateway listener. The service's upstream scheme selects the protocol used from the gateway to its upstream. With key_type: var, limit-conn.key is the variable name remote_addr without a $ prefix.
What changed: the two listeners now select different labeled services and upstream protocols.
Step 3: Apply both services and routes with ADC
Skip this alternative if you completed step 2. Save one complete desired-state file so that a later sync does not replace one protocol's resources with the other:
services:
- name: stream-tcp-service
labels: {guide: tcp-udp-proxy}
upstream:
scheme: tcp
nodes:
- {host: "${STREAM_UPSTREAM_HOST}", port: 3306, weight: 100}
stream_routes:
- name: stream-tcp-route
labels: {guide: tcp-udp-proxy}
server_port: 2000
plugins:
limit-conn:
conn: 100
burst: 50
default_conn_delay: 0.1
key: remote_addr
key_type: var
- name: stream-udp-service
labels: {guide: tcp-udp-proxy}
upstream:
scheme: udp
nodes:
- {host: "${STREAM_UPSTREAM_HOST}", port: 3001, weight: 100}
stream_routes:
- name: stream-udp-route
labels: {guide: tcp-udp-proxy}
server_port: 2001Render only the upstream host, then lint, validate, and inspect the selected diff:
envsubst '${STREAM_UPSTREAM_HOST}' < stream-adc.yaml.tmpl > stream-adc.yaml
adc lint -f stream-adc.yaml
adc validate -f stream-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=tcp-udp-proxy
adc diff -f stream-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=tcp-udp-proxyExpected: validation succeeds and the diff creates two labeled services with one nested stream route each. Apply only that reviewed diff:
adc sync -f stream-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=tcp-udp-proxyWhat changed: ADC reconciled the TCP and UDP resources together under one label selector.
Validate
Query MySQL through the gateway TCP listener:
export MYSQL_PWD="password"
mysql --connect-timeout=5 --protocol=TCP -h "${STREAM_GATEWAY_HOST}" -P "${TCP_STREAM_PORT}" -u root -Nse "SELECT 'mysql through API7';"
unset MYSQL_PWDExpected: mysql through API7.
Send a datagram through the gateway UDP listener and require the same payload back within five seconds:
python3 - <<'PY'
import os
import socket
address = (os.environ["STREAM_GATEWAY_HOST"], int(os.environ["UDP_STREAM_PORT"]))
payload = b"udp through API7"
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
client.settimeout(5)
client.sendto(payload, address)
data, _ = client.recvfrom(65535)
assert data == payload, (data, payload)
print(data.decode())
PYExpected: udp through API7.
Roll back / Clean up
For the Admin API path, remove the routes before their services:
for id in stream-udp-route stream-tcp-route; do
curl -ksS --connect-timeout 3 --max-time 15 -X DELETE "${API7_ADMIN_URL}/apisix/admin/stream_routes/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
done
for id in stream-udp-service stream-tcp-service; do
curl -ksS --connect-timeout 3 --max-time 15 -X DELETE "${API7_ADMIN_URL}/apisix/admin/services/${id}?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
doneFor ADC, reconcile an empty selected state after reviewing the diff:
services: []adc diff -f stream-cleanup.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=tcp-udp-proxy
adc sync -f stream-cleanup.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=tcp-udp-proxyStop the sample upstreams and remove their local files:
docker rm -f api7-stream-mysql
kill "$(cat udp-echo.pid)"
rm udp-echo.py udp-echo.pid udp-echo.log stream-adc.yaml.tmpl stream-adc.yaml stream-cleanup.yaml
unset MYSQL_PWDRemove the two stream listener entries and their TCP/UDP exposure only if you added them solely for this guide. Apply that rollback through the same deployment method and verify its rollout.
Troubleshoot
- A client cannot connect to either port: confirm the running gateway configuration contains both listeners and that the container, Kubernetes Service, load balancer, and firewall expose the correct protocol for each port. Publishing
2001/TCPdoes not expose2001/UDP. - The gateway logs an upstream connection error: test
3306/TCPand3001/UDPfrom the gateway network namespace.127.0.0.1refers to the gateway itself there; use a gateway-reachable address. - MySQL fails but UDP succeeds: inspect
docker logs api7-stream-mysql, then repeat the direct MySQL check. Keep--connect-timeout=5so a network failure does not leave the client waiting indefinitely. - UDP times out: confirm the echo process is alive with
kill -0 "$(cat udp-echo.pid)"and that every network policy and port mapping uses UDP. - ADC proposes unrelated changes: stop. Confirm the gateway group display name and label selector, and make sure both protocols remain in the same desired-state file.
- A TLS stream needs SNI routing: configure a TCP listener with
tls: true, install the matching certificate, and use the stream routesnifield. Follow Stream Routes; the plaintext samples in this guide do not validate TLS termination.