Docker 镜像从 892MB 压到 118MB,我们却把 K8s 拆了回 ECS:3 个 Spring Boot 服务的 18 个月实录

🔑 关键词:容器化,Docker镜像优化,Kubernetes,jib分层构建,单体部署

📖 摘要:一个 12 人团队把 3 个 Spring Boot 服务容器化、上 K8s、又拆回 ECS + systemd 的完整过程。含镜像瘦身的具体参数、Alpine musl 的 DNS 坑、etcd 2GB 配额炸盘事故,以及一套「你到底该不该上 K8s」的判断清单。

先说结论

图片

我们团队 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.basejava.sqljava.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=alwaysRestartSec=3
  • 发布走 apt install ./app_1.4.7_amd64.deb,deb 里带 postinst 脚本负责 docker loadsystemctl 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,我自己现在用一套挺粗糙的判断:

  1. 服务数量是不是持续超过 15 个,且团队超过 3 个,各自独立发布?不是的话,Ansible + systemd 更省心。
  2. 有没有真正的弹性需求?比如每天有 10 倍以上的流量波峰,且成本对此敏感。我们的波峰是 1.8 倍,扛得住。
  3. 团队里有没有至少一个人,能看懂 etcd 的 defrag 和 kubelet 的 PLEG 报错?没有的话,托管版也救不了你。

我们三条都不满足,所以拆了。但如果哪天服务涨到 30 个,我还是会回去,只不过这次我会先招个 SRE,再上集群,而不是反过来。

🏷️ 标签: