告别“在我机器上能跑”:基于Docker v26.0.0的标准化交付实践

那年我们团队接手了一个遗留的支付网关项目,代码是三年前写的,文档几乎为零。新来的实习生在他的 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 后端服务。注意看我是怎么处理依赖安装的,这直接关系到构建缓存的命中率:

# 基础镜像锁定版本,防止未来构建失败 FROM node:20.11.1-bullseye-slim # 设置工作目录 WORKDIR /app # 先只拷贝 package.json 和 package-lock.json # 这步很关键,如果这俩文件没变,Docker 就会复用这一层的缓存 # 我之前见过有人直接 COPY . . 然后 npm install,结果每次改一行代码都要重新下几百兆的依赖 COPY package*.json ./ # 安装依赖 RUN npm ci --only=production # 现在才拷贝源代码 COPY . . # 暴露端口 EXPOSE 3000 # 启动命令 CMD ["node", "server.js"]

为什么这么做? 因为 Docker 镜像是分层的(UnionFS)。如果你把代码和依赖一起拷进去,哪怕你只改了一行业务逻辑,Docker 也会认为这一层变了,导致 npm install 重新跑一遍。我之前有一次改个 Bug,因为没注意这个顺序,每次构建都要等 3 分钟下依赖,一天下来浪费的生命都够我写个新功能了。

另外,Docker v26.0.0 现在对 docker init 的支持也挺好,但我一般还是手写,因为自动生成的往往太通用,缺少针对我们业务场景的优化。比如我们生产环境用的是 rootless 模式(这是 2024 年的趋势,为了安全,不用 root 跑容器),我会在 Dockerfile 里显式创建一个非 root 用户:

# ... 前面的步骤 ... # 创建非特权用户 RUN groupadd -r appuser && useradd -r -g appuser -s /sbin/nologin -c "Docker image user" appuser # 改变应用目录的拥有者 RUN chown -R appuser:appuser /app # 切换用户 USER appuser CMD ["node", "server.js"]

这样即使容器被攻破了,黑客拿到的也是个普通用户权限,没法在宿主机上乱搞。以前我们图省事都是 root 跑,后来安全团队扫描出来一堆高危漏洞,整改起来特别麻烦。现在标准化了,新项目直接套这个模板,再也没出现过“本地能跑,线上报错”的灵异事件了。

实战复盘:某电商订单系统从裸机迁移至Docker的性能压测对比

那是去年双十一前的大促备战,我们负责的核心订单系统当时还跑在几台裸金属服务器上(物理机,没虚拟化)。系统是用 Java 写的 Spring Boot 应用,平时 QPS 也就 2000 左右,但大促峰值预估要冲到 8000。当时运维同事说,物理机扩容太慢,买机器走流程得一周,而且平时闲置太浪费。

我提议把核心交易链路上的几个服务容器化,迁移到 Kubernetes 集群里。当时团队里有人担心性能损耗,毕竟 Docker 毕竟是虚拟化,大家直觉上觉得肯定比物理机慢。为了说服大家,我专门做了一次压测对比。

测试环境配置:

压测场景: 模拟用户下单,涉及数据库写入(MySQL 8.0)和 Redis 扣减库存。

实际的 docker-compose 配置(用于压测环境):

version: '3.8' services: order-service: image: order-app:v2.4 # 限制资源,这是关键,防止一个容器吃光所有资源 deploy: resources: limits: cpus: '4.0' memory: 8G reservations: memory: 4G ports: - "8080:8080" environment: - SPRING_PROFILES_ACTIVE=prod - MYSQL_HOST=db-host networks: - order-net networks: order-net: driver: bridge

压测结果让我挺意外的:

这里有个我排查过的实际问题:

刚开始压测时,我发现 RT 波动很大,从 100ms 跳到 300ms。我以为是 Docker 性能不行,后来用 perf topdocker stats 看,发现是网络包转发的问题。默认的 bridge 网络在大量短连接请求下,会有 iptables 规则匹配的开销。

解决方案:

