Consul End to End: Install, Canary, Lock Down, Monitor

The first instruction in this guide is to not do the thing the other two guides in this series told you to do. Do not install the Gateway API CRDs before Consul. Consul ships its own copy, and an existing installation of the upstream ones puts its API Gateway controller into a crash loop that reports itself as a healthy GatewayClass.

That sets the tone. Consul is the most talkative of the three meshes I have tested, and almost every failure below names its own cause in a message somewhere. The catch is that the messages are in places nobody looks: a SYNCED column, a .status.conditions block, an init container log that scrolls the same line once a second. This guide goes end to end on Consul 2.0.2 and Kubernetes 1.35, on a kind cluster, in about two hours. Every number is measured, including the ones that made me delete a section and write it again.

Before you start

You need Docker, kind, kubectl and Helm. You do not need the consul-k8s CLI to follow this guide, because everything here goes through Helm, but it is worth having for consul-k8s status and it is the one tool people skip:

# macOS or Linux, via Homebrew
brew install hashicorp/tap/consul-k8s

# or pin it explicitly
VERSION=2.0.2
curl -sSLo consul-k8s.zip \
  "https://releases.hashicorp.com/consul-k8s/${VERSION}/consul-k8s_${VERSION}_linux_amd64.zip"
unzip consul-k8s.zip && sudo mv consul-k8s /usr/local/bin/

Check it before going further. The build I ran reports a FIPS suffix, which is normal:

consul-k8s version
consul-k8s v2.0.2+fips1402

The cluster is two nodes on plain kindnet:

kind create cluster --name mesh-consul --image kindest/node:v1.35.0

The demo app is four workloads: a backend in two versions serving distinguishable bodies, a flaky workload backed by go-httpbin for controllable failure, and a client that only makes requests. Every workload has its own ServiceAccount, which matters more here than on any other mesh.

Step 1 - Do not install Gateway API, and understand why

Both the Linkerd and Istio guides open by installing the upstream Gateway API CRDs. On Consul that is actively harmful, and it took me three failures to see it.

The Helm chart ships its own copy of five Gateway API CRDs, annotated bundle-version: v0.6.2 and channel: experimental. The chart’s own values file warns you:

# These CRDs can clash with existing Gateway API CRDs if they are already
# installed in your cluster.
manageExternalCRDs: true

If you install upstream Gateway API v1.6.1 first and then run helm install, you get three errors in a row, and only the last one is about Gateway API at all.

It starts with Helm refusing to adopt CRDs it does not own:

Error: INSTALLATION FAILED: unable to continue with install: CustomResourceDefinition
"gatewayclasses.gateway.networking.k8s.io" in namespace "" exists and cannot be
imported into the current release: invalid ownership metadata; label validation
error: missing key "app.kubernetes.io/managed-by": must be set to "Helm"

Nothing there mentions Consul or Gateway API versions. It reads like a labelling problem, so you label them.

Label them and the next refusal is about field ownership:

Error: INSTALLATION FAILED: conflict occurred while applying object
/gatewayclasses.gateway.networking.k8s.io ... Apply failed with 4 conflicts:
conflicts with "kubectl":
- .metadata.annotations.gateway.networking.k8s.io/bundle-version
- .metadata.annotations.gateway.networking.k8s.io/channel
- .spec.versions

That is a direct consequence of the kubectl apply --server-side both earlier guides teach. Helm 4 applies server-side too, and the field manager named kubectl owns .spec.versions.

Force past that and you finally get told what is actually wrong, because upstream Gateway API now ships a ValidatingAdmissionPolicy called safe-upgrades with failurePolicy: Fail:

customresourcedefinitions.apiextensions.k8s.io "referencegrants.gateway.networking.k8s.io"
is forbidden: ValidatingAdmissionPolicy 'safe-upgrades.gateway.networking.k8s.io'
denied request: Installing CRDs with version before v1.5.0 is prohibited by default.

The failure that does not announce itself

I spent a while on a configuration that looks like the right answer: set connectInject.apiGateway.manageExternalCRDs: false so Consul coexists with the upstream CRDs. It installs cleanly. The GatewayClass reports:

NAME     CONTROLLER                                ACCEPTED
consul   consul.hashicorp.com/gateway-controller   True

