30.5 Service Mesh 与 Sidecar

一句话

Service Mesh 把超时、重试、熔断、mTLS、流量染色从业务进程搬到同 Pod 的 sidecar 代理,业务代码零改动就获得治理能力;代价是每跳多两次本地转发、每 Pod 多一个容器、以及一整套控制面要运维——它是“组织规模 × 语言异构”的解,不是“架构先进”的标志。

先看清“两次代理”

一次 A → B 的调用,在 Mesh 里实际走过的路径:

A 业务容器 → A 的出站 sidecar → (网络) → B 的入站 sidecar → B 业务容器
                 ①                            ②
转发段 谁做 开销
① A 出站 sidecar iptables/ebpf 把 A 的出站流量重定向到本地代理(iptables/CNI 细节见 7.8 网络模型 (CNI / NetworkPolicy)) 一次本地回环 + 一次策略检查
② B 入站 sidecar 同理,入站流量先到代理 再一次本地回环 + mTLS 解密

🔑 所以“sidecar 只加 0.2ms”这类说法要看清测的是什么:每跳是两次代理,跨机房时网络才是大头,同机时这两次回环才是大头。容量规划时按“每跳多两次进程间转发”算,不要按“多一个代理”算。

三种形态:SDK / Sidecar / Ambient

维度 SDK(治理库) Sidecar Ambient(节点级代理)
语言绑定 强(每种语言一套 SDK) 无 无
升级方式 改代码 + 重新发布 重启 Pod 或热更新 升级节点级组件
每跳额外转发 0 2 次本地回环 1 次(L4 在节点上,L7 按需)
资源占用 无额外进程 每 Pod 一份内存/CPU 每节点一份
故障域 业务进程内(SDK bug 直接崩业务) Pod 内(代理挂了影响本 Pod) 节点级(影响该节点所有 Pod)
治理覆盖面 只有接了 SDK 的语言 所有流量 所有流量
适合 单一语言、团队能统一 SDK 版本 多语言、服务数 ≥ 20 大规模、追求低开销

选型就一句话:语言种类 ≥ 3 或服务数 ≥ 20 时,Sidecar 的运维成本才开始低于“维护 N 套 SDK”;否则 SDK 更简单(尤其是只有 Go 的团队)。

代码:一个最小 sidecar,业务代码零改动

下面这段程序起两个 HTTP 服务:业务服务(只有业务逻辑)和 sidecar(只有治理逻辑)。客户端只访问 sidecar。

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"sync/atomic"
	"time"
)

// ---------- 业务服务:没有任何治理代码 ----------
func newBiz() *httptest.Server {
	var flakyCalls int32
	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/fast":
			fmt.Fprint(w, "fast-ok")
		case "/slow":
			time.Sleep(80 * time.Millisecond) // 永远慢
			fmt.Fprint(w, "slow-ok")
		case "/flaky":
			if atomic.AddInt32(&flakyCalls, 1) == 1 {
				time.Sleep(80 * time.Millisecond) // 第一次慢,之后正常
			}
			fmt.Fprint(w, "flaky-ok")
		default:
			http.NotFound(w, r)
		}
	}))
}

// ---------- sidecar:超时 + 重试 + 头注入,业务进程零改动 ----------
type sidecar struct {
	target   *url.URL
	client   *http.Client
	timeout  time.Duration
	attempts int
}

func (s *sidecar) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	var lastErr error
	for i := 1; i <= s.attempts; i++ {
		// 每次尝试都带独立的超时预算:这是 sidecar 替业务做的事
		ctx, cancel := context.WithTimeout(r.Context(), s.timeout)
		req, _ := http.NewRequestWithContext(ctx, r.Method, s.target.String()+r.URL.Path, nil)
		req.Header.Set("x-mesh-attempt", fmt.Sprint(i)) // 染色:下游能看到第几次重试

		resp, err := s.client.Do(req)
		if err == nil && resp.StatusCode < 500 {
			body, _ := io.ReadAll(resp.Body)
			resp.Body.Close()
			cancel()
			fmt.Printf("  [sidecar] 第 %d 次尝试 -> %d\n", i, resp.StatusCode)
			w.Write(body)
			return
		}
		if resp != nil {
			resp.Body.Close()
			err = fmt.Errorf("upstream %d", resp.StatusCode)
		}
		cancel()
		lastErr = err
		fmt.Printf("  [sidecar] 第 %d 次尝试 -> %v\n", i, err)
	}
	http.Error(w, "upstream unavailable: "+lastErr.Error(), http.StatusServiceUnavailable)
}

