在使用 Docker Compose 部署应用时,你是否也曾为“如何在主应用启动前优雅地运行数据库迁移或修复权限”而烦恼过?
过去,我们不得不编写各种一次性服务并用复杂的 depends_on 串联。而现在,随着 Docker Compose 新特性的发布,我们终于迎来了官方的 Init Containers 支持。本文将带你一探究竟,看看它如何帮我们精简 compose.yaml!
在使用 Docker Compose 部署应用时,我们经常会遇到一种很常见的需求:在应用启动之前,先执行一些初始化任务。
例如:
- 启动 Web 服务之前,先执行数据库迁移
- 容器以非 root 用户运行前,先修复挂载目录权限
- 应用正式启动前,先生成配置文件
- 先导入初始化数据,再启动主服务
过去我们通常会单独定义一个 migrate、init、setup 之类的一次性服务,然后通过 depends_on 和 condition: service_completed_successfully 控制启动顺序。
现在,Docker Compose 正式提供了更自然的写法:Init Containers。
根据 Docker 官方文档,该功能需要 Docker Compose 5.3.0 及以上版本。
Init Containers 本质上是 short-lived(短生命周期)容器,会在主服务容器启动之前依次执行;如果其中任何一步返回非 0 退出码,主服务就不会启动。
什么是 Init Containers?
Init Containers 可以理解为:
绑定在某个服务启动前执行的一组初始化步骤。
它们不是长期运行的服务,也不是后台任务,而是“跑完就退出”的临时容器。
Docker Compose 中这个能力通过 pre_start 生命周期钩子实现。根据官方文档说明,pre_start 中的每个步骤都会在独立的临时容器中运行,这些容器会在服务容器创建之后、真正启动之前执行。
一个最简单的例子:
1 | services: |
这段配置表示:
- Docker Compose 先创建
app服务容器; - 在
app真正启动之前,先运行./manage.py migrate; - 如果迁移命令成功退出(即返回状态码
0),app才会启动; - 如果迁移失败,
app不会启动。
这比过去单独写一个 migrate 服务清晰很多。
以前我们是怎么做的?
在 pre_start 出现之前,如果想表达“先执行迁移,再启动应用”,通常会这样写:
1 | services: |
这种写法虽然可以工作,但存在以下几个明显的问题:
- 概念混淆:
migrate本质上并不是一个真正的长期运行服务,它只是app启动前的一个初始化步骤。把它放在services顶层,会让整个 Compose 配置文件显得臃肿。 - 状态残留:任务执行完成后,它会作为已退出的服务留在
docker compose ps列表中,使得容器服务列表不够清爽。 - 编排繁琐:如果初始化步骤较多(比如先迁移数据库、再导入默认数据、最后生成配置文件),就需要定义多个一次性服务,并用一堆复杂的
depends_on串联起来。
使用新的 pre_start 特性后,我们可以直接把它们收纳进服务内部:
1 | services: |
Docker 官方也明确指出,pre_start 的优势在于:
- 初始化逻辑作为服务的从属步骤表达,不再伪装成并列的独立服务;
- 已完成的临时步骤不会出现在
docker compose ps中,保持环境整洁; - 多个步骤按顺序执行,无需再通过复杂的
depends_on逻辑进行链式串联。
示例一:启动前执行数据库迁移
这是最典型的使用场景。
1 | services: |
这里有两个关键点:
- 依赖关系:
app通过depends_on等待db容器进入service_healthy(健康)状态。 - 初始化时机:在
app真正启动前,会先在临时容器中执行./manage.py migrate。
若数据库迁移成功,app 主服务会继续启动;若失败,app 不会启动,且相关错误日志会直接输出在 docker compose up 中。
这种设计非常适合 Django、Rails、Laravel、Prisma、Drizzle、Alembic 等需要在服务运行前进行表结构迁移的项目。
示例二:修复 Volume 权限
另一个痛点是权限问题:当服务本身以非 root 用户运行,但挂载的 Docker named volume 默认归属于 root 权限时,容器可能会因为没有写入权限而崩溃。
过去,我们可能需要在 Dockerfile 里大动干戈,或者单独拉起一个辅助服务。现在,使用 pre_start 可以非常轻量地搞定:
1 | services: |
在这个例子中:
app容器以1000:1000非 root 用户运行。pre_start步骤指定了覆盖镜像为busybox,并以root权限执行chown,从而在主服务运行前顺利修正挂载目录所有权。
该场景在自托管/开源应用部署中极度实用,例如:
- 修复上传/缓存目录权限;
- 自动初始化数据目录结构;
- 在挂载路径中提前生成默认配置文件。
示例三:串联多个初始化步骤
pre_start 支持定义一个步骤列表,并会按照声明顺序串行执行。只有当前一个步骤成功退出(返回 0)时,才会继续运行下一个步骤;若中间某一步失败,后续步骤和主服务都将终止。
例如:
1 | services: |
整个启动流为:
- 等待数据库容器进入健康状态;
- 执行数据库表结构迁移;
- 导入系统初始数据;
- 生成动态配置文件;
- 拉起主应用容器。
需要注意的是,pre_start 中的多个步骤并不是事务。如果第二步执行成功、第三步失败,Docker Compose 不会自动回滚第二步。
因此,强烈建议将初始化脚本设计为可重复运行的幂等逻辑。
例如在导入数据时:
1 | -- 不推荐:每次无脑插入,可能会导致主键冲突或数据重复 |
pre_start 容器的运行规则
根据官方文档说明,pre_start 步骤拉起的临时容器具有以下行为特征:
- 独立容器:每个步骤都在自己专属的临时容器中运行。
- 默认继承:默认继承当前宿主服务的
image定义,无需重复指定镜像。 - 允许覆盖:可以通过
image字段指定其他的轻量工具镜像(如busybox)。 - 同网共用:会自动加入当前宿主服务所在的网络,因此可以直接访问通过
depends_on声明的其他依赖服务。 - 共享卷挂载:会自动共享当前服务定义的所有
volumes挂载。 - 强状态约束:必须返回退出码
0,下一个步骤及主服务才会继续运行。
这些特性为我们编写配置带来了很大便利。例如,若迁移命令就存放在应用镜像内,你只需直接声明命令,无需重复书写镜像名称:
1 | pre_start: |
如果需要使用辅助镜像(如 busybox)来做文件操作,直接在步骤内声明 image 即可进行覆盖:
1 | pre_start: |
因为共享了同一网络与 volumes,初始化容器在挂载目录中写入的动态配置文件、修正的权限或是写入的数据,在主容器拉起后会立刻生效。
它会每次 docker compose up 都执行吗?
答案是:不会。这是一个非常关键的细节。
根据 Docker 官方文档:
- 如果某个
pre_start步骤在上一次部署中已经成功执行过,且其定义没有发生任何变化,那么后续执行docker compose up时,Compose 会自动跳过该步骤。 - 当服务因
restart策略发生重启时,不会重新触发pre_start。 - 只有在以下情况下,初始化步骤才会再次执行:
- 步骤的配置定义发生变更;
- 上一次执行失败(退出码非 0);
- 执行
docker compose up --force-recreate强制重建服务。
由此可见,pre_start 偏向于“宿主服务生命周期中的一次性部署初始化”,而不是“每次容器进程启动前的强制拦截”。
提示: 如果你有一些需要在每次容器重启、扩容或进程拉起时都必定运行的逻辑(如动态读取最新环境变量),建议依然将其写入镜像的 entrypoint.sh 入口脚本中。
什么时候不适合使用 Init Containers?
Init Containers 虽然方便,但并非银弹。根据官方文档的建议,如果你的需求仅是注入静态配置文件或密钥,应当优先使用 Docker Compose 原生的 configs 和 secrets,因为它们支持直接挂载,并允许精细化配置权限和所属用户。
以下场景不建议使用 pre_start:
1. 静态配置文件挂载
对于不需要动态生成的固定配置文件,直接挂载即可,无需启动一个额外的初始化容器去写文件。
推荐方案:
1 | configs: |
2. 敏感凭证 (Secret) 注入
数据库密码、API 密钥等敏感数据切忌通过初始化脚本写入 volume 或文件系统,以防泄露。
推荐方案:
1 | secrets: |
3. 定时任务与后台运维任务
例如:
- 定期数据备份
- 定期清理缓存与临时文件
- 容器销毁后的退出清理工作
这些任务并不属于“主应用启动前必须阻塞完成的必要依赖”,因此不适合放进 pre_start。
当前的设计限制
由于该特性还相对较新,在使用时需要注意以下局限性:
- 非副本级执行:
pre_start是针对服务整体执行一次,而非针对每个容器副本单独运行(目前尚不支持per_replica: true配置)。 - 部分挂载类型不兼容:多副本共享的命名卷(Named Volume)和绑定挂载(Bind Mount)能够完美适配;但对于每个实例完全独立的匿名卷或
tmpfs挂载,pre_start临时容器写入的数据是无法同步到主容器的。 - 扩容时不触发:当你执行扩容命令时(例如
docker compose up --scale app=3),新创建的副本容器不会再次触发pre_start执行。它依然只遵循“配置变更/强制重建”的触发逻辑。
因此,如果你的某些初始化逻辑(如本地缓存预热)必须在每一个容器副本里运行,目前请不要依赖 pre_start。
与 Kubernetes Init Containers 的区别
从概念上看,两者的目的非常一致:都在主业务容器拉起前执行前置准备。
但实现和能力上有所差异:
- Kubernetes (K8s) 的 Init Containers 是 Pod 级别的核心一等公民,高度契合云原生分布式调度,且天然对多副本容器进行各自初始化。
- Docker Compose 的 Init Containers 则是通过
pre_start生命周期钩子实现的一种轻量级解决方案。它更契合单机部署、本地开发、小规模自托管(Self-Hosted)场景。
简单来说,Docker Compose 的 Init Containers 是为 Compose 场景量身定制的精简版初始化机制,小巧但十分够用。
最佳实践建议
在实际生产或开发环境使用 pre_start 时,建议遵循以下原则:
- 绝对的幂等性:初始化脚本一定要做到“多次执行,结果一致”,随时准备应对重试和重建场景。
- 严禁包含长驻进程:
pre_start的步骤必须是能在短时间内主动退出并返回0的任务,否则将无限期阻塞主服务的启动。 - 区分生产与开发环境的迁移策略:虽然在
pre_start中自动运行migrate很爽,但在生产环境中自动执行数据库迁移依然存在一定风险。建议重要线上项目通过独立的 CI/CD Pipeline 触发迁移。 - 封装复杂逻辑为脚本:如果初始化流程复杂(包含多步判断和环境准备),建议封装为独立脚本(如
./scripts/init.sh)放入容器,而在compose.yaml中只调用该脚本,避免配置文件过长。
1 | pre_start: |
总结
Docker Compose 的 Init Containers 终于解决了容器编排中长久以来的一个痛点:如何优雅地表达“启动前的必要前置步骤”。
我们可以将它的应用场景简单归纳为:
| 适合使用 ✅ | 不适合使用 ❌ |
|---|---|
数据库自动迁移 (migrate) |
长期运行的后台任务 |
| 初始数据导入与种子填充 | 定时备份/清理等 Cron 任务 |
| 修正宿主卷挂载(Volume)权限 | 纯静态的配置文件挂载 (使用 configs) |
| 动态生成局部配置 | 敏感凭证的安全管理 (使用 secrets) |
| 串联多个有顺序的前置校验步骤 | 必须针对每个副本 (Replica) 重复执行的初始化 |
最后,让我们用一张直观的对比来看看引入这一新特性后的变化:
1 | services: |
1 | services: |
这就是 Docker Compose Init Containers 的终极奥义 —— 让初始化步骤,优雅地回到它真正属于的地方。
评论