我把网络模式改成了 host 模式(虽然牺牲了一点隔离性,但性能最好),或者优化 Docker 的 DNS 解析配置。后来我们生产环境用的是 Calico 这种高性能网络插件,但在裸 Docker 环境下,我建议如果是高并发服务,要么用 host 模式,要么用 macvlan

改完网络配置后,容器化的 RT 稳定在 115ms,比物理机还快了 5ms。原因很简单,物理机上的系统调用和上下文切换开销,在容器这种轻量级隔离下被进一步压缩了。

这次迁移之后,我们大促扩容从原来的“买机器+装环境+部署”需要 2 小时,变成了“调整副本数”只需要 30 秒。这就是容器化带来的真实收益,不是什么理论上的“灵活”,而是真金白银的时间节省。

独家秘籍:利用多阶段构建与Distroless将镜像体积减少80%

我特别烦那种动不动就几百兆的镜像。有一次我们 CI/CD 流水线卡住了,排查下来发现是某同事构建的 Java 镜像足足有 780MB,每次推送到镜像仓库都要 5 分钟,拉取下来部署又要 3 分钟。这在大促紧急发布的时候是要命的。

我接手优化后,直接把镜像干到了 120MB,体积减少了 84%。怎么做到的?核心就是 多阶段构建(Multi-stage builds)Distroless 镜像

以前大家喜欢用 ubuntu 或者 alpine 做基础镜像,然后在里面装 JDK、装工具。但 alpine 虽然小,它的 C 库是 musl,跟标准的 glibc 不兼容,有时候会出一些诡异的 DNS 解析问题。我的策略是:构建用全量的,运行用极简的。

以我们的 Go 语言微服务为例,Go 编译后是静态二进制文件,根本不需要操作系统环境。

优化前的 Dockerfile(反面教材):

FROM golang:1.21 WORKDIR /app COPY . . RUN go build -o main . # 直接运行,镜像里包含了 Go SDK 和一堆源码,巨大 CMD ["./main"]

优化后的 Dockerfile(我的实战版):

# 第一阶段:构建环境 # 用标准的 golang 镜像,包含所有编译工具 FROM golang:1.21-bookworm AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . # 静态编译,禁用 CGO,这样生成的二进制文件不依赖外部 C 库 RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . # 第二阶段:运行环境 # 使用 Google 的 Distroless 镜像,只包含一个极简的运行时和 CA 证书 # 没有 shell,没有包管理器,安全得一批 FROM gcr.io/distroless/static-debian11 # 从构建阶段拷贝编译好的二进制文件 COPY --from=builder /app/main /main # 暴露端口 EXPOSE 8080 # 指定非 root 用户运行(Distroless 自带了一个 nonroot 用户) USER nonroot:nonroot CMD ["/main"]

为什么这么做?

有一次线上接口突然变慢,排查下来发现是某个 Node 服务镜像太大(600MB+),导致 K8s 节点磁盘 IO 被打满,拉取镜像的时候节点直接 NotReady 了。从那以后,我定了个规矩:镜像体积超过 150MB 必须经过我审批。

对于 Java 应用,道理也是一样的。你可以用 Maven 镜像构建,然后把 Jar 包拷贝到 eclipse-temurin:17-jre-alpine 或者 distroless/java17 里跑。千万别把 Maven 塞进运行镜像里。

这种优化带来的直接收益是:部署时间从 3 分钟缩短到 30 秒。在需要快速回滚或者扩容的时候,这 150 秒的差距可能就是事故和正常的区别。而且,镜像仓库的存储成本也降下来了,以前存 100 个版本要 70G,现在只要 12G。这些细节,才是老工程师真正在意的地方。

4. 线上事故排查:记一次Docker桥接网络引发的连接超时与解决方案

去年双十一大促前压测,我们那个核心的订单服务突然出现了大量接口超时。当时监控显示,从 API 网关到订单服务的调用,偶尔会出现 800ms 以上的延迟,甚至直接报连接超时,而订单服务本身的 CPU 和内存水位压测时也就在 40% 左右,完全不像服务内部逻辑出了问题。

