How does a service mesh work? Data plane, control plane, and identity

A service mesh is a layer of proxies that handles service-to-service traffic so application code doesn't have to. It has three moving parts.

The data plane is the set of proxies that actually carry requests. In the classic model, each pod runs a sidecar proxy, and the pod's network rules redirect its traffic through that proxy. A call from service A to service B takes this path: A's outbound proxy, then the network, then B's inbound proxy, then B. Both proxies see every request. That is why they can encrypt traffic, retry failures, split traffic between versions, and emit latency and error metrics without changes to the application.

The control plane configures those proxies. It watches the cluster for services and endpoints, takes your routing and authorization policy, and pushes the resulting configuration to every proxy. The proxies enforce the policy. The control plane decides what the policy is.

Identity is what makes the mesh more than a set of load balancers. The control plane runs a certificate authority. It issues each workload a short-lived certificate tied to its service account and rotates it automatically. Proxies use these certificates for mutual TLS, so a policy can say "only the billing service may call payments" based on cryptographic identity instead of IP addresses.

Before you adopt a mesh, check what you already have, because some of this may be covered:

  • Retries and timeouts are often handled by client libraries.
  • Request metrics may already come from instrumented RPC frameworks.
  • Edge routing and TLS termination are typically handled by your ingress controller.
  • Coarse traffic restrictions may already be enforced by network policies.
+---------------------------------------------------------------------------+
| CONTROL PLANE                                                             |
| - watches the cluster for services and endpoints                          |
| - turns routing/authz policy into proxy config, pushes it to proxies      |
| - runs a CA: issues short-lived certs tied to service accounts, rotates   |
+---------------------------------------------------------------------------+
               | config + certs                              | config + certs
               v                                             v
+-----------------------------+               +-----------------------------+
| billing pod                 |               | payments pod                |
|  +---------+   +---------+  |               |  +---------+   +---------+  |
|  | billing |-->| sidecar |--+-----mTLS------+->| sidecar |-->|payments |  |
|  |   app   |   |  proxy  |  |               |  |  proxy  |   |   app   |  |
|  +---------+   +---------+  |               |  +---------+   +---------+  |
|  outbound: retries, timeouts|               |  inbound: mTLS termination, |
|  traffic split, metrics     |               |  authz by identity, metrics |
|  pod rules redirect traffic |               |  pod rules redirect traffic |
+-----------------------------+               +-----------------------------+

Request path: billing -> its proxy -> network (mTLS) -> payments' proxy
              -> payments. The control plane decides; the proxies enforce.

Sidecar service mesh: the control plane distributes config and short-lived workload certificates, and the sidecar proxies on each side of a call carry the traffic over mutual TLS and enforce retries, routing, metrics and identity-based authorization.

Do I need a service mesh? A decision checklist

The question is whether the remaining gaps recur across many services and teams, or whether they are isolated problems a library or a design change would fix more cheaply.

Signal Points toward a mesh Points toward waiting
Services and teams Dozens of services owned by several teams A handful of services, one team
Language stack Polyglot, so each library feature is reimplemented per language One language with a mature RPC library
Encryption in transit Compliance requires mTLS on every internal hop, with audit evidence TLS at the edge satisfies your auditors
Service identity Policy must say "service A may call B," independent of IP Network policies by namespace are enough
Traffic control Retries, timeouts and canary shifts are inconsistent or missing Already configured in client libraries and working
Platform ownership A team will own upgrades, certificates and incidents Nobody has time to upgrade the control plane

A mesh earns its overhead when several rows land in the left column at once. Uniform mTLS is the clearest case. Rotating certificates and enforcing identity across five languages by hand produces drift, and the mesh replaces that with a single mechanism.

Wait if the friction is really application design. A mesh retry policy cannot fix a chatty call graph, a missing idempotency key, or a synchronous chain that should be a queue. If you add retries in the proxy without fixing those, failures get amplified, not absorbed. An unowned mesh is also worse than none. Control-plane upgrades and certificate expiry turn into outages that nobody on call understands.

Who should use a service mesh

