Want a canary on Envoy Gateway? Weight the backends

Tested on RHEL 10.2 (Coughlan)
Package kubectl v1.37.0
Envoy Gateway v1.9.2
ingress-nginx controller v1.15.1
Gateway 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
Short on time?Beta Get the gist of this 16-minute read in seconds, then ask follow-up questions

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 canary 10 / (90 + 10) = 10%
  • stable 9 / (9 + 1) = 90%, and canary 1 / (9 + 1) = 10%
                              Client
                                |
                                v
                         HTTPRoute rule
                                |
              +-----------------+-----------------+
              |                                   |
         weight 90                            weight 10
              |                                   |
              v                                   v
      web-stable Service                  web-canary Service

The 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:

yaml
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: 10

The 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:

yaml
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: 10

Apply that file before the requests. created means the object is new:

bash
kubectl apply -f edge.yaml
output
configmap/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 created

The 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:

bash
kubectl get gateway eg -o jsonpath='{.status.conditions[?(@.type=="Programmed")].status}{" "}'\
'{.status.conditions[?(@.type=="Programmed")].reason}{"\n"}'
output
False AddressNotAssigned

The Envoy Service name is generated from the Gateway. Read it before you forward it:

bash
kubectl get svc -n envoy-gateway-system -l gateway.envoyproxy.io/owning-gateway-name=eg
output
NAME                        TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
envoy-default-eg-e41e7b31   LoadBalancer   10.96.235.236   <pending>     80:32317/TCP   4m42s

EXTERNAL-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:

bash
kubectl -n envoy-gateway-system port-forward svc/envoy-default-eg-e41e7b31 19080:80
output
Forwarding from 127.0.0.1:19080 -> 10080
Forwarding from [::1]:19080 -> 10080

Forward ingress-nginx on 18080 as well. That Service port 80 is also the port in the pod:

bash
kubectl -n ingress-nginx port-forward svc/ingress-nginx-controller 18080:80
output
Forwarding from 127.0.0.1:18080 -> 80
Forwarding from [::1]:18080 -> 80

The route is accepted and its Service refs resolve. Read the conditions by type:

bash
kubectl get httproute web -o \
jsonpath='{range .status.parents[0].conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'
output
Accepted=True Accepted
ResolvedRefs=True ResolvedRefs

Accepted=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:

bash
curl -sS -D - --max-time 5 -H 'Host: app.example.com' http://127.0.0.1:19080/
output
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=stable

This 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:

bash
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
output
     20 version=canary
    180 version=stable

180 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:

yaml
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: 1

Apply the smaller weights:

bash
kubectl apply -f route-9-1.yaml
output
httproute.gateway.networking.k8s.io/web configured

Count 100 requests:

bash
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
output
     10 version=canary
     90 version=stable

90 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

IMPORTANT
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:

yaml
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: 10

Apply it:

bash
kubectl apply -f route-omitted.yaml
output
httproute.gateway.networking.k8s.io/web configured

Count 100 requests again:

bash
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
output
     91 version=canary
      9 version=stable

91 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" and canary-weight-total: "100" is 10%
  • canary-weight: "10" and canary-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:

yaml
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: 80

Apply both Ingresses:

bash
kubectl apply -f ingress-canary.yaml
output
ingress.networking.k8s.io/web created
ingress.networking.k8s.io/web-canary created

Count 200 requests on port 18080:

bash
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
output
     22 version=canary
    178 version=stable

22 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:

yaml
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: 0

Apply the zero weight:

bash
kubectl apply -f route-rollback.yaml
output
httproute.gateway.networking.k8s.io/web configured

Count 50 requests:

bash
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
output
     50 version=stable

All 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:

bash
kubectl annotate ingress web-canary nginx.ingress.kubernetes.io/canary-weight=0 --overwrite
output
ingress.networking.k8s.io/web-canary annotated

Count 50 requests on port 18080:

bash
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
output
     50 version=stable

canary-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-header first
  • canary-by-cookie next
  • canary-weight for 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:

bash
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 --overwrite
output
ingress.networking.k8s.io/web-canary annotated

Send X-Canary: always once:

bash
curl -sS -D - --max-time 5 -H 'Host: app.example.com' -H 'X-Canary: always' http://127.0.0.1:18080/
output
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=canary

Twenty 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:

yaml
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: 10

Apply the two-rule route:

bash
kubectl apply -f route-header.yaml
output
httproute.gateway.networking.k8s.io/web configured

Send x-canary: always to port 19080:

bash
curl -sS -D - --max-time 5 -H 'Host: app.example.com' -H 'x-canary: always' http://127.0.0.1:19080/
output
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=canary

Twenty 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:

bash
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
output
      6 version=canary
     44 version=stable

Six 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


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.

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.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)