| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | kubectl v1.37.0Envoy Gateway v1.9.2ingress-nginx controller v1.15.1Gateway API CRDs v1.6.1 (standard channel)version app python:3.12-alpine (Python/3.12.15) |
| Applies to | Any host with kubectl configured; any Kubernetes cluster that already runs ingress-nginx and Envoy Gateway |
| Lab environment | Both controllers already installed, GatewayClass eg, and app.example.com only in the Host header |
| Privilege | Normal user |
| Scope | How to send a fraction of requests for one hostname to a second app version with HTTPRoute backend weights, how that ratio maps to ingress-nginx canary-weight, how a repeated sample shows the split, and how to put the canary share back to zero. A header match takes the whole request. Does not cover Argo Rollouts. |
| Related guides | Blue-green and canary rollouts Migrate ingress-nginx to Envoy Gateway Gateway API with HTTPRoute Expose services with Ingress Kubernetes Services |
You have a stable version of an app on app.example.com and a new version you want to try on real traffic. The goal is to send about 10% of requests to the new version and keep 90% on stable. Then count responses to confirm the split, and set the canary share to zero if it misbehaves. If you are coming from ingress-nginx, your canary-weight: "10" should carry over as that same 10%, not as a lone weight: 10 that sends most traffic to the canary.
Two versions of an app can share one hostname. On Envoy Gateway, put both Services on one HTTPRoute rule and set weight on each backendRef. On ingress-nginx, the same idea is canary-weight on a second Ingress.
The proxy chooses a backend for each HTTP request. One person who sends ten separate requests can land on both versions. The weights do not pin a user or a session. A DNS weight on the hostname is a different mechanism, and the proxy does not save every tenth request for the canary.
Each backend's share is its weight divided by the sum of the weights on that rule. An omitted weight defaults to 1, and the numbers do not have to add up to 100:
- stable
90 / (90 + 10)= 90%, and canary10 / (90 + 10)= 10% - stable
9 / (9 + 1)= 90%, and canary1 / (9 + 1)= 10%
Client
|
v
HTTPRoute rule
|
+-----------------+-----------------+
| |
weight 90 weight 10
| |
v v
web-stable Service web-canary ServiceThe HTTPRoute does not choose Pods itself. Each backendRef names a Service, and that Service selects the Pods for one app version. Both backendRefs are part of the same HTTPRoute rule, so Envoy chooses between those Services only after the request has matched that rule.
Create a weighted HTTPRoute
Blue-green and canary rollouts cover a selector switch between Deployments. This page leaves both Services in place and lets the proxy choose. The HTTPRoute below is the whole canary rule. web-stable gets 90 shares and web-canary gets 10:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: web
spec:
parentRefs:
- name: eg
hostnames:
- app.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: web-stable
port: 80
weight: 90
- name: web-canary
port: 80
weight: 10The weight is the answer to the split. The next section adds the two demo Services, then counts real requests.
Test the 90 and 10 split
Migrate ingress-nginx to Envoy Gateway is the install. Start from a GatewayClass named eg. The stable process answers version=stable. The canary answers version=canary. This file creates both apps and the weighted route:
apiVersion: v1
kind: ConfigMap
metadata:
name: versions
data:
app.py: |
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import os
class H(BaseHTTPRequestHandler):
def do_GET(self):
body = ("version=%s\n" % os.environ.get("VERSION", "unknown")).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, fmt, *args):
return
ThreadingHTTPServer(("0.0.0.0", 8080), H).serve_forever()
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-stable
spec:
replicas: 1
selector:
matchLabels:
app: web-stable
template:
metadata:
labels:
app: web-stable
spec:
containers:
- name: web
image: python:3.12-alpine
command: ["python", "/app/app.py"]
env:
- name: VERSION
value: stable
ports:
- containerPort: 8080
volumeMounts:
- name: app
mountPath: /app
volumes:
- name: app
configMap:
name: versions
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-canary
spec:
replicas: 1
selector:
matchLabels:
app: web-canary
template:
metadata:
labels:
app: web-canary
spec:
containers:
- name: web
image: python:3.12-alpine
command: ["python", "/app/app.py"]
env:
- name: VERSION
value: canary
ports:
- containerPort: 8080
volumeMounts:
- name: app
mountPath: /app
volumes:
- name: app
configMap:
name: versions
---
apiVersion: v1
kind: Service
metadata:
name: web-stable
spec:
selector:
app: web-stable
ports:
- port: 80
targetPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: web-canary
spec:
selector:
app: web-canary
ports:
- port: 80
targetPort: 8080
---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: eg
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: eg
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Same
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: web
spec:
parentRefs:
- name: eg
hostnames:
- app.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: web-stable
port: 80
weight: 90
- name: web-canary
port: 80
weight: 10Apply that file before the requests. created means the object is new:
kubectl apply -f edge.yamlconfigmap/versions created
deployment.apps/web-stable created
deployment.apps/web-canary created
service/web-stable created
service/web-canary created
gatewayclass.gateway.networking.k8s.io/eg created
gateway.gateway.networking.k8s.io/eg created
httproute.gateway.networking.k8s.io/web createdThe listener is programmed, but the Gateway stays Programmed False with reason AddressNotAssigned when nothing assigns a load balancer address. Use port-forward for the curls. Check that reason first:
kubectl get gateway eg -o jsonpath='{.status.conditions[?(@.type=="Programmed")].status}{" "}'\
'{.status.conditions[?(@.type=="Programmed")].reason}{"\n"}'False AddressNotAssignedThe Envoy Service name is generated from the Gateway. Read it before you forward it:
kubectl get svc -n envoy-gateway-system -l gateway.envoyproxy.io/owning-gateway-name=egNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
envoy-default-eg-e41e7b31 LoadBalancer 10.96.235.236 <pending> 80:32317/TCP 4m42sEXTERNAL-IP stays <pending> for the same reason. Forward Service port 80. The process in the pod listens on 10080, which is why the forward line points at 10080:
kubectl -n envoy-gateway-system port-forward svc/envoy-default-eg-e41e7b31 19080:80Forwarding from 127.0.0.1:19080 -> 10080
Forwarding from [::1]:19080 -> 10080Forward ingress-nginx on 18080 as well. That Service port 80 is also the port in the pod:
kubectl -n ingress-nginx port-forward svc/ingress-nginx-controller 18080:80Forwarding from 127.0.0.1:18080 -> 80
Forwarding from [::1]:18080 -> 80The route is accepted and its Service refs resolve. Read the conditions by type:
kubectl get httproute web -o \
jsonpath='{range .status.parents[0].conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'Accepted=True Accepted
ResolvedRefs=True ResolvedRefsAccepted=True means the Gateway took the route. ResolvedRefs=True means both Services were found. Send one request. The body is the version that won this draw:
curl -sS -D - --max-time 5 -H 'Host: app.example.com' http://127.0.0.1:19080/HTTP/1.1 200 OK
server: BaseHTTP/0.6 Python/3.12.15
date: Sat, 03 Oct 2026 20:23:36 GMT
content-type: text/plain
content-length: 15
version=stableThis request reached the stable app. One response cannot show a 9-to-1 split. Count 200 requests. sort | uniq -c prints the canary line first because the text sorts that way:
for i in $(seq 1 200); do
curl -sS --max-time 5 -H 'Host: app.example.com' http://127.0.0.1:19080/
done | sort | uniq -c 20 version=canary
180 version=stable180 stable and 20 canary is the 9-to-1 ratio, and every response was one of the two versions. The weight is the long-run proportion, not an exact quota for a short sample. Repeat the run and the counts will move. Gateway API also allows a small gap from the exact proportion, depending on how the implementation rounds.
Compare weights 9 and 1
9 and 1 are the same ratio as 90 and 10. Replace only the weights:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: web
spec:
parentRefs:
- name: eg
hostnames:
- app.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: web-stable
port: 80
weight: 9
- name: web-canary
port: 80
weight: 1Apply the smaller weights:
kubectl apply -f route-9-1.yamlhttproute.gateway.networking.k8s.io/web configuredCount 100 requests:
for i in $(seq 1 100); do
curl -sS --max-time 5 -H 'Host: app.example.com' http://127.0.0.1:19080/
done | sort | uniq -c 10 version=canary
90 version=stable90 and 10 out of 100. weight: 1 on the canary did not mean 1 percent. It meant 1 share out of 9 + 1.
Keep both weights in the ratio
weight: 10 is 10% only when the other weights make 10 that share of the total. If the stable backend has no weight, it defaults to 1, so the canary gets 10 / 11, about 91%.
That is the easy mistake when you copy a nginx canary value onto only one backend. Apply that shape on purpose so the count is visible:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: web
spec:
parentRefs:
- name: eg
hostnames:
- app.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: web-stable
port: 80
- name: web-canary
port: 80
weight: 10Apply it:
kubectl apply -f route-omitted.yamlhttproute.gateway.networking.k8s.io/web configuredCount 100 requests again:
for i in $(seq 1 100); do
curl -sS --max-time 5 -H 'Host: app.example.com' http://127.0.0.1:19080/
done | sort | uniq -c 91 version=canary
9 version=stable91 requests hit the canary. Set both weights when you move a canary-weight value across.
Map canary-weight on ingress-nginx
ingress-nginx uses a second Ingress for the canary Service. Gateway API puts both Services on one rule instead. The controller also allows only one canary Ingress per Ingress rule.
| ingress-nginx | Envoy Gateway |
|---|---|
| Main Ingress points at the stable Service | One backendRef points at the stable Service |
| Canary Ingress points at the canary Service | A second backendRef points at the canary Service |
canary-weight: "10" with the total left at 100 |
Relative weights such as 90 and 10 |
canary-weight: "0" |
Canary weight: 0 |
canary-by-header |
An HTTPRoute header match |
canary-by-cookie |
No dedicated cookie-canary field |
canary-weight is a count out of canary-weight-total. The total defaults to 100:
canary-weight: "10"andcanary-weight-total: "100"is 10%canary-weight: "10"andcanary-weight-total: "200"is 5%
Convert that finished ratio when you write the HTTPRoute. Copying the number 10 onto one weight field is the omitted-weight case above.
The main Ingress has no canary annotation and points at web-stable. The canary Ingress points at web-canary:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-stable
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-canary
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "10"
spec:
ingressClassName: nginx
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-canary
port:
number: 80Apply both Ingresses:
kubectl apply -f ingress-canary.yamlingress.networking.k8s.io/web created
ingress.networking.k8s.io/web-canary createdCount 200 requests on port 18080:
for i in $(seq 1 200); do
curl -sS --max-time 5 -H 'Host: app.example.com' http://127.0.0.1:18080/
done | sort | uniq -c 22 version=canary
178 version=stable22 of 200 is close to 10 percent, and it is not exactly 20. That is normal variation in a short sample, not a schedule of every tenth request.
Send the canary share back to zero
A backendRef with weight: 0 stays in the HTTPRoute and should receive no traffic from that rule. Leave the stable backend in the same rule so the route still has somewhere to send requests. Put the canary weight at 0:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: web
spec:
parentRefs:
- name: eg
hostnames:
- app.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: web-stable
port: 80
weight: 90
- name: web-canary
port: 80
weight: 0Apply the zero weight:
kubectl apply -f route-rollback.yamlhttproute.gateway.networking.k8s.io/web configuredCount 50 requests:
for i in $(seq 1 50); do
curl -sS --max-time 5 -H 'Host: app.example.com' http://127.0.0.1:19080/
done | sort | uniq -c 50 version=stableAll 50 stayed on stable. The canary Service is still there. This rule just gives it no share.
Set the nginx annotation to 0 as well:
kubectl annotate ingress web-canary nginx.ingress.kubernetes.io/canary-weight=0 --overwriteingress.networking.k8s.io/web-canary annotatedCount 50 requests on port 18080:
for i in $(seq 1 50); do
curl -sS --max-time 5 -H 'Host: app.example.com' http://127.0.0.1:18080/
done | sort | uniq -c 50 version=stablecanary-weight: "0" also sent all 50 to stable. That annotation does not delete the canary Ingress.
Send selected callers to the canary
A header or cookie check is a separate switch from the percentage. ingress-nginx looks at them in this order:
canary-by-headerfirstcanary-by-cookienextcanary-weightfor everything that did not match
always and never are special only in those ingress-nginx annotations. always sends that request to the canary. never sends it to the main Ingress. Any other value ignores that check and falls through. Envoy Gateway does not treat those words as keywords. You write a match for the exact value you want.
Put the nginx weight back to 10, and name the header and the cookie:
kubectl annotate ingress web-canary \
nginx.ingress.kubernetes.io/canary-weight=10 \
nginx.ingress.kubernetes.io/canary-by-header=X-Canary \
nginx.ingress.kubernetes.io/canary-by-cookie=canary --overwriteingress.networking.k8s.io/web-canary annotatedSend X-Canary: always once:
curl -sS -D - --max-time 5 -H 'Host: app.example.com' -H 'X-Canary: always' http://127.0.0.1:18080/HTTP/1.1 200 OK
Date: Sat, 03 Oct 2026 20:26:56 GMT
Content-Type: text/plain
Content-Length: 15
Connection: keep-alive
version=canaryTwenty requests with that header all returned version=canary, even though the weight was still 10. X-Canary: never did the reverse: 20 of 20 stayed on stable. A cookie value of always also sent 20 of 20 to the canary. A cookie value of yes, which is neither word, fell through to the weight: 18 of those 20 requests stayed on stable.
Gateway API has a native header match. It does not have a canary-by-cookie field. A cookie arrives inside the Cookie header, so copying that nginx annotation is not a one-field change. Matching a cookie can mean matching that header, or an implementation-specific policy, depending on how exact the cookie logic needs to be.
On the HTTPRoute, add a rule that matches x-canary: always and lists only web-canary. Both rules match the / path, but the first rule also requires the x-canary: always header. That additional match makes it more specific for requests carrying that header, so those requests go only to web-canary. Requests without the header do not match that rule and use the weighted / rule instead. Its position first in the YAML makes the configuration easier to read, but rule order is not what gives it precedence in this example:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: web
spec:
parentRefs:
- name: eg
hostnames:
- app.example.com
rules:
- matches:
- headers:
- name: x-canary
value: always
backendRefs:
- name: web-canary
port: 80
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: web-stable
port: 80
weight: 90
- name: web-canary
port: 80
weight: 10Apply the two-rule route:
kubectl apply -f route-header.yamlhttproute.gateway.networking.k8s.io/web configuredSend x-canary: always to port 19080:
curl -sS -D - --max-time 5 -H 'Host: app.example.com' -H 'x-canary: always' http://127.0.0.1:19080/HTTP/1.1 200 OK
server: BaseHTTP/0.6 Python/3.12.15
date: Sat, 03 Oct 2026 20:26:56 GMT
content-type: text/plain
content-length: 15
version=canaryTwenty requests with that header all returned version=canary. Requests with no x-canary header still use the weights. Fifty of those returned 44 stable and 6 canary, which is the 10 percent rule:
for i in $(seq 1 50); do
curl -sS --max-time 5 -H 'Host: app.example.com' http://127.0.0.1:19080/
done | sort | uniq -c 6 version=canary
44 version=stableSix canary hits in 50 is the weighted rule, not the header rule.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Almost every request hits the canary after you set weight: 10 on that backend only |
The stable backend has no weight, so it defaults to 1. The split is 10 / 11 |
Set both weights, for example 90 and 10 |
| 200 requests produced 22 canary responses instead of 20 | The weight is a long-run proportion. A short sample varies | Count more requests. Do not expect the same positions every run |
| The same browser sometimes sees stable and sometimes sees canary | Each request is chosen on its own. Weights do not pin a session | Use a header match when one caller should always see the canary |
X-Canary: always ignores a 10 percent weight |
ingress-nginx checks that header before canary-weight. always is its word, not a Gateway API keyword |
Use the header only for callers who should always see the canary |
weight: 1 on the canary looks like 1 percent |
The field is a share of the sum | Compare 9 and 1 with 90 and 10. Both were a 9-to-1 split |
Summary
HTTPRoute weights 90 and 10 sent 180 of 200 requests to web-stable and 20 to web-canary. Weights 9 and 1 sent 90 of 100 to stable. Each share is the backend weight divided by the sum of the weights. Leaving the stable weight off, and setting only the canary to 10, sent 91 of 100 to the canary because the omitted weight defaults to 1.
With the default canary-weight-total: "100", canary-weight: "10" sends about 10% of requests to the canary. If canary-weight-total is changed, calculate the percentage as canary-weight / canary-weight-total. A sample of 200 came back 178 stable and 22 canary. A weight of 0 stays on the HTTPRoute and should receive no traffic from that rule. The same 0 annotation on the canary Ingress sent the next 50 requests entirely to stable.
Both rules match /, and the header rule also requires x-canary: always, so those requests go only to the canary. Requests without the header keep the weighted split. always and never mean that only on ingress-nginx. Gateway API matches the header value you write, and it has no dedicated cookie-canary field.
References
- Envoy Gateway HTTP traffic splitting (v1.9)
- Gateway API traffic splitting
- Gateway API HTTPRoute
- ingress-nginx canary annotations
- ingress-nginx canary example
Frequently Asked Questions
Do HTTPRoute weights have to add up to 100?
No. Each backend's share is its weight divided by the sum of the weights on that rule. Weights 9 and 1 sent 90 of 100 requests to stable. Weights 90 and 10 sent 180 of 200 to stable.
Does a 10 percent canary pin 10 percent of users?
No. The proxy chooses a backend for each request. One user who sends ten separate requests can see both versions. The weights do not create session stickiness.
What if only the canary backend has weight 10?
The stable backend then defaults to 1. The canary receives 10 / 11 of the requests, about 91%. In a sample of 100, that was 91 canary responses and 9 stable responses.
How close will a sample be to 10 percent?
The weight is the long-run proportion, not an exact quota for every sample. Envoy's 200 requests at weights 90 and 10 landed 20 on the canary. ingress-nginx canary-weight: "10" landed 22 of 200 on the canary. A sample of 20 or 100 can sit a little off the ratio.
How do I send all traffic back to stable?
Set the canary weight to 0 and leave that backend in the rule. It should receive no traffic from the rule. Fifty requests then all returned version=stable. On ingress-nginx, set canary-weight to 0. Fifty requests there also all returned version=stable.
Does canary-by-header replace the percentage?
For the requests that match. ingress-nginx checks the header, then the cookie, then the weight. X-Canary: always sent 20 of 20 to the canary while the weight was still 10. X-Canary: never sent 20 of 20 to stable. Those two words are ingress-nginx behavior, not Gateway API keywords.
How do I force one caller onto the Envoy canary?
Add an HTTPRoute rule that matches x-canary: always and lists only the canary Service. Both rules match /, and that extra header match sends those requests only to the canary. Rule order is not what gives it precedence. x-canary: always sent 20 of 20 to the canary. Fifty requests with no such header still followed the 90 and 10 weights, with 6 of them on the canary.
Can I copy canary-by-cookie onto an HTTPRoute?
Not as one field. Gateway API matches paths, headers, query parameters, and methods. It has no dedicated cookie-canary field. A cookie arrives in the Cookie header, so the nginx annotation does not map one-to-one onto a native cookie matcher.