A mesh fits organizations where the same cross-cutting networking problem shows up in many services and is owned by many teams. The typical candidates are:

  • Platform teams running a shared Kubernetes cluster for several product teams. They need one way to enforce encryption, identity, and traffic policy without asking every team to adopt the same library.
  • Polyglot organizations. If services are written in several languages, a proxy gives every service the same mTLS, retry, and telemetry behavior, instead of a separate library implementation per language with its own bugs and upgrade schedule.
  • Teams under compliance requirements for encryption in transit. Mesh mTLS with strict mode gives a single, auditable mechanism for "every internal hop is encrypted and authenticated."
  • Teams that ship frequently and want progressive delivery. Weighted traffic splits and header-based routing make canary releases a configuration change rather than a deployment trick.
  • Organizations with people to own it. A mesh is a good fit only if someone will own control-plane upgrades, certificate health, and mesh-related incidents as part of their job.

If you match only the last point, the mesh is affordable but not necessarily useful. If you match the first four but not the last, it is useful but likely to become a liability.

When should you avoid a service mesh

Avoid or postpone a mesh in these situations:

  • Small systems. A few services owned by one team rarely have enough repeated work to justify a new piece of critical infrastructure. Client libraries, ingress TLS, and NetworkPolicy usually cover the need.
  • Single-language stacks with a mature RPC framework. If that framework already gives you TLS, deadlines, retries, and metrics, a mesh mostly duplicates them and adds a second place where retries can be configured.
  • No owner. If no team will handle upgrades, certificate rotation failures, and proxy debugging, do not install one. The mesh sits in the path of every request, so neglect turns into outages.
  • Problems that are really design problems. Chatty call graphs, non-idempotent operations, and synchronous chains that should be asynchronous are not fixed by proxies. Retries in the mesh can make them worse.
  • Workloads the mesh cannot cover well. If most of your critical traffic goes to external databases, managed services, or hostNetwork workloads, mesh mTLS protects little of it.
  • Tight resource or latency budgets you have not measured. If every replica's CPU and memory reservation matters, or extra proxy hops would break a latency target, measure on a test namespace before committing.
  • Not on Kubernetes or a platform the mesh supports well. Both Istio and Linkerd are designed primarily around Kubernetes. Running them for VM-heavy or mixed environments adds operational work you should weigh separately.

In these cases, the cheaper path is usually to standardize a client library, enforce TLS at the ingress and for external connections, and use NetworkPolicy for reachability. Revisit the decision when service count, team count, or compliance requirements change.

mTLS between services: mesh identity versus the alternatives

In a mesh, identity comes from the Kubernetes service account, not from IP addresses. The control plane acts as a certificate authority. It issues each workload a short-lived certificate that encodes that service account, typically as a SPIFFE-style URI, and rotates it automatically before expiry. The proxies perform the mTLS handshake, so each side learns which workload is calling, not just which pod IP. Authorization policy builds on that identity: "only orders may call payments, and only POST /charge." This is the main reason to choose mesh mTLS over cheaper options. You get identity-based, L7-aware policy, and nobody has to handle certificates by hand.

Approach Workload identity L7 policy Encryption Rotation burden
Mesh mTLS Yes, per service account Yes Proxy to proxy Automated by control plane
Application-level TLS Only if you build mTLS In app code End to end Yours, per service
Kubernetes NetworkPolicy No, labels and IPs only No, L3/L4 None None
CNI-level encryption No, node level No Node to node Handled by CNI

NetworkPolicy and mesh policy work well together. NetworkPolicy blocks reachability at L3/L4, and the mesh adds identity and request-level rules on top.

Mesh mTLS also has limits. It secures transport between proxies and nothing more. It does not tell you whether user 42 may read invoice 7, so application authorization stays in your code. Some traffic is not protected at all:

  • Traffic to external databases or SaaS APIs
  • Workloads without a sidecar or ambient enrollment
  • Anything using hostNetwork
  • Workloads in namespaces set to permissive mode

That traffic goes out exactly as the application sent it, often in plaintext.

apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
  namespace: shop
spec:
  mtls:
    mode: STRICT
---
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: payments-allow-orders
  namespace: shop
