概述

把 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
KafkaMirrorMaker2 同步 + 消费组迁移offset 迁移,消费顺序保证
MongoDBoplog 同步 + 切流索引重建,连接池配置

第二层:内存态(进程内缓存、会话状态)

进程内缓存是迁移中最容易被忽略的。我的建议是:迁移前做一次缓存依赖审计。对每个服务检查三个问题:

  1. 有没有进程内缓存?(搜 Caffeine、Guava Cache、Ehcache、go-cache 等关键字)
  2. 缓存数据来源是什么?(配置服务、数据库、其他 API?)
  3. 缓存重建依赖启动顺序吗?

如果第三个答案是"是",你必须在迁移方案中处理启动顺序问题。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/UnderlayOverlay (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 不会自动感知变更。你有两个选择:

  1. 重启 Pod:kubectl rollout restart deployment/<name>,简单粗暴,但会中断长连接
  2. 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_totalcontainer_cpu_usage_seconds_total需加 namespace 和 container 过滤
node_memory_MemAvailable_bytescontainer_memory_working_set_bytes含义不同,working_set 更接近实际使用
node_filesystem_avail_byteskubelet_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 延迟2ms0.3ms
DNS P99 延迟18ms1.2ms
CoreDNS QPS80001200(缓存命中率 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 不要一开始就全开。默认拒绝所有流量在生产环境是很危险的——你永远不知道哪个服务有未文档化的依赖。

我推荐三步走:

  1. 迁移前:全部默认允许,只开最小化的 Namespace 隔离
  2. 迁移中:逐个服务补充 NetworkPolicy,先验证不影响通信再收紧
  3. 迁移后:全量开启默认拒绝 + 白名单策略

决策四:回滚方案设计

迁移前必须设计好回滚方案。我们的回滚方案有三个层级:

回滚级别触发条件操作耗时
L1 流量回滚单服务异常Ingress 权重切回旧环境< 1 分钟
L2 批次回滚批次内多服务异常回滚该批次所有服务的 Ingress 权重< 5 分钟
L3 全量回滚集群级故障DNS 切回旧环境,K8s 集群保留待排查< 10 分钟

关键设计:每层回滚都是流量层面的,不需要删除 Pod 或回滚部署。旧环境在迁移期间保持运行,直到新环境 100% 稳定运行一周后才下线。

总结

120+ 微服务的 K8s 迁移,技术方案本身不复杂——Dockerfile、Helm Chart、CI/CD 流水线这些都是标准操作。真正的难点在于迁移过程中的细节决策:

  1. 服务依赖梳理要用运行时数据,不能只看静态配置。Jaeger 链路追踪 + 拓扑分析是必须的,循环依赖组必须整体迁移。
  2. 有状态服务分三层处理:持久化数据用主从同步,内存态缓存用 readinessProbe 保障,文件系统状态需要单独审计。
  3. CNI 选型要提前做,50 节点以上推荐 Cilium。Calico 的 iptables 在大规模下有性能拐点,eBPF 方案更稳定。
  4. 灰度切流要处理 DNS 缓存:应用层降 TTL、CoreDNS 优化缓存、过渡期双 IP 策略三管齐下。
  5. 配置管理要分层:公共配置、环境差异配置、敏感配置分别用不同的 K8s 资源管理,避免单个 ConfigMap 膨胀到不可维护。
  6. 监控指标迁移不能漏:标签映射 + 告警规则对照表 + 双环境并行校验,三步确保监控不断层。

这次迁移最深的感受是:迁移不是技术问题,是工程管理问题。每个坑都不是什么高深的技术难题,但每个坑都会在你不注意的时候咬你一口。能做的就是在迁移前把依赖梳理透、把有状态服务摸清楚、把 CNI 选对、把回滚方案设计好。剩下的就是在迁移过程中保持警觉,快速响应。

迁移完成后,扩缩容从 25 分钟降到 1 分钟(HPA + Cluster Autoscaler 自动响应),发布效率提升 50%(CI/CD 流水线标准化),服务可用性从 99.5% 提升到 99.9%。这些数字看起来漂亮,但只有经历过整个过程才知道背后踩了多少坑。

希望这些经验能帮到正在做或准备做 K8s 迁移的同行。有问题欢迎交流。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. Optimizing Stateful Microservice Migration in Kubernetes with MS2M and Forensic Checkpointing — IEEE,提出了 MS2M 框架和 Forensic Container Checkpointing 实现有状态微服务迁移的方法,参考了有状态服务迁移的思路
  2. Enhancing the Kubernetes Platform with a Load-Aware Orchestration Strategy — Springer Nature,分析了微服务在 K8s 中的调度优化策略,参考了大规模微服务调度的架构思路
  3. Stateful workloads in Azure Kubernetes Service (AKS) — Microsoft Learn,提供了有状态工作负载迁移到 K8s 的最佳实践和存储选型指导
  4. Locality-Aware QoS Optimization for Microservices Scheduling in Kubernetes Cluster — IEEE,LOCUS 调度优化器的设计,参考了微服务依赖与调度性能的关系分析
  5. Architecture best practices for Azure Kubernetes Service (AKS) — Microsoft,AKS 架构最佳实践,参考了工作负载隔离和多集群管理的权衡决策