A service mesh moves cross-cutting concerns — mTLS, retries, circuit breaking, traffic routing, and distributed tracing — out of your Go application code and into the infrastructure layer. Your service writes plain HTTP or gRPC; the sidecar proxy handles the rest. This guide covers both Istio (feature-rich, complex) and Linkerd (lightweight, Go-native) with practical Go application patterns.
Why a Service Mesh
Without a mesh, every Go service implements its own:
- TLS configuration and certificate rotation
- Retry logic with jitter and exponential backoff
- Circuit breaker state machines
- Distributed trace context propagation
- Metrics collection per-service
With a mesh, a sidecar proxy (Envoy for Istio, linkerd2-proxy for Linkerd) handles all of this transparently. Your Go code stays simple:
// Without mesh: every service has this boilerplate
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: loadTLSConfig(),
},
Timeout: 5 * time.Second,
}
// Plus retry logic, circuit breaker, metric recording...
// With mesh: just make a plain HTTP call
resp, err := http.Get("http://order-service/api/orders")
// The sidecar handles mTLS, retries, circuit breaking, tracing
Writing Mesh-Ready Go Services
Your Go service should expose health endpoints the mesh can probe:
package main
import (
"encoding/json"
"net/http"
"sync/atomic"
"time"
)
var ready atomic.Bool
func main() {
mux := http.NewServeMux()
// Liveness: is the process alive? (restart if not)
mux.HandleFunc("/health/live", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "alive"})
})
// Readiness: can this instance take traffic? (remove from LB if not)
mux.HandleFunc("/health/ready", func(w http.ResponseWriter, r *http.Request) {
if !ready.Load() {
http.Error(w, `{"status":"not ready"}`, http.StatusServiceUnavailable)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ready"})
})
// Startup: has the app finished initializing? (delay liveness check)
mux.HandleFunc("/health/startup", func(w http.ResponseWriter, r *http.Request) {
if !ready.Load() {
http.Error(w, `{"status":"starting"}`, http.StatusServiceUnavailable)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "started"})
})
mux.HandleFunc("/api/orders", handleOrders)
// Mark ready after startup tasks complete
go func() {
time.Sleep(2 * time.Second) // simulate DB connection, migrations, etc.
ready.Store(true)
}()
http.ListenAndServe(":8080", mux)
}
Propagate Trace Headers
Meshes inject trace headers. Pass them through your service:
var traceHeaders = []string{
"x-request-id",
"x-b3-traceid",
"x-b3-spanid",
"x-b3-parentspanid",
"x-b3-sampled",
"x-b3-flags",
"x-ot-span-context",
"traceparent", // W3C Trace Context
"tracestate",
}
func propagateTrace(ctx context.Context, req *http.Request, incoming *http.Request) {
for _, header := range traceHeaders {
if val := incoming.Header.Get(header); val != "" {
req.Header.Set(header, val)
}
}
}
func callDownstream(ctx context.Context, incoming *http.Request, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
propagateTrace(ctx, req, incoming)
return http.DefaultClient.Do(req)
}
Istio Setup
# Install Istio CLI
curl -L https://istio.io/downloadIstio | ISTIO_VERSION=1.21.0 sh -
cd istio-1.21.0
export PATH=$PWD/bin:$PATH
# Install Istio on cluster (demo profile includes Kiali, Grafana, Jaeger)
istioctl install --set profile=demo -y
# Enable sidecar injection for your namespace
kubectl label namespace default istio-injection=enabled
# Deploy your Go app (Envoy sidecar injected automatically)
kubectl apply -f deployment.yaml
Go App Kubernetes Manifest for Istio
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
labels:
app: order-service
version: v1
spec:
replicas: 3
selector:
matchLabels:
app: order-service
version: v1
template:
metadata:
labels:
app: order-service
version: v1
# Optional: skip injection for this pod
# annotations:
# sidecar.istio.io/inject: "false"
spec:
containers:
- name: order-service
image: myregistry/order-service:1.0
ports:
- containerPort: 8080
name: http
livenessProbe:
httpGet: { path: /health/live, port: 8080 }
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet: { path: /health/ready, port: 8080 }
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests: { memory: "64Mi", cpu: "100m" }
limits: { memory: "256Mi", cpu: "500m" }
Traffic Management: Canary Deployment
Route 10% of traffic to v2 while keeping 90% on v1:
# virtual-service.yaml
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: order-service
spec:
hosts:
- order-service
http:
- match:
- headers:
x-canary:
exact: "true"
route:
- destination:
host: order-service
subset: v2
- route:
- destination:
host: order-service
subset: v1
weight: 90
- destination:
host: order-service
subset: v2
weight: 10
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: order-service
spec:
host: order-service
trafficPolicy:
connectionPool:
http:
http2MaxRequests: 1000
maxRequestsPerConnection: 100
outlierDetection:
consecutiveGatewayErrors: 5
interval: 30s
baseEjectionTime: 30s
maxEjectionPercent: 50
subsets:
- name: v1
labels:
version: v1
- name: v2
labels:
version: v2
Circuit Breaking
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: payment-service
spec:
host: payment-service
trafficPolicy:
connectionPool:
tcp:
maxConnections: 100
http:
http1MaxPendingRequests: 50
http2MaxRequests: 100
maxRetries: 3
outlierDetection:
consecutive5xxErrors: 5 # eject after 5 consecutive errors
interval: 30s # check interval
baseEjectionTime: 30s # minimum ejection duration
maxEjectionPercent: 100 # allow ejecting all hosts if needed
Retries and Timeouts
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: payment-service
spec:
hosts:
- payment-service
http:
- timeout: 5s
retries:
attempts: 3
perTryTimeout: 2s
retryOn: "gateway-error,connect-failure,retriable-4xx"
route:
- destination:
host: payment-service
port:
number: 8080
mTLS Enforcement
# Require mTLS for all services in the namespace
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: default
spec:
mtls:
mode: STRICT
---
# Authorization policy: only frontend can call order-service GET /api/*
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: order-service-policy
namespace: default
spec:
selector:
matchLabels:
app: order-service
rules:
- from:
- source:
principals: ["cluster.local/ns/default/sa/frontend"]
- to:
- operation:
methods: ["GET"]
paths: ["/api/*"]
Linkerd: Lighter Weight Alternative
Linkerd uses a Rust-based micro-proxy (linkerd2-proxy) instead of Envoy, resulting in much lower resource overhead. It’s simpler to configure and operates well at small-to-medium scale.
# Install Linkerd CLI
curl https://run.linkerd.io/install | sh
export PATH=$PATH:$HOME/.linkerd2/bin
# Pre-check cluster
linkerd check --pre
# Install Linkerd
linkerd install --crds | kubectl apply -f -
linkerd install | kubectl apply -f -
# Verify installation
linkerd check
# Inject sidecar into namespace
kubectl annotate namespace default linkerd.io/inject=enabled
# Or per-deployment
kubectl annotate deployment order-service linkerd.io/inject=enabled
kubectl rollout restart deployment/order-service
Linkerd Traffic Policy (Service Profile)
apiVersion: linkerd.io/v1alpha2
kind: ServiceProfile
metadata:
name: order-service.default.svc.cluster.local
namespace: default
spec:
routes:
- name: GET /api/orders
condition:
method: GET
pathRegex: /api/orders(/.*)?
responseClasses:
- condition:
status:
min: 500
max: 599
isFailure: true
timeout: 5s
retryBudget:
retryRatio: 0.2 # retry at most 20% of requests
minRetriesPerSecond: 10
ttl: 10s
- name: POST /api/orders
condition:
method: POST
pathRegex: /api/orders
timeout: 10s
# No retries for POST — not idempotent
Linkerd Traffic Split (Canary)
apiVersion: split.smi-spec.io/v1alpha1
kind: TrafficSplit
metadata:
name: order-service
spec:
service: order-service
backends:
- service: order-service-v1
weight: 900m # 90%
- service: order-service-v2
weight: 100m # 10%
Istio vs Linkerd: Choosing
| Istio | Linkerd | |
|---|---|---|
| Proxy | Envoy (C++) | linkerd2-proxy (Rust) |
| Resource overhead | ~100MB+ per proxy | ~10-20MB per proxy |
| Feature set | Comprehensive | Focused (mTLS, retries, observability) |
| Configuration | Complex YAML | Simpler profiles |
| mTLS | Manual setup | Automatic |
| Learning curve | Steep | Moderate |
| Best for | Large enterprise, advanced traffic mgmt | Kubernetes-native, resource-constrained |
Choose Istio when you need advanced traffic management (canary routing by header, fault injection, Wasm extensions) or have multi-cluster setups.
Choose Linkerd when you want automatic mTLS with minimal overhead, simpler operations, and you don’t need Istio’s advanced routing features.
Observability Without Code Changes
Both meshes inject metrics and traces automatically. Access them:
# Istio — open Kiali dashboard
istioctl dashboard kiali
# Istio — Grafana dashboards
istioctl dashboard grafana
# Linkerd — built-in dashboard
linkerd viz dashboard
# Linkerd — tap live traffic
linkerd tap deploy/order-service
# Linkerd — route-level stats
linkerd viz stat deploy/order-service
linkerd viz routes deploy/order-service
Summary
- Write mesh-ready Go services: expose
/health/live,/health/ready, propagate trace headers - Use Istio for advanced traffic management (weighted routing, header-based routing, fault injection)
- Use Linkerd for simple mTLS + observability with minimal overhead
- Both handle circuit breaking, retries, and mTLS at the infrastructure layer — keep your Go code simple
- Service profiles (Linkerd) and VirtualServices (Istio) are the primary configuration surface
Comments