AIHub

Docker 与现代化部署:从容器到 CI/CD

进阶22 分钟读完2026-08-06#后端#Docker#CI/CD#部署#Kubernetes
Docker 与现代化部署:从容器到 CI/CD

每个后端工程师都听过这句话:「在我机器上是好的。」开发环境是 Mac,测试环境是 Ubuntu,生产环境是 CentOS,Python 版本差一个小数点、系统库差一个版本,程序就以千奇百怪的方式挂掉。Docker 要解决的正是这个环境不一致的千古难题。

它的思路借自航运业:货物形态千差万别(汽车、粮食、化学品),但只要装进标准集装箱,货轮、吊车、卡车都不用关心里面是什么。容器就是软件的集装箱——应用连同它的运行时、依赖库、配置一起打包,到任何机器上行为完全一致。

一、容器不是虚拟机:先建立正确的心智模型

很多人把容器理解成「轻量级虚拟机」,这个比喻容易误导。虚拟机是在硬件之上虚拟出完整的操作系统,每台 VM 都要跑一个完整的内核;而容器共享宿主机的 Linux 内核,只是用 namespace 隔离了进程视图(它看不到别人的进程、网络、文件系统),用 cgroup 限制了资源用量(CPU、内存)。

所以容器的本质是:一个被隔离和限流的普通进程。这解释了它的两个关键特性——启动是毫秒级的(不用引导操作系统),开销极小(没有第二个内核在跑)。代价是隔离性弱于 VM,且 Linux 容器只能跑在 Linux 内核上(Mac 和 Windows 上的 Docker 其实偷偷跑了一个 Linux 虚拟机)。

二、镜像分层:Docker 最精妙的设计

镜像分层像一叠透明胶片

镜像(image)是容器的「模板」,它不是一个完整的大文件,而是一层一层叠起来的只读层。每个 Dockerfile 指令(FROMCOPYRUN)都会产生一个新层,层与层之间是增量关系。

这个设计带来三个实际好处:

  • 复用:十个应用都基于 node:20 镜像,底层在磁盘上只存一份。
  • 缓存:构建时如果某层没变,直接复用缓存,秒级完成。
  • 分发:拉镜像时只下载本地没有的层。

还有第四个隐藏好处叫写时复制(Copy-on-Write):容器启动时只是在只读的镜像栈顶上加一层薄薄的可写层,容器里的一切改动都落在这层上,镜像本身永远不被修改。这就是「容器删了数据就没了」的原因,也是为什么从同一个镜像启动一百个容器几乎不占额外磁盘——它们共享整套只读层,各写各的可写层。

镜像存放在镜像仓库(Registry)里:Docker Hub 是公共仓库,公司内部通常自建 Harbor 或使用云厂商的仓库服务。docker pull / docker push 就是和仓库打交道,工作流和 Git 几乎是同构的——镜像名加标签(registry/app:v1.2.3)相当于仓库地址加版本号。

分层缓存直接决定了 Dockerfile 的写法:把不常变的指令放前面,常变的放后面。反面教材是先 COPY . 再装依赖——你改一行业务代码,所有依赖就得重装一遍:

# 好的写法:依赖层先固定下来
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./   # 只有锁文件变了才重装依赖
RUN npm ci --omit=dev
COPY . .                                  # 业务代码这层天天变,放最后
CMD ["node", "server.js"]

三、Dockerfile 最佳实践:多阶段构建与镜像瘦身

生产镜像的第一原则是。小意味着拉取快、启动快、攻击面小。一个没优化的 Node 镜像轻松超过 1GB,因为里面装着编译工具链、devDependencies 和源码。多阶段构建让编译发生在一个「脏」的构建镜像里,最终镜像只拿走编译产物:

# 第一阶段:构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 第二阶段:运行,只拷贝产物
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

其他几条经验法则:用 alpineslim 基础镜像(Go/Rust 编译出静态二进制后甚至可以用 FROM scratch,镜像只有几 MB);不要用 root 跑容器,加一个 USER 指令;记得写 .dockerignore(排除 node_modules.git、本地配置文件),否则构建上下文又大又可能泄密。

还有两个容易忽略的运行时习惯。一是日志写到标准输出,不要写进容器里的文件——Docker 会收集容器的 stdout/stderr,docker logs 和后续的日志平台都建立在这个约定上,写进文件等于把日志锁进了随时会销毁的容器。二是配置走环境变量,同一份镜像靠注入不同的环境变量就能跑在开发、测试、生产三个环境,这正是「一次构建,到处运行」的完整含义。

四、docker-compose:单机上的多容器编排

真实应用不是一个容器:Web 服务、数据库、缓存、队列,每个一个容器。手动 docker run 一长串参数很快失控,docker-compose.yml 用声明式 YAML 描述整组服务:

services:
  web:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://app:secret@db:5432/appdb
      - REDIS_URL=redis://cache:6379
    depends_on:
      - db
      - cache
  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=secret
  cache:
    image: redis:7-alpine
volumes:
  pgdata:

一条 docker compose up -d 就把整套环境拉起来,新人入职第一天就能跑通全栈——这在以前是半天起步的苦差。

这里值得说清两个直觉。容器网络:compose 会自动建一个内部网络,服务之间用服务名当域名互相访问——上面 web 容器里的 db:5432 能通,靠的就是这个内置 DNS,所以编排文件里不需要写死任何 IP。端口映射"3000:3000" 的含义是「把宿主机的 3000 端口转发到容器的 3000 端口」;容器之间通信根本不需要映射端口,映射只是为了让你能从宿主机(比如浏览器)访问。数据库和缓存不映射端口反而是好事——它们不该暴露到容器网络之外。

注意 compose 适合单机开发和测试;跨多机的生产编排是 Kubernetes 的领土。

五、CI/CD:让部署变成一条流水线

CI(持续集成)指代码每次推送都自动构建、跑测试;CD(持续交付/部署)指通过验证的制品自动或一键发布。它的价值不只是省人力,而是把发布从一次胆战心惊的仪式变成日常操作——发布越小越频繁,每次的风险越低,出问题越容易定位。

一条典型流水线只有四步:代码推送 → 安装依赖并跑测试 → 构建镜像并推送到仓库 → 通知生产环境拉取新版本。用 GitHub Actions 写出来很直观:

name: ci
on:
  push:
    branches: [main]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test
      - run: docker build -t registry.example.com/app:$(git rev-parse --short HEAD) .
      - run: docker push registry.example.com/app:$(git rev-parse --short HEAD)

关键心智:流水线的产物是不可变的镜像,而不是在某台服务器上原地改代码。任何环境跑的都是同一个镜像哈希,「测试通过但生产不一样」在机制上被消灭了。

发布策略也值得知道名字:滚动发布逐批替换旧实例,无停机但新旧版本短暂共存(接口要兼容);蓝绿部署同时维护新旧两套环境,切换只是改负载均衡的指向,回滚就是切回去,代价是资源翻倍;金丝雀发布先放 5% 流量到新版本,观察指标无恙再全量——本质是用真实流量做最后一次验证。小团队从滚动发布起步即可,另外两种知道何时该用上。

六、Kubernetes:你只需要先建立三个直觉

K8s 是个庞大的系统,但入门阶段只需理解三个概念的分工:

  • Pod:最小的部署单位,可以理解成「一台逻辑主机」,里面跑一个(偶尔几个)容器。Pod 是易逝的——挂了就被扔掉重建,IP 会变。
  • Deployment:Pod 的「期望状态管理员」。你声明「要 3 个副本」,它就不停地让现实逼近这个声明:Pod 挂了补一个,发布新版本就滚动地一批批替换。
  • Service:一组 Pod 前面的稳定入口。Pod 生生死死 IP 乱变,Service 提供一个固定名字和 IP,把流量负载均衡到当前健康的 Pod 上。

一句话串起来:你告诉 Deployment 你要什么,它管 Pod 的生老病死,Service 负责让别人稳定地找到它们。至于调度、存储、网络策略,用到再学不迟。

再往深说一层,K8s 与 docker-compose 的本质差异是声明式 + 控制器循环:compose 是「执行一串命令把环境搭起来」,K8s 是「你提交一份期望状态,系统永不停歇地对比现实与期望并纠偏」。机器宕机、容器 OOM、磁盘写满,这些在 K8s 眼里都只是「现实偏离了期望」,由控制器自动修复。这种「自愈」能力才是它在生产环境不可替代的原因,也是学习它的主要价值所在。

常见误区

  • 把容器当虚拟机用:在容器里跑 systemd、塞五六个进程、SSH 进去改文件。正确姿势是一个容器一个主进程,改东西靠重建镜像。
  • 镜像用 latest 标签上生产:没人知道 latest 到底是哪次构建,回滚无从下手。永远用具体版本或 git commit 哈希打标签。
  • 在容器里存数据:容器文件系统随容器销毁。数据库数据、上传的文件必须挂 volume 或走对象存储。
  • 为了 K8s 而 K8s:三个人的团队、一个日活几千的应用,docker-compose 或 PaaS 完全够用。K8s 解决的是几十上百个服务的编排复杂度,复杂度本身是成本。

小结

Docker 的本质是共享内核的隔离进程;镜像分层带来缓存与复用,决定了 Dockerfile「稳定在前、易变在后」的写法;多阶段构建让镜像只携带运行所需;compose 编排单机多容器;CI/CD 让「构建不可变镜像、自动测试、自动发布」成为肌肉记忆;K8s 先用 Pod/Deployment/Service 三个概念建立地图。到这里,你已经能把一个应用标准化地交付到任何环境——下一篇我们进入分布式世界:当一台机器装不下你的系统时会发生什么。


系列导航:上一篇《消息队列:异步、削峰与可靠投递》(/content/backend-expert-09-message-queue) · 下一篇《分布式与微服务:CAP、拆分与治理》(/content/backend-expert-11-microservices)

相关教程

Docker 与现代化部署:从容器到 CI/CD | AIHub