spec:
  selector:
    matchLabels:
      app: payments
  action: ALLOW
  rules:
  - from:
    - source:
        # Principal comes from the caller's mTLS certificate
        # (SPIFFE ID: spiffe://cluster.local/ns/shop/sa/orders)
        principals: ["cluster.local/ns/shop/sa/orders"]
    to:
    - operation:
        methods: ["POST"]
        paths: ["/charge"]

Istio configuration that enforces strict mTLS in a namespace and lets only the orders service account send POST /charge to payments. The caller's identity comes from its mesh certificate, not from its pod IP.

Sidecar overhead and ambient mesh: what the proxies cost

Every sidecar is a full proxy container in every pod, and that has four costs:

  • Resources per replica. The scheduler reserves the proxy's CPU and memory requests whether or not the service uses any mesh feature, so cost grows with pod count rather than with mesh usage.
  • Latency per hop. Each request passes through two extra proxies, one on the client side and one on the server side.
  • Lifecycle ordering. Init containers run before the proxy exists, so they have no mesh networking. A Job can hang after its main container exits because the proxy keeps the pod alive.
  • Restarts. Injecting or upgrading a sidecar requires restarting every affected workload.

Illustrative estimate (assumed numbers, not a benchmark):

Reserved overhead = pod count × per-sidecar request

Assume 400 pods, with 100m CPU and 128 MiB memory requested per sidecar:

  • CPU: 400 × 0.1 = 40 vCPU
  • Memory: 400 × 128 MiB = 50 GiB

Replace these with your own pod count and the requests your injector actually sets.

Istio's ambient mode removes the sidecar. A shared per-node component handles L4 proxying and mTLS. Optional L7 proxies run only for workloads that need routing or HTTP policy. This eliminates injection restarts and per-pod reservations. The tradeoff is that traffic from many tenants now passes through shared node components, so a fault or compromise in one of them affects every pod on that node.

Aspect Sidecar Ambient
Resource cost scales with Pod count Node count, plus L7 proxies where used
Enrollment Injection and restart Label, no restart
Blast radius of a proxy fault One pod Every pod on the node
Tenant isolation Per pod Shared node component

Before adopting ambient mode, check how mature it is for your CNI, platform, and required features.

Istio vs Linkerd: feature surface versus operational simplicity

Istio and Linkerd solve the same core problems: identity, mTLS, telemetry, and retries. They make opposite bets on scope. Istio builds on Envoy, a general-purpose proxy, and exposes much of Envoy's capability through its own resources. That brings fine-grained traffic shifting, header-based routing, fault injection, egress control, and extension points such as WebAssembly filters. It also offers both sidecar and ambient data planes. Linkerd uses its own Rust proxy, built only to be a mesh sidecar. It covers fewer cases, so there is less to learn, configure, and get wrong.

Dimension Istio Linkerd
Proxy Envoy, general-purpose and extensible Purpose-built lightweight proxy
Data plane modes Sidecar or ambient Sidecar
Configuration surface Many CRDs with overlapping concerns Small set of resources
Learning curve Steep; Envoy knowledge helps when debugging Shallow
Debugging tooling istioctl analyze, proxy config dumps linkerd check, built-in dashboards
Day-2 upgrades More moving parts to validate Fewer components, simpler rollouts

Each side carries a risk. With Istio, the large surface makes it easy to write configuration that is valid but wrong. A misordered route or an over-broad policy can pass validation and still break traffic. Debugging it means reading Envoy's view of the world. With Linkerd, the risk comes later. If you need advanced routing or custom proxy behavior, you may have to build workarounds in an ingress layer or in application code.

Choose based on two questions: which features you need today, and how many engineers can own the mesh. If you lack dedicated platform staff, Linkerd's narrower scope is usually the safer bet. Istio pays off when you need its traffic features and have people to run it.

Observability, retries, and the debugging cost a mesh adds

Once installed, a mesh gives you request rate, error rate, and latency per service, and often per route, without touching application code. Because the proxies terminate mTLS, that telemetry carries verified workload identity, so "who called whom" comes from certificates rather than from IP addresses that change as pods reschedule. That alone can cut hours from incident triage.

Distributed tracing is not free. The proxies can emit spans, but they cannot connect an inbound request to the outbound calls it triggers. Your applications must still forward trace headers. Skip that, and you get disconnected single-hop spans instead of end-to-end traces.

