DevOps

Service Mesh Without Istio: Building a Service Network in Go with Envoy and Kubernetes

Ruslan Ismailov Published 12 min read
S

Introduction: When Istio Becomes a Burden

Istio is a powerful tool, but its complexity is often inversely proportional to the size of the team using it. A control plane consisting of multiple components (istiod, ingress gateway, egress gateway), resource overhead on the cluster, non-trivial debugging, and a steep learning curve make it excessive for many production systems. According to the CNCF Survey 2024, a significant portion of teams that tried Istio abandoned it within the first year — primarily due to operational complexity.

If your team works with Go microservices in Kubernetes and needs mTLS, tracing, health checks, and traffic load balancing — all of this can be achieved without Istio by manually configuring Envoy as a sidecar proxy. That is exactly what this article covers.

What Is a Service Mesh and Why Does It Matter in Go Architecture

A service mesh is an infrastructure layer that manages inter-service communication: traffic routing, load balancing, authentication, encryption, and observability. In a Go-based microservices architecture, these concerns were traditionally handled at the application level through libraries (e.g., go-kit, grpc-go with interceptors). However, this approach has drawbacks:

  • Networking logic is scattered across the code of each service
  • Updating policies requires rebuilding and redeploying services
  • Inconsistent implementations across teams
  • Difficulty enforcing mTLS uniformly

A service mesh moves this logic into a separate proxy process (sidecar) running alongside each pod. Your Go service knows nothing about encryption or circuit breaking — Envoy handles all of it transparently.

Envoy as the Data Plane: Key Capabilities

Envoy Proxy is a high-performance L4/L7 proxy written in C++. It serves as the data plane in Istio, Linkerd v2 (partially), and other mesh solutions. Key Envoy capabilities relevant to our use case:

  • Dynamic configuration via xDS API — configuration updates without restarts
  • HTTP/gRPC load balancing — round-robin, least-request, ring-hash
  • mTLS termination and origination — encryption between services
  • Distributed tracing — integration with Zipkin, Jaeger, OpenTelemetry
  • Prometheus metrics — built-in metrics exposition
  • Circuit breaking and retries — fault tolerance at the proxy level
  • Health checking — active checks of upstream services

In "without Istio" mode, we manage Envoy through static YAML configurations or a custom xDS control plane. For most teams, static configuration is more than sufficient.

Manually Configuring an Envoy Sidecar in Kubernetes

Let's walk through a step-by-step example of deploying a Go service with an Envoy sidecar in Kubernetes. Assume we have a service called order-service that communicates with payment-service.

Step 1: ConfigMap with Envoy Configuration

apiVersion: v1
kind: ConfigMap
metadata:
  name: envoy-config
  namespace: production
data:
  envoy.yaml: |
    static_resources:
      listeners:
        - name: ingress_listener
          address:
            socket_address:
              address: 0.0.0.0
              port_value: 10000
          filter_chains:
            - filters:
                - name: envoy.filters.network.http_connection_manager
                  typed_config:
                    "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                    stat_prefix: ingress_http
                    access_log:
                      - name: envoy.access_loggers.stdout
                        typed_config:
                          "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
                    http_filters:
                      - name: envoy.filters.http.router
                        typed_config:
                          "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
                    route_config:
                      name: local_route
                      virtual_hosts:
                        - name: local_service
                          domains: ["*"]
                          routes:
                            - match:
                                prefix: "/"
                              route:
                                cluster: local_app
      clusters:
        - name: local_app
          connect_timeout: 0.25s
          type: STATIC
          load_assignment:
            cluster_name: local_app
            endpoints:
              - lb_endpoints:
                  - endpoint:
                      address:
                        socket_address:
                          address: 127.0.0.1
                          port_value: 8080
        - name: payment_service
          connect_timeout: 0.5s
          type: STRICT_DNS
          lb_policy: ROUND_ROBIN
          load_assignment:
            cluster_name: payment_service
            endpoints:
              - lb_endpoints:
                  - endpoint:
                      address:
                        socket_address:
                          address: payment-service.production.svc.cluster.local
                          port_value: 10000
    admin:
      address:
        socket_address:
          address: 0.0.0.0
          port_value: 9901

