Service Mesh Without Istio: Building a Service Network in Go with Envoy and Kubernetes
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 viaenvoy_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 →