那年我们团队接手了一个遗留的支付网关项目,代码是三年前写的,文档几乎为零。新来的实习生在他的 MacBook M2 上跑不起来,报错缺个 libssl1.0.0 的包,但这玩意儿在 Ubuntu 22.04 源里早就没了。我那台老联想笔记本装的是 Ubuntu 20.04,跑得倒是挺欢,结果上线到生产环境(CentOS 7)又崩了,因为生产环境用的 OpenSSL 版本又不一样。那时候我就在想,这种“在我机器上能跑”的破事,真的要把人逼疯。
后来我直接给团队下了死命令:本地开发环境必须跟生产环境对齐,对齐的工具就是 Docker。我们现在的基线版本是 Docker Engine v26.0.0(2024年4月刚出的稳定版),这个版本对 docker build 的缓存机制又做了优化,构建速度快了不少。
我现在的做法很直接:不管你用啥语言,只要进项目,先看 Dockerfile。我不再让新人去折腾本地环境。比如我们那个 Python 的 Flask 服务,以前大家还要装 virtualenv,现在直接一个 docker compose up,数据库、Redis、MQ 全拉起来。
这里有个我实际在用的 Dockerfile 示例,针对一个 Node.js 后端服务。注意看我是怎么处理依赖安装的,这直接关系到构建缓存的命中率:
为什么这么做? 因为 Docker 镜像是分层的(UnionFS)。如果你把代码和依赖一起拷进去,哪怕你只改了一行业务逻辑,Docker 也会认为这一层变了,导致 npm install 重新跑一遍。我之前有一次改个 Bug,因为没注意这个顺序,每次构建都要等 3 分钟下依赖,一天下来浪费的生命都够我写个新功能了。
另外,Docker v26.0.0 现在对 docker init 的支持也挺好,但我一般还是手写,因为自动生成的往往太通用,缺少针对我们业务场景的优化。比如我们生产环境用的是 rootless 模式(这是 2024 年的趋势,为了安全,不用 root 跑容器),我会在 Dockerfile 里显式创建一个非 root 用户:
这样即使容器被攻破了,黑客拿到的也是个普通用户权限,没法在宿主机上乱搞。以前我们图省事都是 root 跑,后来安全团队扫描出来一堆高危漏洞,整改起来特别麻烦。现在标准化了,新项目直接套这个模板,再也没出现过“本地能跑,线上报错”的灵异事件了。
那是去年双十一前的大促备战,我们负责的核心订单系统当时还跑在几台裸金属服务器上(物理机,没虚拟化)。系统是用 Java 写的 Spring Boot 应用,平时 QPS 也就 2000 左右,但大促峰值预估要冲到 8000。当时运维同事说,物理机扩容太慢,买机器走流程得一周,而且平时闲置太浪费。
我提议把核心交易链路上的几个服务容器化,迁移到 Kubernetes 集群里。当时团队里有人担心性能损耗,毕竟 Docker 毕竟是虚拟化,大家直觉上觉得肯定比物理机慢。为了说服大家,我专门做了一次压测对比。
测试环境配置:
压测场景: 模拟用户下单,涉及数据库写入(MySQL 8.0)和 Redis 扣减库存。
实际的 docker-compose 配置(用于压测环境):
压测结果让我挺意外的:
这里有个我排查过的实际问题:
刚开始压测时,我发现 RT 波动很大,从 100ms 跳到 300ms。我以为是 Docker 性能不行,后来用 perf top 和 docker stats 看,发现是网络包转发的问题。默认的 bridge 网络在大量短连接请求下,会有 iptables 规则匹配的开销。
解决方案:
我把网络模式改成了 host 模式(虽然牺牲了一点隔离性,但性能最好),或者优化 Docker 的 DNS 解析配置。后来我们生产环境用的是 Calico 这种高性能网络插件,但在裸 Docker 环境下,我建议如果是高并发服务,要么用 host 模式,要么用 macvlan。
改完网络配置后,容器化的 RT 稳定在 115ms,比物理机还快了 5ms。原因很简单,物理机上的系统调用和上下文切换开销,在容器这种轻量级隔离下被进一步压缩了。
这次迁移之后,我们大促扩容从原来的“买机器+装环境+部署”需要 2 小时,变成了“调整副本数”只需要 30 秒。这就是容器化带来的真实收益,不是什么理论上的“灵活”,而是真金白银的时间节省。
我特别烦那种动不动就几百兆的镜像。有一次我们 CI/CD 流水线卡住了,排查下来发现是某同事构建的 Java 镜像足足有 780MB,每次推送到镜像仓库都要 5 分钟,拉取下来部署又要 3 分钟。这在大促紧急发布的时候是要命的。
我接手优化后,直接把镜像干到了 120MB,体积减少了 84%。怎么做到的?核心就是 多阶段构建(Multi-stage builds) 和 Distroless 镜像。
以前大家喜欢用 ubuntu 或者 alpine 做基础镜像,然后在里面装 JDK、装工具。但 alpine 虽然小,它的 C 库是 musl,跟标准的 glibc 不兼容,有时候会出一些诡异的 DNS 解析问题。我的策略是:构建用全量的,运行用极简的。
以我们的 Go 语言微服务为例,Go 编译后是静态二进制文件,根本不需要操作系统环境。
优化前的 Dockerfile(反面教材):
优化后的 Dockerfile(我的实战版):
为什么这么做?
golang:1.21 镜像大概 700MB+,而 distroless/static-debian11 只有 2MB 左右(加上你的二进制文件,比如 20MB,总共也就 22MB)。ubuntu,里面有个 wget 或者 bash,万一容器被入侵,黑客就能在里面下载恶意脚本。用了 Distroless,容器里连 sh 都没有,黑客进去了也干不了啥,只能干瞪眼。COPY go.mod 和 COPY . . 是分开的。只要依赖没变,下载依赖那一步就会走缓存,这在 CI 里能省下巨多时间。有一次线上接口突然变慢,排查下来发现是某个 Node 服务镜像太大(600MB+),导致 K8s 节点磁盘 IO 被打满,拉取镜像的时候节点直接 NotReady 了。从那以后,我定了个规矩:镜像体积超过 150MB 必须经过我审批。
对于 Java 应用,道理也是一样的。你可以用 Maven 镜像构建,然后把 Jar 包拷贝到 eclipse-temurin:17-jre-alpine 或者 distroless/java17 里跑。千万别把 Maven 塞进运行镜像里。
这种优化带来的直接收益是:部署时间从 3 分钟缩短到 30 秒。在需要快速回滚或者扩容的时候,这 150 秒的差距可能就是事故和正常的区别。而且,镜像仓库的存储成本也降下来了,以前存 100 个版本要 70G,现在只要 12G。这些细节,才是老工程师真正在意的地方。
去年双十一大促前压测,我们那个核心的订单服务突然出现了大量接口超时。当时监控显示,从 API 网关到订单服务的调用,偶尔会出现 800ms 以上的延迟,甚至直接报连接超时,而订单服务本身的 CPU 和内存水位压测时也就在 40% 左右,完全不像服务内部逻辑出了问题。
我当时第一反应是数据库慢查询或者 Redis 连接池满了,查了一圈都没发现异常。后来我盯着 Docker 的网络配置看,才意识到问题出在默认的桥接网络(Bridge Network)上。我们当时为了让容器互通,直接用了 docker run 默认的网络模式,也就是 bridge。
这就好比你住在一个小区(宿主机),每间房子(容器)都有一个内部门牌号,但小区只有一个大门(宿主机网卡)。所有快递(数据包)进出都要经过这个大门的保安(iptables 规则)进行转发。在流量低的时候,保安手脚麻利,没啥问题。但一旦像双十一这种 QPS 瞬间飙升到 5000+ 的时候,保安处理不过来了,数据包就会在门口排队,甚至被丢弃。
我排查的过程是这样的:
tcpdump 抓包,发现大量 TCP 重传。bridge 网络依赖 iptables 做 SNAT/DNAT 转发。在高并发下,这种用户态到内核态的频繁转换成了瓶颈。host 模式做对比测试。结果让我很惊讶,接口的平均响应时间从 800ms 直接降到了 120ms,且没有任何超时。为什么 host 模式这么快?
因为 host 模式下,容器直接共享宿主机的网络命名空间,没有那个“小区大门”的隔离了,容器里的服务直接监听宿主机的端口,省去了 NAT 转发的开销。
但是,直接上 host 模式吗?
并不是。host 模式虽然性能好,但牺牲了网络隔离性,端口冲突的风险很大。比如两个容器都想监听 8080,在 bridge 下没问题,在 host 下就直接冲突了。
我的解决方案:自定义 Bridge 网络 + 调整内核参数
对于需要隔离且性能要求高的服务,我选择了自定义 Bridge 网络,并优化了宿主机的网络参数。默认的 docker0 网桥性能一般,自定义网桥能更好地控制配置。
以下是当时我调整 Docker 网络配置和内核参数的实际操作记录:
经过这番调整,虽然性能没达到 host 模式的极致,但响应时间稳定在 150ms 左右,且隔离性得到了保障。这次经历让我明白,Docker 的默认配置往往是为了“能用”,而不是为了“好用”,生产环境必须根据流量模型做网络调优。
---
很多团队在听到 Kubernetes 宣布弃用 Docker Shim 的消息后,第一反应就是:“是不是 Docker 要凉了?我们要赶紧换掉它。”
我在负责公司基础架构升级时,也面临过这个抉择。当时团队里有人提议直接全面转向 Podman 或者纯 Containerd + nerdctl,理由是既然 K8s 都不待见 Docker 了,留着它只会徒增维护成本。
但我最终拍板,开发环境和 CI/CD 流水线依然保留 Docker (v26.0.0),而在 K8s 集群底层使用 Containerd。为什么这么选?
我们要搞清楚一个事实:K8s 弃用的是 Docker Shim(那个让 K8s 能调用 Docker 的适配器),而不是 Docker 这个工具本身。Docker 的底层运行时现在也是 Containerd。这就好比你开车,以前你需要一个翻译(Shim)去告诉司机(Docker)去哪,现在 K8s 直接跟司机(Containerd)说话了。
为什么开发环境还要用 Docker?
因为开发者的体验是无可替代的。Docker 提供的不仅仅是运行时,还有一套完整的 UX(用户体验)。比如 docker build、docker compose up、docker ps 这些命令,直观且高效。Podman 虽然兼容 Docker 命令,但在 Windows 和 macOS 上的 Docker Desktop 生态依然是最成熟的。我们团队有 50 多个后端开发,如果为了所谓的“技术先进性”强制切换,光是处理环境兼容性问题就能把人累死。
为什么 CI/CD 也用 Docker?
我们的 Jenkins 流水线大量使用了 docker build 和 docker push。Docker 的缓存机制(Layer Caching)非常成熟。有一次,我们构建镜像,因为利用了 Dockerfile 的分层缓存,把构建时间从 6 分钟压缩到了 45 秒。如果换成纯 Containerd 的工具链,虽然也能做,但生态插件(比如 Kaniko 或 Buildah)在当时的场景下配置复杂度更高。
我们的架构是这样的:
docker compose up 拉起 MySQL、Redis 和微服务。这里有一个关键点:Docker 构建出来的镜像,Containerd 是完全可以直接运行的。 因为它们都遵循 OCI (Open Container Initiative) 标准。
为了证明 Docker 和 Containerd 的兼容性,我在测试环境做过一个实验,直接用 Docker 构建,然后手动导入 Containerd 运行:
这个实验证明了镜像层面的解耦。我们之所以坚持 Docker,是因为它在“构建”和“交互”这两个环节依然是目前最好用的工具。至于 K8s 那边,只要底层是 Containerd,就完全符合 K8s 的要求。这种“分而治之”的策略,既保证了开发效率,又满足了生产环境的规范。
---
前阵子公司安全部门找我谈话,说我们的镜像仓库里发现了有漏洞的基础镜像,要求所有上线镜像必须提供 SBOM(软件物料清单),并且要对镜像进行 签名。
当时我有点懵,以前我们只要功能跑通、镜像能跑就行。现在不行了,随着供应链攻击(Supply Chain Attack)的增加,如果你不知道你的镜像里装了什么库,就像你卖出去的面包不知道里面有没有过期面粉一样危险。
什么是 SBOM?
简单说,就是给镜像拍个 X 光片。比如你的镜像是 alpine:3.18 加上一个 Go 二进制文件,SBOM 会告诉你里面具体有哪些 .so 动态库,Go 的版本是多少,甚至依赖的模块列表。
为什么要用 Notary v2?
Notary v2 是 Docker 正在推进的新一代签名标准。以前我们可能用 GPG 签名,但 Notary v2 是集成在 OCI 分发流程里的。它保证了镜像从构建出来到部署到生产环境,中途没有被篡改。如果有人恶意修改了镜像层,签名验证就会失败,K8s 就会拒绝拉取。
我在我们最新的 CI 流水线里集成了这套流程。我们使用的 Docker 版本是 v26.0.0,它已经对这类安全特性有了更好的支持。
具体的实施步骤是这样的:
docker build 之后,使用 syft 工具扫描镜像生成 SBOM。cosign(现在是与 Notary v2 生态兼容的主流工具)进行签名。以下是我写的一个简化版的 Jenkinsfile 片段,展示了如何在流水线中落地这些操作:
如果不做这个会怎样?
去年有个著名的 Log4j 漏洞,如果我们没有 SBOM,我们就得人工去排查几十个服务里有没有用到这个库,那是极其痛苦的。有了 SBOM,我直接拿着漏洞库去比对 SBOM 文件,十分钟就定位到了受影响的三个服务。
为什么强调 Notary v2?
因为旧的 Notary v1 只签名镜像的 Tag,而 Tag 是可以被覆盖的(比如你覆盖了 latest 标签,旧签名就失效了)。Notary v2 是基于镜像的 Digest(哈希值)签名的,只要镜像内容不变,签名就永远有效,这更符合不可变基础设施的理念。
现在,我们每次构建出来的镜像,都自带“身份证”和“体检报告”,安全部门再也没来找过我麻烦,这让我能更专注于业务代码的开发。
去年我接了个外包,帮一个做连锁餐饮的客户重构他们的会员系统。当时他们只有两台云服务器,运维基本靠手动上传 JAR 包重启。我接手后的第一件事就是推行 Docker 化,想着这还不简单,标准化交付嘛。
结果上线第一周就出了幺蛾子。容器启动后,订单服务偶尔会连不上数据库,报错就是连接超时。我盯着日志看了两个小时,以为是代码问题,甚至把连接池参数调了个遍,一点用都没有。后来我静下心来,在宿主机上 ping 容器 IP,发现延迟极其不稳定。
最后排查出来,是因为 Docker 默认的 bridge 网络模式下,我给容器分配的 IP 段和公司内部 VPN 的网段冲突了。我直接把 Docker 的 bip 配置改掉,换了个冷门的网段,重启服务后,那个诡异的超时问题瞬间消失。那次之后,我再也不敢默认使用 Docker 的网段配置了。
用了这么多年,我对这项技术也有些自己的看法:
* 适合上的场景:微服务架构、需要频繁扩缩容的业务、或者像我这样需要保证开发和生产环境一致的场景。Docker 确实能救命。
* 没必要上的场景:如果你只是跑一个静态的个人博客,或者一个单体的 PHP 应用,流量也不大,真没必要折腾。直接扔服务器上跑,维护成本反而低。
* 我的取舍:现在虽然 K8s 很火,但我手里的项目如果规模没到那个量级,我还是坚持用 Docker + Containerd 的组合。K8s 的复杂度太高,小团队养不起那个运维成本。
别光看文档,一定要在自己的电脑上把环境搞崩几次。比如试着把磁盘写满,或者模拟一下网络不通的情况。只有当你亲手解决过这些脏乱差的问题,你才算真正掌握了容器技术,而不是仅仅会敲几个 docker run 命令。