Step 2: Deployment with Envoy Sidecar

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      initContainers:
        - name: iptables-init
          image: busybox:1.36
          securityContext:
            capabilities:
              add: ["NET_ADMIN"]
          command:
            - sh
            - -c
            - |
              iptables -t nat -A OUTPUT -p tcp --dport 8080 -j RETURN
              iptables -t nat -A OUTPUT -p tcp -j REDIRECT --to-port 10000
      containers:
        - name: order-service
          image: myregistry/order-service:1.4.2
          ports:
            - containerPort: 8080
          env:
            - name: PAYMENT_SERVICE_URL
              value: "http://127.0.0.1:10001"
        - name: envoy
          image: envoyproxy/envoy:v1.29-latest
          args: ["-c", "/etc/envoy/envoy.yaml", "--log-level", "info"]
          ports:
            - containerPort: 10000
              name: proxy
            - containerPort: 9901
              name: admin
          volumeMounts:
            - name: envoy-config
              mountPath: /etc/envoy
          resources:
            requests:
              memory: "64Mi"
              cpu: "50m"
            limits:
              memory: "128Mi"
              cpu: "200m"
      volumes:
        - name: envoy-config
          configMap:
            name: envoy-config

Note the initContainer with iptables: it intercepts outbound traffic and redirects it through Envoy. This is the same standard technique used by Istio.

Integration with Go Services: Health Checks, Metrics, Tracing

Health Checks

The Go service must expose a health check endpoint. A minimal example:

package main

import (
    "net/http"
    "encoding/json"
)

func healthHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/health", healthHandler)
    mux.HandleFunc("/ready", healthHandler)
    http.ListenAndServe(":8080", mux)
}

In the Envoy configuration, add an active health check for the upstream cluster:

clusters:
  - name: payment_service
    health_checks:
      - timeout: 1s
        interval: 10s
        unhealthy_threshold: 3
        healthy_threshold: 2
        http_health_check:
          path: "/health"

Metrics via Prometheus

Envoy automatically exports metrics on the admin port (default 9901) at the path /stats/prometheus. Add a ServiceMonitor for the Prometheus Operator:

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: envoy-sidecar-metrics
  namespace: production
spec:
  selector:
    matchLabels:
      app: order-service
  endpoints:
    - port: admin
      path: /stats/prometheus
      interval: 15s

Distributed Tracing with OpenTelemetry

Add tracing configuration to Envoy to send spans to Jaeger:

tracing:
  http:
    name: envoy.tracers.zipkin
    typed_config:
      "@type": type.googleapis.com/envoy.config.trace.v3.ZipkinConfig
      collector_cluster: jaeger
      collector_endpoint: "/api/v2/spans"
      shared_span_context: false
      collector_endpoint_version: HTTP_JSON

In your Go service, use go.opentelemetry.io/otel to create spans and propagate context via HTTP headers (traceparent, b3). Envoy will automatically pick up and continue the trace.

mTLS Between Services Without a Service Mesh Operator

Setting up mTLS manually is the most labor-intensive part, but it is entirely feasible. You need: a root CA, certificates for each service, and Envoy configured on both ends of the connection.

Certificate Generation (cert-manager)

Use cert-manager for automatic certificate issuance and rotation:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: order-service-cert
  namespace: production
spec:
  secretName: order-service-tls
  duration: 24h
  renewBefore: 1h
  subject:
    organizations:
      - mycompany
  commonName: order-service.production.svc.cluster.local
  dnsNames:
    - order-service.production.svc.cluster.local
  issuerRef:
    name: internal-ca
    kind: ClusterIssuer

mTLS Configuration in Envoy (Upstream — Origination)