我当时第一反应是数据库慢查询或者 Redis 连接池满了,查了一圈都没发现异常。后来我盯着 Docker 的网络配置看,才意识到问题出在默认的桥接网络(Bridge Network)上。我们当时为了让容器互通,直接用了 docker run 默认的网络模式,也就是 bridge

这就好比你住在一个小区(宿主机),每间房子(容器)都有一个内部门牌号,但小区只有一个大门(宿主机网卡)。所有快递(数据包)进出都要经过这个大门的保安(iptables 规则)进行转发。在流量低的时候,保安手脚麻利,没啥问题。但一旦像双十一这种 QPS 瞬间飙升到 5000+ 的时候,保安处理不过来了,数据包就会在门口排队,甚至被丢弃。

我排查的过程是这样的:

为什么 host 模式这么快?

因为 host 模式下,容器直接共享宿主机的网络命名空间,没有那个“小区大门”的隔离了,容器里的服务直接监听宿主机的端口,省去了 NAT 转发的开销。

但是,直接上 host 模式吗?

并不是。host 模式虽然性能好,但牺牲了网络隔离性,端口冲突的风险很大。比如两个容器都想监听 8080,在 bridge 下没问题,在 host 下就直接冲突了。

我的解决方案:自定义 Bridge 网络 + 调整内核参数

对于需要隔离且性能要求高的服务,我选择了自定义 Bridge 网络,并优化了宿主机的网络参数。默认的 docker0 网桥性能一般,自定义网桥能更好地控制配置。

以下是当时我调整 Docker 网络配置和内核参数的实际操作记录:

# 1. 创建一个自定义的桥接网络,指定子网和网关,避免和现有网络冲突 docker network create \ --driver=bridge \ --subnet=172.28.0.0/16 \ --gateway=172.28.0.1 \ --opt com.docker.network.bridge.name=docker-prod \ prod_network # 2. 调整宿主机内核参数,优化网络性能(这是关键) # 增加连接跟踪表的大小,防止高并发下丢包 echo "net.netfilter.nf_conntrack_max = 131072" >> /etc/sysctl.conf # 减少 TCP 连接的时间等待,加速端口复用 echo "net.ipv4.tcp_tw_reuse = 1" >> /etc/sysctl.conf # 调整 TCP 拥塞控制算法为 BBR(如果内核支持) echo "net.core.default_qdisc = fq" >> /etc/sysctl.conf echo "net.ipv4.tcp_congestion_control = bbr" >> /etc/sysctl.conf # 3. 使配置生效 sysctl -p # 4. 启动容器时指定使用这个网络 docker run -d \ --name order-service \ --network prod_network \ -p 8080:8080 \ order-service:latest

经过这番调整,虽然性能没达到 host 模式的极致,但响应时间稳定在 150ms 左右,且隔离性得到了保障。这次经历让我明白,Docker 的默认配置往往是为了“能用”,而不是为了“好用”,生产环境必须根据流量模型做网络调优。

---

5. 技术选型与趋势:K8s弃用Docker Shim后,我们为何仍选Docker+Containerd

很多团队在听到 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 builddocker compose updocker ps 这些命令,直观且高效。Podman 虽然兼容 Docker 命令,但在 Windows 和 macOS 上的 Docker Desktop 生态依然是最成熟的。我们团队有 50 多个后端开发,如果为了所谓的“技术先进性”强制切换,光是处理环境兼容性问题就能把人累死。

为什么 CI/CD 也用 Docker?

我们的 Jenkins 流水线大量使用了 docker builddocker push。Docker 的缓存机制(Layer Caching)非常成熟。有一次,我们构建镜像,因为利用了 Dockerfile 的分层缓存,把构建时间从 6 分钟压缩到了 45 秒。如果换成纯 Containerd 的工具链,虽然也能做,但生态插件(比如 Kaniko 或 Buildah)在当时的场景下配置复杂度更高。

我们的架构是这样的:

这里有一个关键点:Docker 构建出来的镜像,Containerd 是完全可以直接运行的。 因为它们都遵循 OCI (Open Container Initiative) 标准。

为了证明 Docker 和 Containerd 的兼容性,我在测试环境做过一个实验,直接用 Docker 构建,然后手动导入 Containerd 运行:

# 1. 使用 Docker 构建镜像 (开发机或 CI 环境) docker build -t my-app:1.0.0 . # 2. 将镜像保存为 tar 包 docker save my-app:1.0.0 -o my-app.tar # 3. 将 tar 包拷贝到 K8s 节点 (模拟传输过程) scp my-app.tar user@node-1:/tmp/ # 4. 在 K8s 节点上使用 Containerd (ctr 命令) 加载镜像 # 注意:这里不需要 Docker 守护进程运行 ctr -n k8s.io images import /tmp/my-app.tar # 5. 验证镜像是否加载成功 ctr -n k8s.io images list | grep my-app

这个实验证明了镜像层面的解耦。我们之所以坚持 Docker,是因为它在“构建”和“交互”这两个环节依然是目前最好用的工具。至于 K8s 那边,只要底层是 Containerd,就完全符合 K8s 的要求。这种“分而治之”的策略,既保证了开发效率,又满足了生产环境的规范。

---

6. 安全左移:在Docker流水线中集成SBOM与镜像签名(Notary v2)

前阵子公司安全部门找我谈话,说我们的镜像仓库里发现了有漏洞的基础镜像,要求所有上线镜像必须提供 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,它已经对这类安全特性有了更好的支持。

具体的实施步骤是这样的:

以下是我写的一个简化版的 Jenkinsfile 片段,展示了如何在流水线中落地这些操作:

pipeline { agent any environment { IMAGE_NAME = "registry.company.com/order-service" IMAGE_TAG = "v1.0.0" // Cosign 的密钥,存储在 Jenkins 凭据中 COSIGN_PASSWORD = credentials('cosign-pass') } stages { stage('Build & Push') { steps { script { // 构建镜像 sh "docker build -t ${IMAGE_NAME}:${IMAGE_TAG} ." // 推送镜像到仓库 sh "docker push ${IMAGE_NAME}:${IMAGE_TAG}" } } } stage('Security Scan & SBOM') { steps { script { // 1. 生成 SBOM (使用 Syft) // 生成 spdx-json 格式,这是目前比较通用的标准 sh "syft ${IMAGE_NAME}:${IMAGE_TAG} -o spdx-json=sbom.json" // 2. 将 SBOM 附加到镜像或者上传到存储系统 // 这里简单演示上传到镜像仓库的附件(OCI 规范支持) // 实际中可能需要单独存储,或者作为证明(Attestation)附加 echo "SBOM generated at sbom.json" } } } stage('Sign Image') { steps { script { // 使用 Cosign 进行签名 // 注意:这里需要提前配置好 cosign 的密钥对 sh "cosign sign --key env://COSIGN_PRIVATE_KEY ${IMAGE_NAME}:${IMAGE_TAG}" } } } stage('Verify') { steps { script { // 在部署前验证签名 // 如果验证失败,流水线会直接报错终止 sh "cosign verify --key env://COSIGN_PUBLIC_KEY ${IMAGE_NAME}:${IMAGE_TAG}" echo "镜像签名验证通过,准备部署" } } } } }

如果不做这个会怎样?

去年有个著名的 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 的几点心里话

用了这么多年,我对这项技术也有些自己的看法:

* 适合上的场景:微服务架构、需要频繁扩缩容的业务、或者像我这样需要保证开发和生产环境一致的场景。Docker 确实能救命。

* 没必要上的场景:如果你只是跑一个静态的个人博客,或者一个单体的 PHP 应用,流量也不大,真没必要折腾。直接扔服务器上跑,维护成本反而低。

* 我的取舍:现在虽然 K8s 很火,但我手里的项目如果规模没到那个量级,我还是坚持用 Docker + Containerd 的组合。K8s 的复杂度太高,小团队养不起那个运维成本。

给正在学习的你

别光看文档,一定要在自己的电脑上把环境搞崩几次。比如试着把磁盘写满,或者模拟一下网络不通的情况。只有当你亲手解决过这些脏乱差的问题,你才算真正掌握了容器技术,而不是仅仅会敲几个 docker run 命令。