Retries belong in one layer

Retries configured in both the client library and the mesh multiply, and they compound at every hop.

Illustrative estimate (assumed numbers, not a benchmark): total attempts at the deepest service = (client attempts × mesh attempts) ^ hops. With 3 client attempts, 3 mesh attempts, and a 3-hop call chain, that is (3 × 3)³ = 729 requests from one user request against an already failing dependency.

Pick one layer per concern. A common split is to put retries and per-try timeouts in the mesh, where they are consistent and centrally visible, and keep only an overall request deadline in the client. Retry only idempotent operations.

New failure sources

A 503 can now come from a misapplied routing rule, an expired or unissued certificate, or an authorization policy denying traffic. Engineers need to learn the mesh CLI, how to read proxy configuration and logs, and how to tell proxy-generated errors from application errors. Budget time for that learning before the first incident.

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: orders
  namespace: shop
spec:
  hosts:
    - orders.shop.svc.cluster.local
  http:
    # Idempotent reads: retry a bounded number of times on transient failures.
    - match:
        - method:
            exact: GET
      route:
        - destination:
            host: orders.shop.svc.cluster.local
      timeout: 3s            # caps all attempts combined for this hop
      retries:
        attempts: 2          # retries after the first try, not total tries
        perTryTimeout: 1s    # stop a slow attempt before the 3s budget is spent
        retryOn: connect-failure,reset,gateway-error
    # Everything else, including POST: never retried by the mesh.
    - route:
        - destination:
            host: orders.shop.svc.cluster.local
      timeout: 3s
      retries:
        attempts: 0          # explicitly disable the mesh default retries

An Istio VirtualService that keeps retries and per-try timeouts in the mesh, limits them to GET requests, and explicitly disables retries for non-idempotent traffic so client libraries need only an overall deadline.

Incremental adoption path and impact on cost and engineering time

Roll out one namespace at a time

Choose a namespace with real traffic but a small blast radius. Before injecting proxies, record per-workload p50/p99 latency, CPU, and memory. Without a baseline, every regression turns into a debate. Start mTLS in permissive mode so meshed and unmeshed workloads can still talk to each other. Switch to strict only after the mesh's metrics show that all inbound traffic arrives over mTLS. Then compare the new numbers against the baseline.

Two things usually cause trouble:

  • Non-HTTP protocols. Databases, message brokers, and server-first protocols can confuse protocol detection, so declare or exclude those ports explicitly.
  • Jobs and init containers. An init container may run before the proxy is ready. A Job can also hang because the sidecar keeps running after the main container exits.

Write and rehearse the rollback before you expand: remove the injection label, restart the pods, and revert the policies.

What it costs

The control plane becomes critical infrastructure. It needs an owner, scheduled upgrades, and its own alerting. A single bad policy or an expired root certificate can break traffic across the whole cluster. Removing the mesh also gets harder once teams depend on traffic shifting or authorization policies.

Compare that against the library work the mesh replaces.

Illustrative estimate (assumed numbers, not a benchmark): Net days saved per year = (services × days per service per year on TLS, retries, and metrics) − mesh ownership days.

Assume 40 services × 2 days = 80 days, minus 0.25 FTE (about 55 days), which leaves about 25 days saved.

With 10 services, the same formula comes out negative.

#!/usr/bin/env bash
set -euo pipefail
NS=payments  # small blast radius, real traffic

kubectl top pods -n "$NS" > baseline-resources.txt

kubectl label namespace "$NS" istio-injection=enabled --overwrite
kubectl rollout restart deployment -n "$NS"

kubectl apply -f - <<EOF
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
  namespace: $NS
spec:
  mtls:
    mode: PERMISSIVE
EOF

kubectl top pods -n "$NS" > after-resources.txt

rollback() {
  kubectl delete peerauthentication default -n "$NS" --ignore-not-found
  kubectl label namespace "$NS" istio-injection-
  kubectl rollout restart deployment -n "$NS"  # pods return without sidecars
}

A staged Istio rollout for a single namespace that records a resource baseline, injects sidecars, starts mTLS in permissive mode, and keeps a scripted rollback ready.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to do i need a service mesh, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.