先说结论
我们团队 12 个人,3 个 Spring Boot 服务,2021 年 4 月开始容器化,2022 年 10 月把 K8s 拆了。不是因为 K8s 不好,是因为我们拿 K8s 去解决了一个我们本来就没有的问题。
顺便说一句,容器化这件事本身我们没退回去,现在还是跑在容器里的,只是编排层从 K8s 换成了 systemd + 一份 deb 包。这个组合听起来土,但过去一年我们的 MTTR 从 25 分钟掉到 6 分钟,月度云成本从 4200 降到 1100。下面全是过程,没有结论先行。
一、那个 892MB 的镜像,是抄来的
最早那份 Dockerfile 是从某个教程里 copy 的,长这样:
FROM openjdk:8-jdk
WORKDIR /app
COPY . .
RUN ./mvnw clean package -DskipTests
EXPOSE 8080
CMD ["java", "-jar", "target/app.jar"]
问题不在 openjdk:8-jdk 有 600 多 MB,问题在 COPY . . 放在了 RUN mvnw 前面。这意味着我改一行 Controller 里的字符串,Docker 的构建缓存整层失效,Maven 从头下依赖。我们的 GitLab CI 是共享 runner,当时也没配 --cache-from,每次构建 11 分钟,其中 9 分半在拉 Maven 仓库。
镜像最终 892MB,推到 Harbor 要 3 分 10 秒。有一次生产回滚,我需要把一个旧版本重新部署,光等镜像 pull 就等了 4 分钟。那天晚上我盯着终端想,这 4 分钟里用户在干什么。
二、压到 118MB,Alpine 那一跤摔得挺疼
第一轮优化是常识性的:多阶段构建 + 把 pom.xml 单独 COPY 出来先跑 dependency:go-offline。镜像从 892MB 掉到 340MB,CI 构建从 11 分钟降到 4 分 20 秒。
然后我换了 eclipse-temurin:11-jre-alpine,镜像掉到 168MB,我挺得意。上线第三天,监控上出现诡异的尖刺——P99 从 40ms 跳到 2.8 秒,没有规律,大概每分钟抖一次。查了两天才定位到:Alpine 用的是 musl libc,JDK 在 musl 环境下 getaddrinfo 的行为和 glibc 不一样,加上容器里 /etc/resolv.conf 指向 CoreDNS,每次解析都真的走了网络,没有 nscd 缓存。修法是在启动参数里塞:
-Dsun.net.inetaddr.ttl=60 -Dsun.net.inetaddr.negative.ttl=10
但更干脆的做法是别用 Alpine。最后我们换回 glibc 基础镜像,用 jlink 裁了一个只含 java.base、java.sql、java.naming 的 JRE,加上 Jib 做分层(dependencies / resources / classes 三层分开),成品 118MB。
这里有个数字我觉得挺说明问题的:Jib 分层之后,改业务代码只推最上面那层 classes,大约 200KB。镜像 push 时间从 3 分 10 秒降到 14 秒。这才是我想要的效果,不是为了镜像小,是为了回滚快。
三、上 K8s 之后,我们丢掉的比得到的多
2021 年 8 月我们上了 K8s,3 个服务,5 个 node,托管版。上线第一个月就出了三件事:
第一件,Prometheus 自己 OOM 了。我们给它配了 512Mi 的 limit,因为「监控不应该占太多资源」。结果它挂了三天没人知道,因为告警是 Prometheus 发的。这个循环依赖我后来在好几个团队都见过。
第二件,Readiness probe 配的 initialDelaySeconds: 5。我们的应用启动要 38 秒(要预热本地缓存)。结果滚动更新的时候,Pod 一进 Ready 就被塞流量,请求全 503,用户那边看到的是「服务偶尔抽风」。这个配置错误,我们花了两个下午才发现,因为本地单机跑不出这个现象。
第三件最狠,某天凌晨一个 node NotReady,查了半天是 etcd 的 quota 满了。K8s 的 etcd 默认 --quota-backend-bytes 是 2147483648,也就是 2GB,超过会直接 NOSPACE 告警并停止写入。我们那个节点的 /var/lib/etcd 挂在一块 40GB 盘上,一直以为够用,实际是 audit log 把碎片撑起来了。三小时排查,最后是 etcdctl compact + defrag 解决的。
说实话,这三件事单拎出来都不算 K8s 的错,是我们的运维成熟度不够。但问题是:我们需要多少运维成熟度,才配得上这套东西? 我们的服务 QPS 峰值 200,日活 3 万,这个体量真的需要一个有状态分布式 KV 来存集群状态吗?
四、回滚方案:一份 deb 包 + systemd
2022 年 10 月我们做了个逆操作。容器留着,编排层换成:
- 一台 4C8G 的 ECS 跑全部 3 个服务(内存够,QPS 也够)
- 一个 systemd unit 文件管一个容器,
Restart=always,RestartSec=3 - 发布走
apt install ./app_1.4.7_amd64.deb,deb 里带 postinst 脚本负责docker load和systemctl restart - 日志直接 docker logs 进 journald,
journalctl -u app-api --since "10 min ago" - 回滚执行
apt install ./app_1.4.6_amd64.deb,实测 22 秒
你可能会说这不就是个穷人的 K8s 吗。对,但它有个 K8s 给不了的东西:整套系统在一个人脑子里装得下。周五晚上出问题,值班的那个后端能自己搞完,不用呼叫 SRE,因为 SRE 就一个人,他也在看球。
数字上:月度成本 4200 → 1100。发布耗时从 K8s 滚动更新的 90 秒缩到 22 秒(因为不用调度和探针)。年故障次数其实涨了一次,但 MTTR 从 25 分钟降到 6 分钟,因为排查路径短了。
五、我现在怎么看容器化这件事
行业里讲容器化的价值,标准答案一般是「一次构建,到处运行」或者「弹性伸缩」。我干了这两年,觉得这俩都不是最大的收益。
最大的收益是:它逼你把环境依赖显式写出来。以前部署文档里写着「需要装 JDK 1.8,需要 /opt/data 目录,需要设置 LANG=zh_CN.UTF-8」,这些东西散在 wiki 里,三年后没人知道哪条还有效。有了 Dockerfile,这些全部变成代码,FROM 那一行就是你的运行时契约。这个副作用,比可移植性值钱多了。
至于要不要上 K8s,我自己现在用一套挺粗糙的判断:
- 服务数量是不是持续超过 15 个,且团队超过 3 个,各自独立发布?不是的话,Ansible + systemd 更省心。
- 有没有真正的弹性需求?比如每天有 10 倍以上的流量波峰,且成本对此敏感。我们的波峰是 1.8 倍,扛得住。
- 团队里有没有至少一个人,能看懂 etcd 的 defrag 和 kubelet 的 PLEG 报错?没有的话,托管版也救不了你。
我们三条都不满足,所以拆了。但如果哪天服务涨到 30 个,我还是会回去,只不过这次我会先招个 SRE,再上集群,而不是反过来。