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 翻译成用户能理解的降级结果。把降级逻辑塞进代理,会让策略变成“谁都能改的配置”,且无法被业务测试覆盖。