把应用打包进Docker镜像,并不是容器化的终点。很多团队在完成镜像构建后,依旧会遇到环境切换失败、日志收集不到、配置文件被烧进镜像等问题。这些问题背后,往往不是Docker用错了,而是对12 Factor应用原则的理解还停留在表面。12 Factor由Heroku平台在云原生早期提出,用来约束软件即服务应用的工程实践;Docker则提供了轻量级运行时和镜像分发能力。两者结合的关键,不是让Docker去实现原则,而是借助Docker的工作流把原则固化为可重复的工程动作。

一、构建阶段:用镜像固化依赖,但不能固化配置
12 Factor的前三条原则分别要求一份基准代码、显式声明依赖、配置与代码严格分离。这三条在Docker构建阶段最容易走样。基准代码要求一个应用对应一个代码仓库,Dockerfile自然应该放在仓库根目录,并且只为一个应用服务。如果为了省事,在一个Dockerfile里通过多个CMD启动网关、后台任务和数据库客户端,镜像就变成了多个应用的混合体,后续扩展和回滚都会互相干扰。
依赖隔离在Docker中通常做得好一些,因为镜像本身就是一个隔离的运行时。但问题往往出现在构建缓存和依赖源上。正确的做法是先用独立的COPY步骤复制依赖清单,再执行安装命令,最后才复制业务代码。这样可以利用Docker层缓存,业务代码改动时不会重新下载全部依赖。下面的反例把配置直接写进了镜像,正例则只声明运行环境和依赖,配置全部留给运行时注入。
# 反例:配置被固化到镜像内 FROM python:3.11 COPY . /app RUN pip install -r /app/requirements.txt ENV DATABASE_URL=postgres://prod:secret@db:5432/mydb CMD ["python", "app.py"] # 正例:只声明依赖和入口,配置外部注入 FROM python:3.11 WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . CMD ["python", "app.py"]
配置管理是12 Factor中与Docker结合最紧密的一条。镜像一旦构建完成,就不应该因为目标环境不同而重新打包。数据库地址、缓存连接、第三方密钥等都应通过环境变量传入。对于本地开发和容器编排环境,可以使用docker run -e或者env_file来加载配置。如果配置文件数量较多,也可以把配置放在独立的config目录,但不要在镜像里保留任何包含真实环境值的副本。
二、运行阶段:单进程容器、端口绑定与优雅退出
12 Factor中的进程模型要求应用以无状态、可水平扩展的方式运行,而Docker容器恰好适合单进程模型。也就是说,一个容器只跑一个主进程,不要把SSH服务、cron守护进程和应用进程塞进同一个容器。主进程退出,容器就应当结束。如果应用依赖后台任务,应该用独立的容器运行同一个镜像的不同命令,而不是在容器内再拉起一个守护进程。
端口绑定原则要求应用自身监听端口,而不是依赖外部Web服务器注入。容器化后,应用应当读取PORT环境变量并监听对应端口,然后通过EXPOSE声明端口,再在运行或编排时映射到宿主机。这样同一个镜像可以在不同端口配置下启动,不修改代码。以Node.js应用为例,app.listen(process.env.PORT || 3000)就是典型做法。
易处理原则强调进程可以快速启动、优雅停止,并能应对突然崩溃。Docker在停止容器时会先发送SIGTERM信号,如果应用不处理,等待超时后才会发送SIGKILL。因此应用应当捕获SIGTERM,完成正在处理的请求、关闭数据库连接后再退出。可以把这段逻辑写进一个入口脚本,作为镜像的ENTRYPOINT,保证所有容器实例都具备优雅退出能力。
#!/bin/bash trap 'exit' SIGTERM python app.py & pid=$! wait $pid
以上脚本只是简单示例,实际应用需要在收到信号后触发框架内的优雅关闭流程。重要的是不要忽略信号处理,否则滚动发布时会频繁出现连接被强行中断的问题。
三、日志与管理任务:让容器只负责输出标准流
12 Factor把日志视为事件流,应用不需要关心日志存储和轮转。Docker原生支持将容器标准输出和标准错误收集到docker logs或日志驱动中。因此应用应直接输出到stdout和stderr,不要写到容器内的/var/log文件。很多传统应用习惯把日志写到文件,容器化后需要改造。如果短期无法修改应用代码,可以在Dockerfile中用符号链接把日志文件指向标准输出。
RUN ln -sf /dev/stdout /var/log/app.log RUN ln -sf /dev/stderr /var/log/app.err
管理进程这一条对应数据库迁移、数据修复、一次性脚本等任务。12 Factor要求这些任务与主应用共享同一代码库和依赖,通常以独立进程运行。在Docker环境中,使用同一个镜像配合不同命令是最自然的实现方式。例如docker run --rm myapp:latest python manage.py migrate,既复用了应用环境,又不会污染正常运行中的容器。
开发与生产环境等价是容器化最有优势的一条原则。同一个镜像可以从本地一直部署到生产环境,区别只在于环境变量和外部服务地址。如果发现需要为生产环境单独修改Dockerfile,通常意味着配置没有彻底外置。可以通过docker-compose.override.yml或者不同编排文件来覆盖运行参数,但镜像本身应保持唯一。
四、12条原则对照表:Docker不是银弹
总体上,Docker为12 Factor原则提供了很好的工程载体,但并没有替代这些原则。下表把12条原则与Docker工作流做一个对照,帮助识别哪些部分可以直接依赖容器能力,哪些还需要在应用设计和运维流程中刻意处理。
| 12 Factor原则 | Docker天然支持 | 需要刻意处理的部分 |
|---|---|---|
| 基准代码 | 一个仓库对应一个镜像 | 避免一个镜像打包多个应用 |
| 依赖 | 镜像层隔离依赖 | 合理利用缓存,锁定依赖版本 |
| 配置 | 环境变量注入 | 严格禁止配置写入镜像 |
| 后端服务 | 通过URL或DNS引用 | 不依赖本地文件或端口 |
| 构建发布运行分离 | 镜像即为构建产物 | 发布与配置分离 |
| 进程 | 单容器单进程 | 守护进程拆分为独立容器 |
| 端口绑定 | EXPOSE加端口映射 | 应用读取PORT变量 |
| 并发 | 水平扩展容器 | 应用无状态化 |
| 易处理 | Docker stop发送SIGTERM | 应用处理信号并优雅退出 |
| 开发生产等价 | 镜像随处运行 | 外部服务版本保持一致 |
| 日志 | stdout/stderr收集 | 应用不写日志文件 |
| 管理进程 | 一次性容器运行 | 复用同一镜像和代码库 |
从表中可以看出,Docker解决的是运行载体、依赖隔离和分发问题,但配置外置、优雅退出、无状态设计、日志输出方式仍然需要开发者在代码层面做出调整。把容器启动起来只是第一步,真正符合12 Factor的容器化应用,应该能够做到任意环境启动、任意实例销毁,而不影响整体业务连续性。
在实际落地时,建议团队把12 Factor审查纳入镜像构建的CI流水线。例如在构建完成后检查镜像层是否存在可疑配置文件,启动一个临时容器验证应用是否读取环境变量,发送停止信号观察退出时间是否符合预期。这些检查可以很轻量,却能在上线前暴露大多数与12 Factor原则相违背的问题。