That is the exact check the Istio guide uses to confirm a good install, and on Consul it is not sufficient. The GatewayClass controller and the Gateway controller are separate processes inside the same pod, and only the first one was running. The injector had six restarts and the previous container’s log said why:

ERROR setup problem running manager {"error": "failed to wait for gateway-v1 caches
to sync kind source: *v1alpha2.TCPRoute: timed out waiting for cache to be synced
for Kind *v1alpha2.TCPRoute"}

Consul detects the tcproutes CRD by name, logs TCPRoute CRD detected - enabling TCPRoute support, and then waits for a v1alpha2 cache. Upstream v1.6.1 serves TCPRoute at v1 only. After about three and a half minutes the manager gives up, the process exits 1, and it starts again. Every Gateway you create sits at Waiting for controller forever.

The mutating webhook is served by that same process, so pod injection keeps working in the gaps between crashes. That is what makes it hard to see.

So: let Consul own those CRDs. If your cluster already has Gateway API for something else, that conflict is real and you will have to resolve it before Consul’s mesh gateway will work at all.

Step 2 - Install Consul, then add the CRD its gateway needs and never installs

The values file is short. Two settings do most of the work:

global:
  name: consul
  datacenter: dc1
  tls:
    enabled: true
  acls:
    manageSystemACLs: true
  metrics:
    enabled: true
    enableGatewayMetrics: true
server:
  replicas: 1
  bootstrapExpect: 1
connectInject:
  enabled: true
  default: false

tls.enabled and acls.manageSystemACLs are what make workload identity real. They are also what makes Consul start closed, which is Step 4. metrics.enabled is false by default and nothing warns you about it; leaving it off means no metrics listener and no Prometheus annotations later.

helm repo add hashicorp https://helm.releases.hashicorp.com
helm install consul hashicorp/consul --version 2.0.2 \
  -n consul --create-namespace -f consul-values.yaml

Three pods, and no DaemonSet, because consul-dataplane talks to the servers directly rather than through a per-node agent:

Pod CPU Memory
consul-server-0 19m 22 Mi
consul-connect-injector 4m 28 Mi
consul-webhook-cert-manager 4m 13 Mi
total 27m 63 Mi

The API Gateway will not start on Consul OSS without a manual CRD

This one is worth the section heading. With the CRD versions correct, my Gateway still sat at Waiting for controller, and the reason is that the gateway-v1 controller watches RouteExtProc unconditionally, while the chart only installs that CRD if you have an enterprise licence.

Every other controller started. That one did not. Measured by adding and removing the CRD twice, restarting the injector each time:

routeextprocs CRD Controllers reaching “Starting workers” gateway-v1 among them
absent 18 no
present 19 yes
absent again 18 no
present again 19 yes

The fix is to render the CRD out of the chart with a dummy licence and apply it. No RouteExtProc object is ever created; the definition only has to exist so the controller’s cache can sync:

helm template consul hashicorp/consul --version 2.0.2 \
  --set global.enterpriseLicense.secretName=dummy \
  --set global.enterpriseLicense.secretKey=dummy \
  -s templates/crd-routeextprocs.yaml | kubectl apply -f -
kubectl rollout restart deployment/consul-connect-injector -n consul

Give it three minutes before judging. I diagnosed this wrong the first time. The controller appeared to be missing, but the pod was actually sitting at Attempting to acquire leader lease because the old replica still held the lock, and no controller at all had started. Leader handover, not a missing CRD. The table above was only produced after allowing time for the lease on every run.

Step 3 - Enrol your app, and meet two rules that are Consul’s alone

Enrolment is an annotation on the pod template, because this install sets connectInject.default: false:

metadata:
  annotations:
    consul.hashicorp.com/connect-inject: 'true'

Two requirements follow, and neither exists on Istio or Linkerd.

Every workload needs its own ServiceAccount. Consul’s Kubernetes auth method binds the Consul service name from the pod’s service account, with a binding rule whose selector is literally serviceaccount.name!=default. A pod on the shared default account can never log in.

Every mesh member needs a Kubernetes Service, including pure clients. Consul registers from Endpoints, so a workload with no Service is never registered and its init container never exits. My client listens on nothing, has no Service in the shared manifest, and sat in Pending indefinitely:

[INFO]  Unable to find registered services; retrying
[INFO]  Check to ensure a Kubernetes service has been created for this application.

Here is the part that will cost you an hour if nobody tells you: that exact message also appears on workloads that are completely healthy. My backend and flaky logged it for 60 to 90 seconds while the endpoints controller caught up, then went 2/2 Running. The message is not a diagnosis. The signal is whether it stops.

The rule hiding behind those two

The Service name and the ServiceAccount name have to match. I found this by pointing a Service named loadgen at pods running under the client account, which put the init container into a restart loop:

[ERROR] Error processing service registration:
        error="service account name client doesn't match Consul service name loadgen"

Consul takes the service name from the Kubernetes Service and the identity from the ServiceAccount, and refuses to register unless they agree. The consequence is worth saying plainly: on Consul, two workloads cannot share a mesh identity. On Istio and Linkerd, any number of Deployments can run under one ServiceAccount and be treated as one caller. Here each one is its own service and needs its own line in policy.

Step 4 - Nothing works now, and that is correct

Enrol the app and every request between meshed pods stops:

client -> backend : curl exit 52, three attempts
client -> flaky   : curl exit 52

This is the opposite of the other two meshes. Istio and Linkerd enrol an app transparently and stay open until you write a policy. Consul enrols your app and cuts its traffic, and you write an intention to get it back.

Be precise about the cause: it follows from acls.manageSystemACLs: true in the values file, which sets the server’s default policy to deny. It is a property of this install, not of Consul in general.

apiVersion: consul.hashicorp.com/v1alpha1
kind: ServiceIntentions
metadata:
  name: backend
  namespace: shop
spec:
  destination:
    name: backend
  sources:
    - name: client
      action: allow

sources.name is the Consul service name, which is both the ServiceAccount and the Service name from Step 3.

One object per destination, and Consul enforces it. A second ServiceIntentions naming the same destination is rejected before it exists:

admission webhook "mutate-serviceintentions.consul.hashicorp.com" denied the request:
an existing ServiceIntentions resource has `spec.destination.name: backend`

So every caller of a service is a list entry inside that service’s single object. This is the pattern for the whole product: Consul is loud in the places the other two are quiet.

Read the SYNCED column, not your curl

The check that matters after applying any Consul config object:

kubectl get serviceintentions -n shop
NAME      SYNCED    LAST SYNCED   AGE
backend   True      0s            0s
flaky     Unknown   0s            0s

On a warm mesh, SYNCED: True and a 200 arrived in the same one-second poll, in both directions, measured across several apply and delete cycles. It is a usable gate rather than a hint. It also catches the failure mode where an object never landed at all, which happened to me once inside a scripted loop that was throwing away its own output.

Step 5 - Tell Consul your services speak HTTP, or half this guide does nothing

This is the largest gotcha in Consul, and it gates two of the four scenarios below.

Consul treats every service as opaque TCP until you say otherwise. With the app meshed and metrics on, I sent 30 requests and watched the counters:

before after
envoy_tcp_downstream_cx_total{envoy_tcp_prefix="public_listener"} 68 84
envoy_cluster_upstream_rq{envoy_cluster_name="local_app"} 17 17

Connections counted, requests did not, because an L4 proxy has no requests to count. There is no success rate panel to build, no latency histogram, no retries, and no traffic splitting.

The fix is one object per service:

apiVersion: consul.hashicorp.com/v1alpha1
kind: ServiceDefaults
metadata:
  name: backend
  namespace: shop
spec:
  protocol: http

Applying it turned on the entire L7 surface at once:

before after
envoy_cluster_upstream_rq{local_app} frozen at 17 433 and climbing
latency histogram series 0 40
RBAC metric series 0 8

I found this the hard way, by applying a ServiceSplitter first and watching traffic carry on round-robining. The splitter told me, in .status.conditions:

Synced=False  reason=ConsulAgentError
message=writing config entry to consul: Unexpected response code: 500
  (discovery chain "backend" uses a protocol "tcp" that does not permit
   advanced routing or splitting behavior)

kubectl get events for that object returned nothing. The reason exists only on the object’s status.

Step 6 - Get metrics, and accept that the mTLS number does not exist

Enabling global.metrics.enabled makes the injector write three annotations onto every meshed pod at injection time, pointing at port 20200 on the proxy:

prometheus.io/scrape = true
prometheus.io/path   = /metrics
prometheus.io/port   = 20200

Existing pods do not get them retroactively. Wait for the injector’s own rollout to finish before restarting your workloads. I restarted mine twelve seconds after the helm upgrade and got pods that were injected, 2/2 Running, and had no annotations and no listener on 20200, because the old injector replica was still serving the webhook. Nothing about those pods looked wrong.

The scrape job is annotation-driven, and the one thing to add is a port filter:

- job_name: 'consul-dataplane'
  kubernetes_sd_configs:
    - role: pod
  relabel_configs:
    - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
      action: keep
      regex: 'true'
    - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_port]
      action: keep
      regex: '20200'

