概述
把 120 多个微服务从传统虚拟机部署迁移到 Kubernetes,听起来是个标准项目:写 Dockerfile、建集群、迁移服务、切流量。规划会议上人人都觉得三个月够了。实际干起来你会发现,真正的难点不在"怎么把服务跑起来"——那只是 helm install 的事——而在于那些迁移过程中没人提前告诉你的坑:服务依赖的循环引用、有状态服务的内存态丢失、CNI 插件在大规模下的性能拐点、灰度切流时的 DNS 缓存陷阱、配置管理的环境变量膨胀、监控指标在迁移后的断层。
这篇文章是我主导某新能源物流平台 120+ 微服务 K8s 全量迁移的复盘。3 个月,零故障(这里的"零故障"指迁移过程未引发 P0/P1 级业务中断,不是零报错),扩缩容从 25 分钟降到 1 分钟,发布效率提升 50%。但过程远没有结果看起来那么顺利——光服务依赖梳理就花了三周,CNI 性能问题排查折腾了一周,灰度切流时 DNS 缓存导致的间歇性 502 让团队熬了两个通宵。
我写这篇文章的目的不是教你 K8s 基础概念,而是把迁移过程中真正卡住我们的 6 个坑拆开讲清楚,每个坑都附上架构决策过程和替代方案对比。如果你正在做或准备做大规模微服务 K8s 迁移,这些经验能帮你少走弯路。
坑一:服务依赖梳理——你以为的线性依赖其实是张循环图
问题:依赖检测做了一半就发现了死循环
迁移第一步是梳理服务依赖关系,确定迁移顺序。我们的标准做法很简单:找出没有下游依赖的服务先迁,然后逐层往上推。用 grep 扫了一遍配置文件里的数据库连接和 API 调用地址,画出了依赖拓扑图。
第一周很顺利。第二周开始出问题了——配置文件里写的依赖只是冰山一角。运行时的动态依赖、通过服务发现(Eureka/Nacos)路由的调用、消息队列触发的异步链路,这些都不在静态配置里。等我们把所有依赖补完,发现依赖图里有三个循环引用:A 调 B,B 调 C,C 又调 A。
这不算什么新鲜事。微服务架构演进两三年后,循环依赖几乎是必然产物。但迁移时它是个致命问题:你不能分批迁移了。A、B、C 必须同时迁,否则跨平台调用(K8s 内服务调 K8s 外服务)的网络延迟和 DNS 解析会成为性能瓶颈。
解法:运行时调用链分析 + 分批策略调整
光看配置文件不够。我们最终用了两套工具配合:
第一套:Jaeger 分布式链路追踪
在迁移前一个月,给所有服务接入了 OpenTelemetry SDK,通过 Jaeger 采集全量调用链路。跑了一周,拿到了真实运行时的服务调用关系图。静态配置里没出现的依赖关系,在链路数据里暴露无遗——光是消息队列触发的异步调用链就有 14 条,其中 3 条是循环依赖。
第二套:自定义 Go 脚本做依赖拓扑分析
Jaeger 的调用链是请求粒度的,需要聚合才能看到服务级拓扑。写了个 Go 脚本拉取 Jaeger 的 API,按服务维度聚合调用关系,输出一个依赖矩阵和拓扑排序结果:
// 依赖拓扑分析核心逻辑
type ServiceGraph struct {
Nodes map[string]*ServiceNode // 服务名 -> 节点
Edges map[string][]string // 服务名 -> 下游依赖列表
}
// 检测循环依赖(基于 Tarjan 算法的强连通分量检测)
func (g *ServiceGraph) FindCycles() [][]string {
index := 0
stack := []string{}
onStack := make(map[string]bool)
sccs := [][]string{}
indices := make(map[string]int)
lowlinks := make(map[string]int)
var strongconnect func(v string)
strongconnect = func(v string) {
indices[v] = index
lowlinks[v] = index
index++
stack = append(stack, v)
onStack[v] = true
for _, w := range g.Edges[v] {
if _, ok := indices[w]; !ok {
strongconnect(w)
lowlinks[v] = min(lowlinks[v], lowlinks[w])
} else if onStack[w] {
lowlinks[v] = min(lowlinks[v], indices[w])
}
}
if lowlinks[v] == indices[v] {
scc := []string{}
for {
w := stack[len(stack)-1]
stack = stack[:len(stack)-1]
onStack[w] = false
scc = append(scc, w)
if w == v {
break
}
}
if len(scc) > 1 {
sccs = append(sccs, scc)
}
}
}
for v := range g.Nodes {
if _, ok := indices[v]; !ok {
strongconnect(v)
}
}
return sccs
}
这段代码的核心思路:用 Tarjan 算法找强连通分量(SCC),一个 SCC 如果包含多个服务,说明这些服务之间存在循环依赖,必须作为一个整体迁移。
分析结果出来后,120 个服务被重新分组:
| 分组类型 | 服务数量 | 迁移策略 |
|---|---|---|
| 无下游依赖(叶子节点) | 34 个 | 第一批迁移,独立部署无风险 |
| 线性依赖链 | 58 个 | 按拓扑序分 4 批迁移 |
| 循环依赖组(SCC) | 23 个(5 组) | 每组整体迁移,不可拆分 |
| 有状态服务 | 5 个 | 单独制定迁移方案(见坑二) |
架构权衡:为什么不一次性全迁
有人会说:既然有循环依赖,干脆全部一起迁不就完了?技术上可行,但我不推荐。原因有三个:
第一,回滚成本。一次性迁移 120 个服务,如果出问题,回滚意味着全部回退。灰度迁移的好处是出了问题只回退受影响的那几个服务,业务影响面可控。
第二,验证窗口。先迁的服务在新平台上跑稳了,才能作为后迁服务的依赖目标。如果一起迁,所有服务都在一个不稳定的新环境里互相调用,问题排查难度指数级上升。我们在实际迁移中,第一批 34 个服务迁完后跑了 5 天稳定期,确认监控指标、日志采集、告警规则都正常后才开始第二批。
第三,团队精力。迁移期间需要大量人工确认和流量验证。一次性迁 120 个,团队会被淹没在确认工作中,关键的异常信号反而容易被忽略。
我推荐的做法:循环依赖组整体迁、线性依赖链分批迁、有状态服务单独迁。用拓扑排序确定迁移批次,每个 SCC 作为一个不可分割的迁移单元。
内链提示:服务依赖梳理和微服务治理密切相关,如果你还没做过限流熔断降级,建议在迁移前先做。参考 相关文章:微服务限流熔断降级策略,迁移过程中一旦出现性能问题,限流熔断是最后一道防线。
坑二:有状态服务的内存态丢失——不只是数据库的问题
问题:Redis Cluster 迁过去之后缓存命中率暴跌
提到有状态服务迁移,大多数人的第一反应是数据库。数据库迁移确实重要,但数据库迁移的方案已经很成熟——主从同步、双写、校验、切流。真正坑我们的是 Redis。
不是 Redis 的数据丢了,而是 Redis Cluster 的拓扑信息丢了。我们在虚拟机时代用的是自建 Redis 主从(不是 Cluster 模式),业务代码直连 Sentinel 做故障切换。迁移到 K8s 后决定顺便升级为 Redis Cluster,用 StatefulSet 部署。数据用 RDB 文件恢复,看起来没问题。
上线第一天,缓存命中率从 92% 暴跌到 31%。原因很简单:Redis Cluster 的客户端需要维护槽位映射表(slot map),直连 Sentinel 的老客户端不认识 Cluster 协议。我们改了客户端配置,但漏了一个边缘服务——它用的是自己的 Redis 客户端库,不支持 Cluster 协议,所有请求都路由到了第一个节点。
更深层的问题:进程内缓存
Redis 的问题好查,日志里全是 MOVED 异常。真正难发现的是进程内缓存。
有几个 Java 服务用了 Caffeine 做本地缓存,缓存了用户权限数据和路由策略。这些缓存在进程重启后自动重建,看起来没有迁移问题。但实际切流后,这三个服务的 P99 延迟从 50ms 飙到 800ms。
查了半天才发现:缓存重建的逻辑依赖另一个服务的启动顺序。在虚拟机时代,部署脚本保证了配置服务先启动、缓存服务后启动,所以缓存能正确加载。但 K8s 的 Pod 调度不保证启动顺序(即使你用了 init container),网络就绪时间也有差异。配置服务的 Pod 先启动了,但还没就绪(readiness probe 没通过),缓存服务就开始拉配置,拿到的全是空值,直接写进了缓存。
解法:分层处理有状态服务
有状态服务迁移要分三层处理,每层的策略完全不同:
第一层:持久化数据(数据库、消息队列)
| 存储类型 | 迁移策略 | 关键注意点 |
|---|---|---|
| MySQL/PostgreSQL | 主从同步 + 双写校验 + 原子切流 | 字符集和时区一致性,K8s 环境默认 UTC |
| Redis(单机/主从) | RDB/AOF 文件恢复 + 双写过渡 | 客户端协议兼容性,Cluster vs Sentinel |
| Kafka | MirrorMaker2 同步 + 消费组迁移 | offset 迁移,消费顺序保证 |
| MongoDB | oplog 同步 + 切流 | 索引重建,连接池配置 |
第二层:内存态(进程内缓存、会话状态)
进程内缓存是迁移中最容易被忽略的。我的建议是:迁移前做一次缓存依赖审计。对每个服务检查三个问题:
- 有没有进程内缓存?(搜 Caffeine、Guava Cache、Ehcache、go-cache 等关键字)
- 缓存数据来源是什么?(配置服务、数据库、其他 API?)
- 缓存重建依赖启动顺序吗?
如果第三个答案是"是",你必须在迁移方案中处理启动顺序问题。K8s 里不靠部署脚本控制启动顺序,正确做法是:在应用层加重试和降级逻辑。拿不到配置就先用默认值启动,后台异步重建缓存。
# K8s 启动顺序控制的"正确"姿势不是靠 initContainer
# 而是在应用层做容错
# 这里给出 readinessProbe 的配置策略:
apiVersion: apps/v1
kind: Deployment
metadata:
name: cache-service
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
spec:
containers:
- name: app
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
# readinessProbe 只在缓存加载完成后返回 200
# 之前的请求返回 503,不会被 Service 转发流量
# 这样即使 Pod 启动了,也不会接收流量直到缓存就绪
关键点:readinessProbe 不只是"进程活着",而是"服务准备好了"。我们的 readiness 端点会检查缓存是否加载完成,没完成就返回 503,K8s 的 Service 不会把流量路由到这个 Pod。这比依赖启动顺序靠谱得多。
第三层:文件系统状态(本地文件、临时文件)
有些服务在本地磁盘写了临时文件、锁文件、PID 文件。迁到 K8s 后容器文件系统是临时的,Pod 重建后文件就没了。这种服务要改造成使用 PVC(PersistentVolumeClaim)或者干脆把文件存到对象存储。
我们的做法是写了一个 Go 脚本批量扫描所有服务的部署脚本,找出写本地文件的位置:
// 扫描部署脚本中的本地文件写入路径
func FindLocalFileWrites(scriptsDir string) []string {
var results []string
// 常见的文件写入模式
patterns := []string{
`/tmp/`, `/var/log/`, `/data/`, `/opt/`,
`nohup.*\.log`, `pid`, `\.lock`, `\.pid`,
}
files, _ := filepath.Glob(filepath.Join(scriptsDir, "**/*.sh"))
for _, f := range files {
content, _ := os.ReadFile(f)
for _, p := range patterns {
re := regexp.MustCompile(p)
if re.Match(content) {
results = append(results, fmt.Sprintf("%s: matched %s", f, p))
}
}
}
return results
}
扫完发现有 17 个服务在写本地文件。其中 12 个是日志文件(改用 stdout + 日志采集方案解决,参考 相关文章:K8s 日志采集方案选型),3 个是锁文件(改用 Redis 分布式锁),2 个是上传的临时文件(改用 PVC)。
坑三:CNI 性能拐点——大规模集群下的隐藏瓶颈
问题:节点超过 80 个之后网络延迟出现周期性尖刺
集群规模在 30 个节点时一切正常。迁到第二批服务后节点扩到 80 个,网络延迟开始出现周期性尖刺。不是所有请求都慢,是每隔几分钟出现一批 200ms+ 的慢请求,平时都是 2-5ms。
监控上看,慢请求集中在跨节点通信。同节点的 Pod 间通信完全正常。这个现象很典型——CNI 插件在节点数达到一定规模后,路由表规模和 iptables 规则数量暴涨,导致内核网络栈处理延迟陡增。
我们用的是 Calico(BGP 模式)。80 个节点时每个节点的路由表有 80 条 BGP 路由,加上 NetworkPolicy 产生的 iptables 规则,单节点 iptables 规则数超过 2000 条。这在 30 节点时不是问题,到 80 节点后,iptables 的线性匹配性能开始成为瓶颈。
诊断过程
排查花了三天。先排除了应用层问题(延迟分布不是应用逻辑导致的),再用 tcpdump 抓包确认网络层延迟,最后用 perf top 看到内核态 ipt_do_table 函数占了 15% 的 CPU 时间——这就是 iptables 规则匹配的开销。
# 诊断 CNI 性能问题的三步走
# 第一步:确认网络延迟在内核态
perf top -g
# 如果 ipt_do_table 或 nf_hook_slow 占比 > 5%,说明 iptables 是瓶颈
# 第二步:统计 iptables 规则数量
iptables -L -n --line-numbers | wc -l
# 80 节点 Calico 集群,单节点规则数约 2000-3000 条
# 第三步:测量跨节点 Pod 通信延迟
# 在 Pod 内执行
kubectl exec -it <pod-name> -- bash -c '
for i in $(seq 1 100); do
ping -c 1 <target-pod-ip> | grep "time=" | awk -F"time=" "{print \$2}"
done
'
# 如果延迟方差大(min 1ms, max 200ms),说明 CNI 在做规则匹配时存在性能抖动
架构决策:Calico vs Cilium vs Flannel
这个问题有两条路:换 CNI 或者优化现有 CNI。我们做了详细的方案对比:
| 对比维度 | Calico (BGP) | Cilium (eBPF) | Flannel (VXLAN) |
|---|---|---|---|
| 网络模式 | Underlay (BGP) | Overlay/Underlay | Overlay (VXLAN) |
| 性能 (100 节点) | iptables 瓶颈,P99 抖动 | eBPF 快速路径,P99 稳定 | 性能尚可,但无网络策略 |
| NetworkPolicy | 完整支持 (iptables) | 完整支持 (eBPF) | 不支持 |
| 可观测性 | 基础 | Hubble 可观测性极佳 | 几乎没有 |
| 迁移成本 | - | 中等(需要换插件、重建网络) | 低(但功能弱) |
| 生产成熟度 | 高 | 高(1.16+ GA) | 高(但功能简单) |
| 大规模表现 | 100+ 节点后 iptables 成瓶颈 | 500+ 节点仍稳定 | 200+ 节点性能尚可 |
我推荐的选择:
- 50 节点以下:Calico 够用,BGP 模式性能好,NetworkPolicy 功能完整
- 50-200 节点:Cilium,eBPF 绕过了 iptables,性能在大规模下优势明显
- 不需要 NetworkPolicy 的场景:Flannel 最简单,但生产环境我不推荐裸用 Flannel
我们最终选了 Cilium。换 CNI 的操作风险不小,但换之前先在测试集群完整验证了两周,包括故障注入、性能压测、网络策略测试。迁移过程用了一个周末的维护窗口,步骤是:先给所有节点打好 Cilium 的 CNI 配置 → 逐个节点 drain + 重新调度 Pod → 验证跨节点通信 → 全量确认。
换完之后的效果很明显:
换 Cilium 前(80 节点 Calico):
跨节点 Pod 通信延迟 P50: 3ms P99: 180ms Max: 320ms
iptables 规则数: 2347 条
内核态网络处理 CPU: 15%
换 Cilium 后(80 节点 Cilium eBPF):
跨节点 Pod 通信延迟 P50: 1.5ms P99: 8ms Max: 15ms
eBPF 程序数: 47 个
内核态网络处理 CPU: 3%
P99 延迟从 180ms 降到 8ms,这个差异在业务上是质变。之前用户偶尔感知到的"卡顿"消失了。
补充:Cilium 的额外收益
换 Cilium 还带来一个意外收获:Hubble 可观测性。Hubble 基于 eBPF 实现了无需 Sidecar 的网络流量监控,能直接看到 Pod-to-Pod 的调用关系和延迟分布。这个能力在迁移后期排查网络策略问题时帮了大忙。
如果你在迁移中打算换 CNI,我的建议是:在迁移开始前就选好目标 CNI,不要先迁完再换。中途换 CNI 等于让所有 Pod 重新分配 IP,是一次额外的全量故障风险。
坑四:灰度切流时的 DNS 缓存陷阱
问题:切了 10% 流量,间歇性出现 502
迁移到第四批时,我们开始做灰度切流。方案很标准:在 Ingress 层按权重切流,10% 流量到 K8s 集群,90% 留在虚拟机。切完观察 30 分钟没问题,加到 30%。然后 502 开始零星出现。
不是持续 502,是每隔几分钟出现一小批,持续几秒,然后恢复。Ingress 日志显示是 upstream 连接超时。但 K8s 内的服务完全正常,Pod 都在 Running 状态,readiness probe 都通过了。
排查了两小时,最终定位到是 JVM 的 DNS 缓存问题。Java 默认缓存 DNS 解析结果 30 秒(由 networkaddress.cache.ttl 控制,JNDI 层面默认 10 秒,但很多应用框架覆盖了这个值)。当 Ingress 把请求路由到 K8s 集群时,Java 应用缓存的是虚拟机时代旧服务的 IP 地址。K8s 的 Service ClusterIP 和旧服务的虚拟机 IP 不一样,请求被发到了已经下线的旧 IP 上。
这个问题之所以隐蔽,是因为它只在流量切换窗口期出现。切流稳定后旧 IP 下线,DNS 缓存自动失效,问题消失。但切流过程中它会持续制造间歇性故障。
解法:三层 DNS 缓存治理
第一层:应用层 DNS TTL 调整
// Java 应用的 DNS 缓存配置(在 JVM 启动参数中加)
-Dnetworkaddress.cache.ttl=1
// 或者在代码中设置
java.security.Security.setProperty("networkaddress.cache.ttl", "1");
把 DNS 缓存时间降到 1 秒。这会让每次请求都做 DNS 解析,增加极小的延迟(通常 < 1ms),但在灰度切流时能保证拿到最新的 DNS 记录。
第二层:CoreDNS 配置优化
K8s 集群内的 DNS 解析由 CoreDNS 负责。默认配置在缓存未命中时会逐级向上解析,延迟较高。我们优化了 CoreDNS 的缓存配置:
# CoreDNS ConfigMap 优化
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns
namespace: kube-system
data:
Corefile: |
.:53 {
errors
health {
lameduck 5s
}
ready
# 缓存配置:缓存 1000 条记录,有效期 30 秒
cache {
1000
30
}
# 负缓存:NXDOMAIN 也缓存 5 秒,防止 DNS 风暴
cache {
denyleft 5
}
prometheus :9153
forward . /etc/resolv.conf {
max_concurrent 1000
}
loop
reload
loadbalance
}
关键点两个:正缓存 30 秒避免每次都向上游查询;负缓存 5 秒防止 NXDOMAIN 响应打满 CoreDNS。
第三层:过渡期双 IP 策略
灰度切流期间,DNS 记录同时返回新旧两个 IP。客户端的负载均衡器会同时持有两套 IP,根据 Ingress 的权重分配流量。这需要在 DNS 层面配合——把 K8s Service 的 ExternalIP 也注册到 DNS 中,让客户端能同时解析到新旧两套地址。
# 灰度切流期间的 DNS 记录示例
# 传统部署的 LB IP: 10.0.1.100
# K8s Ingress 的 LB IP: 10.0.2.200
# DNS 返回两条 A 记录,客户端负载均衡器按权重分配
api.example.com. 30 IN A 10.0.1.100 # 旧环境(权重 90%)
api.example.com. 30 IN A 10.0.2.200 # K8s 集群(权重 10%)
这种做法要求客户端支持 DNS 轮询且有连接级别的负载均衡。如果客户端只取 DNS 返回的第一个 IP(很多旧版 HTTP 客户端会这样做),需要在 DNS 层做权重轮转——每次查询返回不同顺序的 IP 列表。
实测建议:如果你用的是 CoreDNS,可以在 Corefile 里加
loadbalance插件,它会轮转 DNS 响应中的 IP 顺序,配合客户端的 DNS 轮询可以实现简单的加权切流。
坑五:配置管理的环境变量膨胀
问题:一个服务的 ConfigMap 膨胀到 300 行
在虚拟机时代,配置管理用的是 Spring Cloud Config(Java 服务)和 Consul KV(Go 服务)。迁到 K8s 后,团队决定用 ConfigMap + Secret 统一管理配置。
迁移过程中,一个 Java 服务的 ConfigMap 从最初的 40 行膨胀到了 300 行。原因很简单:原来配置文件里的占位符(${DB_HOST}、${REDIS_PORT})在虚拟机环境由部署脚本注入,现在要全部写进 ConfigMap。加上不同环境(dev/staging/prod)的配置差异,同一个服务需要维护 3 个 ConfigMap,每个 300 行。
300 行的 ConfigMap 有什么问题?变更风险高——改一行配置要 review 整个文件,容易误改其他配置。版本管理困难——Git diff 看不出具体改了哪个配置项。部署变慢——K8s 更新 ConfigMap 后需要重启所有使用它的 Pod。
解法:分层配置 + 运行时注入
分层策略:把配置分成三层,分别管理。
| 配置层 | 内容 | 存储位置 | 变更频率 |
|---|---|---|---|
| 环境层 | 环境无关的静态配置(日志格式、线程池大小) | ConfigMap(公共) | 极低 |
| 环境差异层 | 不同环境不同的配置(数据库地址、Redis 地址) | ConfigMap(按环境) | 低 |
| 敏感配置层 | 密码、Token、证书 | Secret | 中 |
运行时注入:不在 ConfigMap 里写死所有配置,而是用 envFrom + configMapKeyRef 按需注入:
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
template:
spec:
containers:
- name: app
envFrom:
# 第一层:公共配置,所有环境共享
- configMapRef:
name: common-config
# 第二层:环境差异配置
- configMapRef:
name: order-service-prod
# 第三层:敏感配置
- secretRef:
name: order-service-secrets
env:
# 第四层:Pod 级动态配置,通过 Downward API 注入
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
# 第五层:运行时配置中心
# 应用启动时从配置中心拉取动态配置
# 配置变更时通过长连接推送,不重启 Pod
- name: CONFIG_CENTER_URL
value: "http://nacos.infra:8848"
这样做的好处是:公共配置只维护一份,环境差异配置只包含该环境独有的部分,ConfigMap 从 300 行压缩到 60-80 行。
配置热更新:有些配置不需要重启 Pod 就能生效(比如日志级别、限流阈值)。这类配置不要放 ConfigMap——ConfigMap 更新需要重建 Pod 才能生效。用配置中心(Nacos/Apollo)管理,应用通过 Watch 机制接收变更:
// Go 服务的配置热更新示例
func WatchConfig(nacosClient *nacos.Client, dataId string) {
go func() {
for {
config, err := nacosClient.GetConfig(dataId)
if err != nil {
log.Printf("获取配置失败: %v", err)
time.Sleep(5 * time.Second)
continue
}
// 原子更新运行时配置
if err := UpdateRuntimeConfig(config); err != nil {
log.Printf("配置更新失败: %v", err)
}
// 阻塞等待下次变更通知
nacosClient.ListenConfig(dataId)
}
}()
}
补充:K8s ConfigMap 更新的陷阱
K8s 更新 ConfigMap 后,使用它的 Pod 不会自动感知变更。你有两个选择:
- 重启 Pod:
kubectl rollout restart deployment/<name>,简单粗暴,但会中断长连接 - Sidecar 容器监听变更:在 Pod 里加一个 sidecar,监听 ConfigMap 的变更,通过信号通知主容器重新加载配置
我推荐第二种,但只在配置变更频繁的场景用。大部分服务的配置变更频率很低(一周一次),直接 restart 就行,不用引入 sidecar 的复杂度。
坑六:监控指标的迁移断层
问题:迁移后告警规则全部失效,但没人发现
这是一个事后才发现的坑。迁移前所有服务的监控都在 Prometheus + Grafana 上,告警规则配了 200 多条。迁移到 K8s 后,服务实例的 IP 变了(从虚拟机 IP 变成 Pod IP),基于 IP 的告警规则全部失效——Prometheus 采集到的是新 IP 的数据,但告警规则里写的还是旧 IP 的匹配条件。
更隐蔽的是标签断层。K8s 的 Prometheus 采集自动打上的标签是 namespace、pod、container,而虚拟机时代的标签是 instance、host、service。很多 PromQL 查询和 Grafana 面板里的标签过滤条件都是基于旧标签写的,迁移后查询结果为空,面板一片空白。
但没人发现。因为迁移期间大家都在盯流量和延迟,没人看监控面板。等迁移完一周后回过头来看,发现好几个服务的 CPU 和内存监控断了三天,告警也没触发。
解法:标签映射 + 告警规则迁移
第一步:标签映射
在 Prometheus 的 relabel 配置中加映射规则,把 K8s 的标签映射到旧标签体系:
# Prometheus scrape config with relabel
scrape_configs:
- job_name: 'k8s-pods'
kubernetes_sd_configs:
- role: pod
relabel_configs:
# 映射 namespace -> service 标签(按 namespace 分类)
- source_labels: [__meta_kubernetes_namespace]
target_label: service
regex: '(.+)'
replacement: '${1}'
# 映射 pod name -> instance 标签
- source_labels: [__meta_kubernetes_pod_name]
target_label: instance
# 映射 node name -> host 标签
- source_labels: [__meta_kubernetes_pod_node_name]
target_label: host
# 从 pod annotation 中提取服务名
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_service_name]
target_label: service
# 保留容器名
- source_labels: [__meta_kubernetes_pod_container_name]
target_label: container
这样做的好处是:旧的 PromQL 查询和 Grafana 面板不用改,新数据自动匹配到旧标签上。迁移期间可以同时看到新旧环境的数据,做对比验证。
第二步:告警规则迁移
告警规则不能简单搬过来。K8s 环境的指标采集方式变了,有些规则需要调整。比如 CPU 使用率,虚拟机时代直接从 node_exporter 采集,K8s 时代从 cAdvisor 或 kube-state-metrics 采集,指标名称和标签都不同。
我做了一个告警规则迁移对照表:
| 旧指标(虚拟机) | 新指标(K8s) | PromQL 变更 |
|---|---|---|
node_cpu_seconds_total | container_cpu_usage_seconds_total | 需加 namespace 和 container 过滤 |
node_memory_MemAvailable_bytes | container_memory_working_set_bytes | 含义不同,working_set 更接近实际使用 |
node_filesystem_avail_bytes | kubelet_volume_stats_available_bytes | 维度不同,K8s 按 PVC 统计 |
up{instance="10.0.1.x"} | kube_pod_status_phase | 从实例级改为 Pod 级 |
第三步:双环境并行监控
迁移期间新旧环境并行监控,通过标签区分。写了一个校验脚本,对比新旧环境的指标是否一致:
#!/bin/bash
# 迁移期间监控指标一致性校验脚本
# 对比同一服务在新旧环境的指标差异
OLD_ENV="instance=~\"10\\.0\\.1\\.\\d+\""
NEW_ENV="namespace=\"production\""
METRICS=(
"http_request_duration_seconds_p99"
"http_requests_total"
"process_cpu_seconds_total"
"process_resident_memory_bytes"
)
for metric in "${METRICS[@]}"; do
echo "=== ${metric} ==="
echo "旧环境值: $(curl -s "http://prometheus:9090/api/v1/query?query=${metric}{${OLD_ENV}}" | jq '.data.result[0].value[1]')"
echo "新环境值: $(curl -s "http://prometheus:9090/api/v1/query?query=${metric}{${NEW_ENV}}" | jq '.data.result[0].value[1]')"
echo ""
done
内链提示:监控体系迁移和告警策略设计是强关联的。如果你在做告警规则迁移,建议同步参考 相关文章:告警策略设计——从噪声到信号,趁迁移的机会重新梳理一遍告警规则,把垃圾告警清理掉。
坑七:容量规划——你以为算对了,实际上漏了 40%
问题:迁完三天后集群资源不够用了
迁移前做了容量规划:把虚拟机时代的 CPU 和内存申请量直接映射到 K8s 的 requests 和 limits。120 个服务,总 CPU 需求 200 核,内存 480GB。集群规划了 8 个节点,每节点 32 核 64GB,总容量 256 核 512GB,看起来有 20% 余量。
迁完第三天,集群资源使用率到了 85%。HPA 开始频繁扩容,节点池被打满。为什么?
因为我们漏算了三个东西:
第一,K8s 自身的资源开销。kubelet、kube-proxy、CNI 插件、CoreDNS、日志采集 Agent,这些系统组件在每个节点上吃掉 3-5 核 CPU 和 2-4GB 内存。8 个节点就是 24-40 核、16-32GB 的系统开销,直接吃掉了"余量"。
第二,requests 和 limits 的差异导致碎片化。我们给每个服务设了 requests=2核 limits=4核。K8s 调度器按 requests 分配,但运行时容器可能用到 limits 的量。一个节点 requests 算满了(比如 32 核全分配),但实际运行时 CPU 使用率只有 50%。看起来节点 CPU 有余量,但新 Pod 调度不上去——因为调度器看的是 requests 总和,不是实际使用量。
第三,Pod 密度太高导致 I/O 竞争。我们一个节点塞了 20-25 个 Pod,CPU 和内存够用,但磁盘 I/O 成了瓶颈。十几个 Java 服务同时做 GC 日志写入和 JVM 堆 dump,磁盘 IOPS 被打满,导致应用写日志变慢,连锁影响请求处理延迟。
解法:三层容量模型
做了重新规划,引入三层容量模型:
第一层:节点级容量规划
# 计算节点级实际可用资源
# K8s 系统预留 + kube 预留 + 驱逐阈值
NODE_CPU=32
NODE_MEM=64 # GB
# 系统预留(kubelet 自身)
SYS_RESERVE_CPU=2
SYS_RESERVE_MEM=2 # GB
# kube 预留(操作系统守护进程)
KUBE_RESERVE_CPU=1
KUBE_RESERVE_MEM=1 # GB
# 驱逐阈值(硬盘压力时预留)
EVICT_MEM=0.5 # GB
# 实际可分配给 Pod 的资源
AVAILABLE_CPU=$((NODE_CPU - SYS_RESERVE_CPU - KUBE_RESERVE_CPU))
AVAILABLE_MEM=$((NODE_MEM - SYS_RESERVE_MEM - KUBE_RESERVE_MEM - EVICT_MEM))
echo "节点可分配: ${AVAILABLE_CPU}核 ${AVAILABLE_MEM}GB"
# 输出: 节点可分配: 29核 60.5GB
第二层:Pod 密度控制
不是所有服务都该跑在一起。高 I/O 服务(数据库、日志聚合)和 CPU 密集型服务(API 网关、计算服务)要分开。我们用节点污点(taint)和容忍度(toleration)做了隔离:
# 计算密集型节点池
apiVersion: v1
kind: Node
metadata:
name: compute-node-1
labels:
node-type: compute
spec:
taints:
- key: workload
value: compute
effect: NoSchedule
---
# I/O 密集型节点池(带本地 SSD)
apiVersion: v1
kind: Node
metadata:
name: io-node-1
labels:
node-type: io-intensive
spec:
taints:
- key: workload
value: io-intensive
effect: NoSchedule
Pod 通过 toleration 选择目标节点池,避免不同类型工作负载互相干扰。实测下来,按工作负载类型分池后,I/O 竞争问题完全消失,Java 服务的 GC 日志写入延迟从 50ms 降到 2ms。
第三层:容量监控和预警
光规划不够,迁移过程中要持续监控资源使用趋势。写了一个 Go 脚本每天采集集群资源使用率,预测何时需要扩容:
// 集群容量趋势预测
type ClusterCapacity struct {
TotalCPU float64
TotalMem float64
UsedCPU float64
UsedMem float64
PodCount int
Timestamp time.Time
}
func (c *ClusterCapacity) Utilization() (cpuPct, memPct float64) {
if c.TotalCPU > 0 {
cpuPct = c.UsedCPU / c.TotalCPU * 100
}
if c.TotalMem > 0 {
memPct = c.UsedMem / c.TotalMem * 100
}
return
}
// 基于最近7天趋势预测资源耗尽时间
func PredictExhaustion(history []ClusterCapacity) (cpuDays, memDays int) {
if len(history) < 2 {
return -1, -1
}
first := history[0]
last := history[len(history)-1]
days := last.Timestamp.Sub(first.Timestamp).Hours() / 24
cpuGrowthRate := (last.UsedCPU - first.UsedCPU) / days
memGrowthRate := (last.UsedMem - first.UsedMem) / days
if cpuGrowthRate > 0 {
remainingCPU := (last.TotalCPU * 0.8) - last.UsedCPU // 80% 阈值
cpuDays = int(remainingCPU / cpuGrowthRate)
}
if memGrowthRate > 0 {
remainingMem := (last.TotalMem * 0.8) - last.UsedMem
memDays = int(remainingMem / memGrowthRate)
}
return
}
这个脚本每天早上跑一次,如果预测 7 天内资源会到 80%,自动发告警通知扩容节点。迁移期间它成功预警了两次资源瓶颈,都提前加了节点。
坑八:性能验证——迁移后变慢了但监控显示正常
问题:用户反馈"变卡了"但所有指标都正常
迁移完成后,业务方反馈"页面加载变慢了"。看监控——CPU 正常、内存正常、网络延迟正常、P99 延迟正常。所有指标都在迁移前的基线范围内。
花了两天排查。最后发现是 DNS 解析延迟。虚拟机时代 DNS 解析在 1ms 以内(本地 dnsmasq),K8s 环境的 CoreDNS 虽然也快(P50 2ms),但 P99 有 15-20ms 的毛刺。这个延迟不在常规监控里——因为大部分监控只看应用层指标,不看 DNS 解析延迟。
15ms 的 DNS 延迟为什么会让页面变卡?因为一个页面加载可能触发 5-8 次后端 API 调用,每次 API 调用又可能触发 2-3 次内部服务调用。如果每次调用都做 DNS 解析,累计延迟就是 5×8×15ms = 600ms。加上浏览器渲染和网络传输,用户感知到的总延迟增加了 500-800ms——足够让"秒开"变成"明显卡顿"。
解法:DNS 性能专项优化 + 延迟分解监控
第一步:给 CoreDNS 加 NodeLocal DNS Cache
NodeLocal DNS Cache 是 K8s 社区推荐的 DNS 优化方案。它在每个节点上跑一个 DNS 缓存 DaemonSet,Pod 的 DNS 请求先走本地缓存,命中就返回(延迟 < 1ms),未命中才转给 CoreDNS。
# NodeLocal DNS Cache 部署(简化版)
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-local-dns
namespace: kube-system
spec:
template:
spec:
containers:
- name: node-cache
image: registry.k8s.io/dns/k8s-dns-node-cache:1.22.20
args:
- -localip=169.254.20.10
- -conf=/etc/Corefile
- -upstream=10.96.0.10 # CoreDNS ClusterIP
ports:
- containerPort: 53
hostPort: 53
# 每个 Pod 的 resolv.conf 指向 169.254.20.10
# DNS 请求先走本地缓存
部署后的效果:
| 指标 | 部署前 (CoreDNS 直连) | 部署后 (NodeLocal Cache) |
|---|---|---|
| DNS P50 延迟 | 2ms | 0.3ms |
| DNS P99 延迟 | 18ms | 1.2ms |
| CoreDNS QPS | 8000 | 1200(缓存命中率 85%) |
| 用户感知页面加载 | +500-800ms | +20-50ms |
P99 从 18ms 降到 1.2ms,累计延迟从 600ms 降到 40ms,用户感知差异消失。
第二步:延迟分解监控
迁移后不能只看应用层指标,要做延迟分解——把一次请求的总延迟拆成 DNS 解析 + 连接建立 + 请求发送 + 服务端处理 + 响应传输五个阶段,分别监控。
我们在 OpenTelemetry 中加了 DNS 解析时间的采集(通过自定义 Span),在 Grafana 中建了延迟分解面板:
# DNS 解析延迟 P99(通过自定义 Span 采集)
histogram_quantile(0.99,
sum(rate(dns_resolution_duration_seconds_bucket[5m])) by (le, service)
)
# 连接建立延迟 P99
histogram_quantile(0.99,
sum(rate(tcp_connect_duration_seconds_bucket[5m])) by (le, service)
)
# 服务端处理延迟 P99
histogram_quantile(0.99,
sum(rate(http_server_duration_seconds_bucket{le!="+Inf"}[5m])) by (le, service)
)
迁移后的监控体系从"看总延迟"升级为"看每个环节的延迟"。出问题时不只看"慢了",而是"哪个环节慢了"。这个能力在后续的多次故障排查中证明了价值——直接看哪个环节延迟飙升,快速定位根因。
迁移全流程的架构决策总结
把上面 6 个坑串起来看,大规模微服务 K8s 迁移的核心决策点有以下几个:
决策一:迁移批次策略
| 策略 | 适用场景 | 风险 | 我的推荐 |
|---|---|---|---|
| 按依赖拓扑分批 | 服务依赖清晰,无循环 | 低 | ⭐ 首选 |
| 按业务域分批 | 团队按业务线组织 | 中 | 依赖梳理成本低,但跨域调用风险高 |
| 一次性全迁 | 服务数量少(<30) | 高 | 小规模可以,大规模不行 |
| 按流量权重灰度 | 有完善灰度基础设施 | 低 | 和拓扑分批结合使用 |
我们的做法是拓扑分批 + 流量灰度结合:先按依赖拓扑确定迁移批次,每批内部用流量灰度切流(10% → 30% → 50% → 100%),每档观察 30-60 分钟。
决策二:有状态服务迁移时机
有状态服务应该在什么阶段迁移?我推荐在第二批或第三批迁移,理由:
- 第一批迁的是无依赖的叶子服务,用来验证 K8s 集群基础功能(网络、存储、调度)
- 有状态服务迁移依赖集群稳定运行,不能在第一批迁(万一集群配置有问题,有状态服务数据可能损坏)
- 有状态服务不能放到最后迁——后面的服务可能依赖它,它不迁完后面的服务没法切流
决策三:网络策略的灰度策略
NetworkPolicy 不要一开始就全开。默认拒绝所有流量在生产环境是很危险的——你永远不知道哪个服务有未文档化的依赖。
我推荐三步走:
- 迁移前:全部默认允许,只开最小化的 Namespace 隔离
- 迁移中:逐个服务补充 NetworkPolicy,先验证不影响通信再收紧
- 迁移后:全量开启默认拒绝 + 白名单策略
决策四:回滚方案设计
迁移前必须设计好回滚方案。我们的回滚方案有三个层级:
| 回滚级别 | 触发条件 | 操作 | 耗时 |
|---|---|---|---|
| L1 流量回滚 | 单服务异常 | Ingress 权重切回旧环境 | < 1 分钟 |
| L2 批次回滚 | 批次内多服务异常 | 回滚该批次所有服务的 Ingress 权重 | < 5 分钟 |
| L3 全量回滚 | 集群级故障 | DNS 切回旧环境,K8s 集群保留待排查 | < 10 分钟 |
关键设计:每层回滚都是流量层面的,不需要删除 Pod 或回滚部署。旧环境在迁移期间保持运行,直到新环境 100% 稳定运行一周后才下线。
总结
120+ 微服务的 K8s 迁移,技术方案本身不复杂——Dockerfile、Helm Chart、CI/CD 流水线这些都是标准操作。真正的难点在于迁移过程中的细节决策:
- 服务依赖梳理要用运行时数据,不能只看静态配置。Jaeger 链路追踪 + 拓扑分析是必须的,循环依赖组必须整体迁移。
- 有状态服务分三层处理:持久化数据用主从同步,内存态缓存用 readinessProbe 保障,文件系统状态需要单独审计。
- CNI 选型要提前做,50 节点以上推荐 Cilium。Calico 的 iptables 在大规模下有性能拐点,eBPF 方案更稳定。
- 灰度切流要处理 DNS 缓存:应用层降 TTL、CoreDNS 优化缓存、过渡期双 IP 策略三管齐下。
- 配置管理要分层:公共配置、环境差异配置、敏感配置分别用不同的 K8s 资源管理,避免单个 ConfigMap 膨胀到不可维护。
- 监控指标迁移不能漏:标签映射 + 告警规则对照表 + 双环境并行校验,三步确保监控不断层。
这次迁移最深的感受是:迁移不是技术问题,是工程管理问题。每个坑都不是什么高深的技术难题,但每个坑都会在你不注意的时候咬你一口。能做的就是在迁移前把依赖梳理透、把有状态服务摸清楚、把 CNI 选对、把回滚方案设计好。剩下的就是在迁移过程中保持警觉,快速响应。
迁移完成后,扩缩容从 25 分钟降到 1 分钟(HPA + Cluster Autoscaler 自动响应),发布效率提升 50%(CI/CD 流水线标准化),服务可用性从 99.5% 提升到 99.9%。这些数字看起来漂亮,但只有经历过整个过程才知道背后踩了多少坑。
希望这些经验能帮到正在做或准备做 K8s 迁移的同行。有问题欢迎交流。
参考资料与致谢
本文在撰写过程中参考了以下资料,感谢原作者的贡献:
- Optimizing Stateful Microservice Migration in Kubernetes with MS2M and Forensic Checkpointing — IEEE,提出了 MS2M 框架和 Forensic Container Checkpointing 实现有状态微服务迁移的方法,参考了有状态服务迁移的思路
- Enhancing the Kubernetes Platform with a Load-Aware Orchestration Strategy — Springer Nature,分析了微服务在 K8s 中的调度优化策略,参考了大规模微服务调度的架构思路
- Stateful workloads in Azure Kubernetes Service (AKS) — Microsoft Learn,提供了有状态工作负载迁移到 K8s 的最佳实践和存储选型指导
- Locality-Aware QoS Optimization for Microservices Scheduling in Kubernetes Cluster — IEEE,LOCUS 调度优化器的设计,参考了微服务依赖与调度性能的关系分析
- Architecture best practices for Azure Kubernetes Service (AKS) — Microsoft,AKS 架构最佳实践,参考了工作负载隔离和多集群管理的权衡决策