2019年我接手公司容器化改造项目的时候,压在我头上的是一套连文档都没有的遗留系统。当时我兴奋坏了,以为只要把这些服务塞进Docker里就能解决环境不一致,结果上线第一周就出了丑:有个Java服务需要在容器里读取设备号做license校验,但容器里没有systemd,原来写在rc.local里的初始化逻辑根本不执行。折腾到凌晨三点,最后在entrypoint脚本里手动调了一堆命令。那个瞬间特别沮丧,因为我才意识到,容器化并不只是换个启动方式,而是整条链路都得重新想。后来我还发现,团队里很多人依然在用虚拟机的思路看待容器,比如疯狂给镜像加补丁包,而不是重新构建干净的镜像——这种惯性思维才真正要命。
过了两周我特意拿容器和VM做了一个对比测试,才更理解两者的隔离逻辑完全不同。VM是硬件虚拟化,每个VM都有自己的内核,跑出问题不容易殃及宿主;容器则是共享宿主内核,靠cgroup和namespace做资源限制与命名空间隔离,所以安全模型不能用老一套。最直观的教训是:我们设置CPU limit=4,结果压测性能反而比limit=2还差。查了很久调了很多参数才搞明白,cgroup的CPU配额是按权重调的,不是给多少个核。我写了个小实验:两个容器一个限制1核一个限制2核,宿主机空闲时两者延迟都差不多,但一旦我拿另一个负载抢占CPU,限制1核的延迟立刻飙升到500ms以上,限制2核的只到200ms左右。也就是说,隔离给你的是限额,不是保障。在K8s里必须考虑Burstable和Guaranteed类的优先级,可很多文章都在教你怎么写YAML,没人告诉你CPU quota背后是个加权调度器。
关于运行时选择,我的观点可能有点不合群。Docker在K8s 1.24被移除了CRI支持,一堆人喊Docker完蛋了,但我们生产环境就是为了不再依赖Docker而转了containerd,结果调试Pod的时候ctr命令难用到令人崩溃,日志查询、打印环境变量都不顺手,最后还得装nerdctl才能模仿Docker的体验。对比了同样一个Spring Boot镜像,Docker和containerd的启动速度其实没啥差别,都在300ms量级,瓶颈反而在calico的vxlan网络转发上。从那以后我明白一个道理:runtime不是越新越好,团队会不会用、调试工具有多完善才是最重要。Docker的build缓存真的做得很好,它的CLI很大程度上塑造了开发者的工作流,containerd更像是给Kubernetes专用的引擎,不该为了赶潮流而否定Docker带来的工程习惯。
真正颠覆我想法的是log4j漏洞爆发那次。周末上午出了CVE,我们排查了一整天,最后发现大约八成正在运行的镜像里还躺着旧版本commons-collections或jacob,一半以上的镜像带过期的OpenSSL。问题是这些镜像可能构建后就没再动过,我们根本不知道它里面有这些玩意。当时头皮发麻,恨的是为什么当初没做镜像供应链的安全基线。后来我逼着团队做了三件事:第一把镜像仓库里所有镜像的digest和tag全部拉出来做了矩阵;第二规定每周用Trivy扫一次仓库,把结果直接发到群里,跑不了;第三废掉原来的非multi-stage Dockerfile,强制拆分为build和runtime阶段,去除编译工具和调试符号。折腾一年后,镜像体积中位数从1.2GB降到了280MB,启动速度也快了一些,但是代价是CI构建机从3台扩到了8台,而且还要专门维护一个buildkit集群。所以现在再问我容器化值不值,我的答案依然会说:它把传统配置漂移问题平移到了镜像层,能让你更早发现依赖混乱,但并没有神奇地帮你消灭变更管理。用容器不用容器,最后拼的都还是纪律和流程。反正我们以后还是会继续用,只是不再神化它了。