Keeping only scrape=true is what every tutorial shows and it is too broad. prometheus.io/* is a general convention, not a Consul one, and in the Istio leg of this lab that mistake pulled MetalLB’s three pods in as permanently down targets.

The panel every mesh guide puts first, and why this one cannot

Linkerd and Istio both have a per-request mTLS label whose obvious query lies, and in both cases a filter fixes it. Consul does not have the label at all. This is the complete label set on its data plane metrics:

consul_source_datacenter   consul_source_namespace   consul_source_partition
consul_source_service      envoy_cluster_name        envoy_http_conn_manager_prefix
envoy_listener_address     envoy_rbac_prefix         envoy_response_code
envoy_response_code_class  envoy_tcp_prefix          envoy_tls_certificate
envoy_worker_id            le                        local_cluster
quantile                   version

No security_policy, no tls, no reporter. The only TLS signal is envoy_listener_ssl_handshake, which counts connections. Dividing it by request count gave me 14.5% on a mesh that was encrypting everything, because requests are multiplexed over connections about seven to one. That number is not a percentage of anything.

What is answerable is which listener a request arrived on:

Listener Carries Encrypted
public_listener mesh inbound mTLS enforced
upstream mesh outbound mTLS
exposed_path_filter__20400 rewritten kubelet probes plaintext
envoy_prometheus_metrics the metrics port plaintext

And the plaintext volume is not a rounding error. Over one window: 716 requests through the mesh, 256 plaintext on the probe port, 20 plaintext on the metrics port. A quarter of the traffic reaching those pods never touched mTLS, and no handshake ratio would have shown it.

Prove the encryption instead of trusting it

Run tcpdump in a privileged DaemonSet on the node, filter to both backend pod IPs, and exclude the two plaintext ports so the capture measures the mesh rather than the probes:

Run Packets Marker in clear Plaintext GET
mesh path, ports 20200 and 20400 excluded 352 0 0
probe port 20400 only 200 20 20

The second row is the control, and it is not optional. Zero markers is what success looks like and it is also what a capture that never started looks like. Filtering on one backend pod is another way to fool yourself here, because the Service balances across two.

Where each object attaches

Where each Consul configuration object attachesA request travels from a client pod, through a Kubernetes Service, to two backend pods. A ServiceDefaults declaring protocol http sits above everything as a gate: without it, the ServiceResolver, ServiceSplitter and ServiceRouter below are rejected or inert, because Consul treats every service as raw TCP by default. Those three attach to the destination's service name. ServiceIntentions attaches to the destination too and allows one object per destination. Circuit breaking is the exception: it lives in a ServiceDefaults belonging to the caller, under upstreamConfig.One object gates the restServiceDefaultsprotocol: httpwithout it, all three are deadServiceResolver · subsetsServiceSplitter · weightsServiceRouter · retries, timeoutsall keyed by the destination service nameclient podits own identityService: backendalso sets the Consul namebackend v190%backend v210%ServiceDefaultson the CALLERupstreamConfig.overridescircuit breaking onlyServiceIntentionsexactly one per destinationevery caller is a list entryREAD IT THIS WAYSet the protocol first or the rest is scenery.Splitting, routing and policy all key off thedestination's name, which comes from itsService. Circuit breaking is the exception:it belongs to whoever is calling.

Step 7 - Ship a canary

Consul splits this across two objects. A ServiceResolver defines what the subsets are, a ServiceSplitter decides the weights:

apiVersion: consul.hashicorp.com/v1alpha1
kind: ServiceResolver
metadata:
  name: backend
  namespace: shop
spec:
  subsets:
    v1: { filter: 'Service.Meta.version == v1' }
    v2: { filter: 'Service.Meta.version == v2' }
---
apiVersion: consul.hashicorp.com/v1alpha1
kind: ServiceSplitter
metadata:
  name: backend
  namespace: shop
spec:
  splits:
    - { weight: 90, serviceSubset: v1 }
    - { weight: 10, serviceSubset: v2 }

There is a step before this that is easy to miss. Both Deployments register as the same Consul service, because they share the backend ServiceAccount, so there is nothing to select on until the pods carry distinguishing metadata:

metadata:
  annotations:
    consul.hashicorp.com/service-meta-version: v1

Two runs of 200 requests against a requested 90/10:

Run v1 v2
1 179 21
2 181 19

Gateway API cannot do this job on Consul

It is reasonable to ask why not use HTTPRoute with weighted backendRefs, which is portable. I tried both ways in.

An HTTPRoute with a Service parentRef, the mesh-routing shape, was accepted by Kubernetes and ignored in silence: status.parents came back empty. No condition, no event, no error. That is the same signature as a wrong Gateway API parent group, and it means the same thing, which is that nothing claimed your route.

Weighted backendRefs needs one Service per version, and that collides with Step 3. Adding a backend-v1 Service alongside backend broke registration on the next restart:

[ERROR] There are multiple Consul services registered for this pod when there must
only be one ... add the label `consul.hashicorp.com/service-ignore: "true"` to all
services except the one used by Consul

Consul names the fix in the error, and taking it closes the door: a Service labelled service-ignore is not a Consul service, so it cannot be a mesh backend.

ResolvedRefs=False  BackendNotFound: shop/backend-v1: backend not found
Accepted=True       Synced=True

The shape of that status is worth noticing, because Accepted and Synced are both True and only ResolvedRefs is false. A reader checking whether the route was accepted gets yes.

So the fork resolves itself. ServiceSplitter is not one of two options, it is the only one, and Consul’s own CRDs reach further anyway: the 90/10 split applied to traffic arriving through the API Gateway in Step 9 with nothing gateway-specific configured, because the split lives in the destination’s discovery chain rather than on a route.

Step 8 - Refuse an unauthorized caller

Deploy a second meshed workload with its own ServiceAccount and no entry in any intention. Without one, a policy that allows the only caller in the namespace proves nothing.

Caller Path Result
client the Service 200 200 200
intruder the Service 000 000 000

The trap: port 20400 walks around the intention

The same refused pod, asking the backend’s pod IP directly:

kubectl exec -n shop intruder-... -- curl http://10.244.1.42:20400/
backend v1

Port 20400 is where Consul rewrites the kubelet’s HTTP readiness probe. It carries no mTLS and consults no intention, because the kubelet has no mesh identity and never will.

Consul’s version of this hole is the narrowest of the three meshes, and you set its size. The listener is named exposed_path_filter__20400 and the name is literal: it serves the probe’s exact path and 404s everything else.

Workload Probe path / /index.html /../etc/passwd
backend / 200, 11 bytes 404 404
flaky /status/200 404 404 404

flaky leaks nothing because its probe path returns an empty body. backend leaks its whole response because its probe path is /. So the first fix is free and needs no policy: point the probe at something that returns nothing worth having.

For defence in depth, a NetworkPolicy. Note that it is allow-list based, so naming any ingress rule for these pods closes every port you did not list, including the mesh’s own 20000:

spec:
  podSelector:
    matchLabels: {}
  policyTypes: [Ingress]
  ingress:
    - ports:
        - { protocol: TCP, port: 20000 }
    - from:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: monitoring }
      ports:
        - { protocol: TCP, port: 20200 }

There is deliberately no ipBlock for the kubelet. Pinning a probe source range is the standard advice and it is wrong: the address is not stable, and a policy that guesses it becomes an outage the first time a node changes. Measured after applying it, every pod stayed Ready with zero restarts, and:

Check Result
intruder to pod IP :20400 exit 28
unmeshed pod to :20400 exit 28
unmeshed pod to :20200 exit 28
client through the mesh 200 200 200
Prometheus targets up 9

Step 9 - Let traffic in from outside

A Gateway and an HTTPRoute, and this is the one place Gateway API is the right tool on Consul:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: shop-gateway
  namespace: shop
spec:
  gatewayClassName: consul
  listeners:
    - name: http
      protocol: HTTP
      port: 8080
      allowedRoutes:
        namespaces: { from: Same }
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: backend
  namespace: shop
spec:
  parentRefs:
    - name: shop-gateway
      sectionName: http
  rules:
    - matches:
        - path: { type: PathPrefix, value: / }
      backendRefs:
        - name: backend
          port: 80

Leave parentRefs.group unset. It is the empty string, not core, and writing core is a mistake that cost the Linkerd version of this guide a correction in four places.

Consul provisions a Deployment and a LoadBalancer Service per Gateway, and reports four conditions including two of its own:

Accepted=True        Programmed=True
Synced=True          ConsulAccepted=True

Two things that will look like the gateway is broken

A green route with dead traffic. My first request through the gateway timed out while the HTTPRoute reported ResolvedRefs, Accepted, Synced and ConsulAccepted all True. The cause was the NetworkPolicy from Step 8, whose podSelector is matchLabels: {} and therefore selected the gateway pod Consul had not created yet. Route status describes the route, not the network. Four green conditions and a timeout means look somewhere else. NetworkPolicies are additive, so a second one selecting gateway.consul.hashicorp.com/managed: "true" and allowing 8080 fixes it without loosening anything.

A 403 that looks like a bug. The gateway is an ordinary mesh member, its identity is the Gateway resource’s name, and nothing allowed it:

RBAC: access denied  <- http=403

Because Consul permits one ServiceIntentions per destination, the ingress step cannot ship as a self-contained manifest. shop-gateway has to be added to backend’s existing object. Worth noticing that the same policy produces two different failures depending on which door you knock on:

Path Denied looks like
meshed caller to the Service connection reset, curl exit 52, no body
outside caller to the API Gateway HTTP 403, body RBAC: access denied

The gateway terminates HTTP and can answer. The sidecar can only hang up.

On kind there is no LoadBalancer, so the Service stays <pending>. MetalLB with a pool from the kind Docker subnet assigned a real address and the Gateway’s status.addresses filled in, and it served 200 from a pod in the node’s network namespace. From my Windows host the same address timed out, because that network lives inside the WSL2 VM. Use kubectl port-forward and move on.

Step 10 - Survive a bad dependency

Consul splits this across two objects with different owners, and getting that backwards is the usual mistake:

Object Belongs to Carries
ServiceRouter the destination numRetries, retryOnStatusCodes, requestTimeout
ServiceDefaults the caller upstreamConfig.overrides[].passiveHealthCheck
apiVersion: consul.hashicorp.com/v1alpha1
kind: ServiceRouter
metadata:
  name: flaky
  namespace: shop
spec:
  routes:
    - match: { http: { pathPrefix: /status } }
      destination:
        service: flaky
        numRetries: 3
        retryOnStatusCodes: [503]
    - match: { http: { pathPrefix: /delay } }
      destination:
        service: flaky
        requestTimeout: 1s

Timeouts land where you put them, and return 504 rather than 503:

Request Result
/delay/0 200 in 0.007s
/delay/1 504 in 0.966s
/delay/3 504 in 0.962s
/headers, matching no route 200 in 0.006s

Retries do exactly what the arithmetic says, against an endpoint that returns 200 or 503 at random:

Success out of 60
no policy 22 (36.7%)
three retries, run 1 56
three retries, run 2 56
three retries, run 3 54

That is 90 to 93 percent against a theoretical 93.75 for three retries on a coin flip.

Circuit breaking with the obvious setting destroys the service

Envoy’s default maxEjectionPercent is 10, which on a three-replica service rounds down to zero instances, so ejection appears not to work and the obvious fix is 100. Measured with 100:

Metric Value
envoy_cluster_outlier_detection_ejections_active 3
envoy_cluster_membership_healthy / total 5 / 8

Three of three instances ejected. Three runs of 60 during that window returned 28, then 0, then 0. A service that was succeeding half the time now succeeds never, and the retry policy is useless because there is nothing left to retry against.

The reason is the part worth keeping. Outlier detection assumes some instances are worse than others. All three replicas here are equally flaky, so there is no healthy instance to shift load to, and allowing 100% ejection lets the proxy conclude that nothing is healthy, which is true and useless. Dropping to 34, at most one of three, returned three consecutive runs of 54 out of 60, which is the retries-only figure. On uniform failure the most circuit breaking can do for you is stay out of the way.

Step 11 - Monitoring you can keep

Seven panels, and the first one is the interesting one because it is not the panel you would build on any other mesh.

Grafana dashboard with seven panels against the app from this guide. Plaintext share of requests reads 10.4 percent in green, throughput 7.46 requests per second, and 5xx rate 2.39 per second in red. A request rate panel shows backend v1 at about 2.4 requests per second against backend v2 at about 0.25, the ninety ten canary, with flaky above both near 4.8. A success rate panel using a 2xx only definition shows backend v1 and backend v2 flat at 100 percent and flaky near 53 percent. A latency panel shows p50 flat near zero, p95 near 30 milliseconds and p99 near 105 milliseconds. An intention denials panel shows denied to backend climbing from zero in the last few minutes, with allowed to backend near 2.3 per second and allowed to flaky near 4.8.
The canary is the gap between backend v1 and backend v2 in the request rate panel. The first panel is a plaintext share rather than an mTLS share, because Consul has no per-request mTLS label to build the usual one from.

Two panels need explaining rather than copying.

Success rate is 2xx only, deliberately. Counting non-5xx as success hides authorization, because a caller denied through the API Gateway is an ordinary 403 and would read as healthy. Note that backend sits at 100% here even with an intruder hammering it, because Consul’s mesh-internal denial resets the connection and never becomes a request at all.

Denials come from envoy_http_rbac_denied, and it only counts L7 refusals. A service-level intention is enforced at L4: the sidecar closes the connection before any HTTP is spoken, so the counter never moves. To make denials visible you need an L7 intention, which is a genuinely useful feature in its own right:

- name: loadgen-intruder
  permissions:
    - action: allow
      http:
        pathExact: /healthz

That caller asks for /index.html every 1.2 seconds, so scoping its allowance to /healthz denies every request it actually makes, with a real 403 that the counters can see.

Step 12 - Your Kubernetes namespaces are not a boundary

Save this one for last because it changes how you write every policy above.

Consul namespaces are an enterprise feature. On OSS every workload lands in the Consul namespace default regardless of which Kubernetes namespace it lives in, and the identity carries no trace of the Kubernetes one:

spiffe://<uuid>.consul/ns/default/dc/dc1/svc/client

So I created a namespace called shop-tenant-b, put a ServiceAccount named client and a Service named client in it, and deployed a pod that does nothing. It has no entry in any intention and no relationship to anything.

The Consul catalogue did not gain a second service. Both pods registered as the same one:

Kubernetes namespace ServiceAccount Pod IP
shop client 10.244.1.36
shop-tenant-b client 10.244.1.70

Then I made it call the protected backend:

Caller Namespace Named in any policy? Result
client shop yes 200
client shop-tenant-b no 10 out of 10, backend v1
intruder shop no 000

A workload in a different namespace, permitted by nobody, got in ten times out of ten, while a workload in the same namespace as the backend was refused. What decided it was the name.

This is not a bug so much as the documented identity model meeting a Kubernetes assumption that does not hold, and on a shared cluster it matters: anyone who can create a namespace with a ServiceAccount and Service of the right name inherits that name’s permissions across the whole datacenter. Istio’s identity is spiffe://cluster.local/ns/<namespace>/sa/<account> and does not have this property.

You get a smaller warning about the same thing if you try to give the tenant its own ServiceDefaults:

admission webhook "mutate-servicedefaults.consul.hashicorp.com" denied the request:
servicedefaults resource with name "client" is already defined - all servicedefaults
resources must have unique names across namespaces

If you run multi-tenant on Consul OSS, service names are a flat global namespace and have to be treated like one. Prefix them per tenant, or budget for Consul Enterprise.

Where to go next

The single thing to take from this guide: on Consul, the object you have not written yet is usually the one breaking the object you have. A ServiceSplitter that quietly does nothing is waiting for a ServiceDefaults to declare a protocol, a workload stuck in Pending is waiting for a Service nobody told you it needed, and a Gateway that never leaves Waiting for controller is waiting for a CRD that only enterprise installs get. Consul had already written down what was wrong in every one of those cases, in a SYNCED column, a .status.conditions block, or an init container log nobody was reading.

So the next action is small, and it is worth building the habit around. Take any Consul config object you have applied and run:

kubectl get serviceintentions,servicedefaults,serviceresolvers,servicesplitters,servicerouters -A

If anything in the SYNCED column says False or Unknown, read its .status.conditions before you touch anything else. That one column would have saved me most of the debugging in this article.