func main() {
	biz := newBiz()
	defer biz.Close()
	target, _ := url.Parse(biz.URL)

	// 治理策略全在 sidecar 上:超时 50ms、最多 2 次
	mesh := httptest.NewServer(&sidecar{
		target: target, client: &http.Client{}, timeout: 50 * time.Millisecond, attempts: 2,
	})
	defer mesh.Close()

	fmt.Println("=== 客户端只访问 sidecar,业务代码里没有一行超时/重试 ===")
	for _, p := range []string{"/fast", "/flaky", "/slow"} {
		t0 := time.Now()
		resp, err := http.Get(mesh.URL + p)
		if err != nil {
			fmt.Println(p, err)
			continue
		}
		b, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		fmt.Printf("GET %-6s -> %d %q  总耗时 %v\n\n",
			p, resp.StatusCode, strings.TrimSpace(string(b)), time.Since(t0).Round(time.Millisecond))
	}
}

本机实测(macOS,Apple M1,Go 1.27):

=== 客户端只访问 sidecar,业务代码里没有一行超时/重试 ===
  [sidecar] 第 1 次尝试 -> 200
GET /fast  -> 200 "fast-ok"  总耗时 2ms

  [sidecar] 第 1 次尝试 -> Get "http://127.0.0.1:64762/flaky": context deadline exceeded
  [sidecar] 第 2 次尝试 -> 200
GET /flaky -> 200 "flaky-ok"  总耗时 53ms

  [sidecar] 第 1 次尝试 -> Get "http://127.0.0.1:64762/slow": context deadline exceeded
  [sidecar] 第 2 次尝试 -> Get "http://127.0.0.1:64762/slow": context deadline exceeded
GET /slow  -> 503 "upstream unavailable: Get \"http://127.0.0.1:64762/slow\": context deadline exceeded"  总耗时 103ms

这段输出把 Mesh 的价值和代价都说清楚了:

现象 说明
/fast 1 次成功 正常路径只多两次本地转发,业务无感
/flaky 第 1 次超时、第 2 次成功 重试策略在 sidecar 里,业务代码不需要知道;x-mesh-attempt 让下游能识别重试并做幂等
/slow 2 次都超时 → 503 重试救不了“一直慢”的服务;这时需要的是熔断(35.6),不是更多重试
业务代码 0 行治理逻辑 换语言、换框架都不用重写治理;代价是治理行为“看不见”,必须靠 sidecar 自己的指标与日志观测

落地:Istio 的四个动作(未验证)

⚠️ 本节代码在本机跑通,但下面的集群命令未在 K8s 集群验证(本机无集群),版本号与输出以官方文档为准。指名产品:Istio(替代品 Linkerd、Cilium Service Mesh)。

# 1) 指名 + 可抄:给命名空间开启 sidecar 自动注入(Istio 1.31)
kubectl label namespace shop istio-injection=enabled --overwrite
kubectl rollout restart deployment -n shop

# 2) 可验:Pod 里应该多出 istio-proxy 容器
kubectl get pod -n shop -o jsonpath='{.items[*].spec.containers[*].name}'
# 预期输出包含:istio-proxy(每个 Pod 一个)
istioctl analyze -n shop
# 预期输出:No validation issues found(若无配置错误)

# 3) 可退:去掉注入标签并滚动重启,Pod 回到单容器
kubectl label namespace shop istio-injection-
kubectl rollout restart deployment -n shop
步骤 判据 失败时看什么
注入成功 Pod 内出现 istio-proxy 容器,且 READY 2/2(Pod 与注入的关系见 7.2 K8s 对象模型) 命名空间 label 是否拼错、是否被 Pod 级 annotation 覆盖
流量被接管 业务日志里能查到 x-request-id、mTLS 生效 sidecar 日志、iptables 规则、istioctl proxy-status
策略生效 人为让下游变慢,调用方在超时阈值内返回 VirtualService/DestinationRule 是否命中(版本、host 名)
可回退 去掉 label 后 Pod 恢复 1/1,业务照常 是否有业务代码依赖了 sidecar 注入的 header

成本:什么时候不值得上 Mesh

成本项 量级 说明
每 Pod 一个 sidecar 内存几十 MB、CPU 零点几核(量级估算) 100 个 Pod = 100 份额外资源,且随 Pod 数线性增长
每跳两次本地转发 亚毫秒到毫秒级 高频调用(如每秒上万次的内部缓存查询)会被放大成可观开销
控制面运维 一套 Istiod + 证书轮换 + 版本升级 小团队往往没有这个人力
排障复杂度 多一层代理,问题可能在 iptables / 代理 / 业务任一层 需要 istioctl 与代理日志的排障能力
情况 建议
服务 < 10 个、单一语言 别上 Mesh,SDK 足够
多语言(Go/Java/Python/Node 混用) Mesh 收益明显:治理不用写 4 遍
团队没有 K8s 深度运维能力 别上,故障时没人能定位
延迟极度敏感(内部高频 RPC) 评估“每跳两次转发”的代价,或考虑 ambient/节点级代理
合规要求全链路 mTLS Mesh 是最省事的实现方式(证书自动轮换)

反模式

反模式 后果
上了 Mesh 就以为“治理自动有了” 默认策略往往很保守(超时很长、重试很多),不配等于没治理
业务代码里还留一套重试,Mesh 再配一套 重试叠加放大(35.5),故障时流量翻几倍
在 sidecar 里做业务降级 降级返回什么属于业务知识,代理不该知道
只有 sidecar 没有控制面治理规范 200 个 Pod 各自一套 VirtualService,配置漂移比 SDK 更严重
为了“统一治理”把网关功能全搬进 Mesh 南北向入口与东西向治理混在一起,责任边界消失(47.5)
忽略 sidecar 的启动顺序与就绪探针 业务容器先于代理就绪,启动瞬间的调用全部失败

自检

Q1 一个只有 Go 服务、总共 6 个服务的团队想上 Istio,理由是“不想在每个服务里写治理代码”。用本节的判据给建议。

▸ 答案:不建议。判据是“语言种类 ≥ 3 或服务数 ≥ 20”:这里 6 个服务、单一语言,用一套 Go 治理库(或在框架里统一拦截器)成本远低于引入控制面。Mesh 的收益是“治理不用写 N 遍”,N=1 时收益接近零;成本是每 Pod 一个 sidecar、一套控制面、一层排障。等语言变多或服务数上到 20+ 再评估,届时 SDK 的版本矩阵维护成本才会超过 Mesh。

Q2 为什么“sidecar 每跳加 0.2ms”这句话容易误导容量规划?

▸ 答案:因为它只算了一次代理。真实路径是“调用方出站 sidecar + 被调方入站 sidecar”,同机时是两次本地回环,跨机时还要加上网络往返;而且代理里的策略(重试、mTLS、路由匹配)会增加每次转发的 CPU。容量规划要按“每跳两次转发 + 策略开销”算,并且要注意 sidecar 的资源是按 Pod 数增长的,不是按服务数。

Q3 Mesh 已经配了超时 1s、重试 2 次,业务代码里又有一个 3 次重试的循环。会发生什么?

▸ 答案:重试叠加:业务层 4 次尝试 × sidecar 3 次尝试 = 最坏 12 次下游调用,一次用户请求能放大 12 倍流量。故障时下游本来就已经过载,这 12 倍流量会让它彻底无法恢复(35.5)。正确做法是重试只在一个地方发生:上了 Mesh 就把业务代码里的重试删掉,或者反过来——Mesh 只做超时与熔断,重试留在业务层(因为业务层才知道哪些操作幂等)。

Q4 为什么“业务降级返回什么”不应该放进 sidecar?

▸ 答案:降级返回的是业务语义:商品详情挂了返回缓存价格还是返回“暂时无法购买”?推荐服务挂了返回空列表还是热门榜单?这些决定需要业务知识,代理只有网络层视角。sidecar 能做的是“快速失败”(返回 503 或触发熔断),业务层负责把 503 翻译成用户能理解的降级结果。把降级逻辑塞进代理,会让策略变成“谁都能改的配置”,且无法被业务测试覆盖。

关联