clusters:
  - name: payment_service
    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
        common_tls_context:
          tls_certificates:
            - certificate_chain:
                filename: /etc/certs/tls.crt
              private_key:
                filename: /etc/certs/tls.key
          validation_context:
            trusted_ca:
              filename: /etc/certs/ca.crt

mTLS Configuration (Downstream — Termination)

filter_chains:
  - transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
        require_client_certificate: true
        common_tls_context:
          tls_certificates:
            - certificate_chain:
                filename: /etc/certs/tls.crt
              private_key:
                filename: /etc/certs/tls.key
          validation_context:
            trusted_ca:
              filename: /etc/certs/ca.crt

The order-service-tls secret is mounted into the pod via volumeMounts. Upon rotation, cert-manager will update the secret, and Envoy can be configured for dynamic reloading via SDS (Secret Discovery Service).

Comparison with Linkerd and Cilium

Linkerd

Linkerd is a significantly lighter service mesh than Istio. Its data plane is written in Rust (linkerd2-proxy), consuming less memory and CPU. Installation is reduced to a few CLI commands. On the downside: less flexible configuration (not as much routing control as Envoy offers) and limited protocol support (primarily HTTP/1, HTTP/2, gRPC). For teams that need a quick start without deep customization, it remains an excellent choice in 2026.

Cilium

Cilium operates at the eBPF level in the Linux kernel, resulting in minimal overhead. It combines CNI (Kubernetes network plugin) and service mesh functionality, eliminating the need for sidecar containers entirely. Cilium Service Mesh supports mTLS, L7 observability, and traffic policies. It is ideal for teams that want minimal latency and network-level control. Debugging can be more complex due to its operation within the kernel.

Manual Envoy Sidecar (Our Approach)

Maximum flexibility and control. No dependency on third-party operators. More challenging to maintain as the number of services grows — configurations need to be versioned and templated (Helm, Kustomize). Best suited for teams with 2 to 20 services or with specific routing or protocol requirements.

Monitoring and Observability Recommendations

Without a centralized control plane, observability becomes especially critical. Recommended stack:

  • Prometheus + Grafana — collect metrics from the Envoy admin endpoint (/stats/prometheus). Use the ready-made Envoy dashboards for Grafana (ID: 6693).
  • Jaeger or Grafana Tempo — collect distributed traces. Envoy generates spans automatically when tracing is configured.
  • Loki — collect Envoy access logs in structured JSON format. Configure the access log format explicitly:
access_log:
  - name: envoy.access_loggers.stdout
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
      log_format:
        json_format:
          timestamp: "%START_TIME%"
          method: "%REQ(:METHOD)%"
          path: "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%"
          response_code: "%RESPONSE_CODE%"
          duration: "%DURATION%"
          upstream_host: "%UPSTREAM_HOST%"
          trace_id: "%REQ(X-B3-TRACEID)%"
  • Alerting: configure alerts on envoy_cluster_upstream_rq_5xx, envoy_cluster_upstream_cx_connect_fail, and P99 latency via envoy_cluster_upstream_rq_time_bucket.

Summary: When to Use a Full-Featured Service Mesh

The "manual Envoy sidecar" approach is justified when:

  • You have between 3 and 20 services and a small DevOps team
  • You need full control over configuration without the "magic" of an operator
  • Istio is too resource-intensive and complex for your needs
  • You have specific routing or protocol requirements

Move to a full-featured service mesh (Istio, Linkerd, Cilium) when:

  • The number of services exceeds 30–50 and maintaining manual configurations becomes burdensome
  • You need automatic certificate rotation and centralized policy management
  • Canary deployments and mesh-level traffic shifting are required
  • The team is ready to invest time in learning and operating a control plane

Building a service mesh without Istio using Go, Envoy, and Kubernetes is a pragmatic choice for mature teams that value transparency and control over their infrastructure. The key is to not overlook configuration versioning, automation via Helm/Kustomize, and timely monitoring of proxy health.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →