教程

Docker Compose 新增 Init Containers:终于不用再写一堆一次性初始化服务了

2026-07-04 #Docker#Docker Compose#Init Containers

在使用 Docker Compose 部署应用时,你是否也曾为“如何在主应用启动前优雅地运行数据库迁移或修复权限”而烦恼过?

过去,我们不得不编写各种一次性服务并用复杂的 depends_on 串联。而现在,随着 Docker Compose 新特性的发布,我们终于迎来了官方的 Init Containers 支持。本文将带你一探究竟,看看它如何帮我们精简 compose.yaml

在使用 Docker Compose 部署应用时,我们经常会遇到一种很常见的需求:在应用启动之前,先执行一些初始化任务。

例如:

  • 启动 Web 服务之前,先执行数据库迁移
  • 容器以非 root 用户运行前,先修复挂载目录权限
  • 应用正式启动前,先生成配置文件
  • 先导入初始化数据,再启动主服务

过去我们通常会单独定义一个 migrateinitsetup 之类的一次性服务,然后通过 depends_oncondition: 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
2
3
4
5
services:
app:
image: myapp:latest
pre_start:
- command: ["./manage.py", "migrate"]

这段配置表示:

  1. Docker Compose 先创建 app 服务容器;
  2. app 真正启动之前,先运行 ./manage.py migrate
  3. 如果迁移命令成功退出(即返回状态码 0),app 才会启动;
  4. 如果迁移失败,app 不会启动。

这比过去单独写一个 migrate 服务清晰很多。

以前我们是怎么做的?

pre_start 出现之前,如果想表达“先执行迁移,再启动应用”,通常会这样写:

1
2
3
4
5
6
7
8
9
10
11
services:
migrate:
image: myapp:latest
command: ["./manage.py", "migrate"]
restart: "no"

app:
image: myapp:latest
depends_on:
migrate:
condition: service_completed_successfully

这种写法虽然可以工作,但存在以下几个明显的问题:

  • 概念混淆migrate 本质上并不是一个真正的长期运行服务,它只是 app 启动前的一个初始化步骤。把它放在 services 顶层,会让整个 Compose 配置文件显得臃肿。
  • 状态残留:任务执行完成后,它会作为已退出的服务留在 docker compose ps 列表中,使得容器服务列表不够清爽。
  • 编排繁琐:如果初始化步骤较多(比如先迁移数据库、再导入默认数据、最后生成配置文件),就需要定义多个一次性服务,并用一堆复杂的 depends_on 串联起来。

使用新的 pre_start 特性后,我们可以直接把它们收纳进服务内部:

1
2
3
4
5
6
services:
app:
image: myapp:latest
pre_start:
- command: ["./manage.py", "migrate"]
- command: ["./manage.py", "loaddata", "fixtures.json"]

Docker 官方也明确指出,pre_start 的优势在于:

  1. 初始化逻辑作为服务的从属步骤表达,不再伪装成并列的独立服务;
  2. 已完成的临时步骤不会出现在 docker compose ps 中,保持环境整洁;
  3. 多个步骤按顺序执行,无需再通过复杂的 depends_on 逻辑进行链式串联。

示例一:启动前执行数据库迁移

这是最典型的使用场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
services:
app:
image: myapp:latest
depends_on:
db:
condition: service_healthy
pre_start:
- command: ["./manage.py", "migrate"]

db:
image: postgres:18
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: password
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s

这里有两个关键点:

  1. 依赖关系app 通过 depends_on 等待 db 容器进入 service_healthy(健康)状态。
  2. 初始化时机:在 app 真正启动前,会先在临时容器中执行 ./manage.py migrate

若数据库迁移成功,app 主服务会继续启动;若失败,app 不会启动,且相关错误日志会直接输出在 docker compose up 中。

这种设计非常适合 DjangoRailsLaravelPrismaDrizzleAlembic 等需要在服务运行前进行表结构迁移的项目。

示例二:修复 Volume 权限

另一个痛点是权限问题:当服务本身以非 root 用户运行,但挂载的 Docker named volume 默认归属于 root 权限时,容器可能会因为没有写入权限而崩溃。

过去,我们可能需要在 Dockerfile 里大动干戈,或者单独拉起一个辅助服务。现在,使用 pre_start 可以非常轻量地搞定:

1
2
3
4
5
6
7
8
9
10
11
12
13
services:
app:
image: myapp:latest
user: "1000:1000"
volumes:
- data:/data
pre_start:
- image: busybox
user: root
command: sh -c 'chown -R 1000:1000 /data'

volumes:
data:

在这个例子中:

  • app 容器以 1000:1000 非 root 用户运行。
  • pre_start 步骤指定了覆盖镜像为 busybox,并以 root 权限执行 chown,从而在主服务运行前顺利修正挂载目录所有权。

该场景在自托管/开源应用部署中极度实用,例如:

  • 修复上传/缓存目录权限;
  • 自动初始化数据目录结构;
  • 在挂载路径中提前生成默认配置文件。

示例三:串联多个初始化步骤

pre_start 支持定义一个步骤列表,并会按照声明顺序串行执行。只有当前一个步骤成功退出(返回 0)时,才会继续运行下一个步骤;若中间某一步失败,后续步骤和主服务都将终止。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
services:
app:
image: myapp:latest
depends_on:
db:
condition: service_healthy
pre_start:
- command: ["./manage.py", "migrate"]
- command: ["./manage.py", "loaddata", "fixtures.json"]
- command: ["./scripts/generate-config.sh"]

db:
image: postgres:18

整个启动流为:

  1. 等待数据库容器进入健康状态;
  2. 执行数据库表结构迁移;
  3. 导入系统初始数据;
  4. 生成动态配置文件;
  5. 拉起主应用容器。

需要注意的是,pre_start 中的多个步骤并不是事务。如果第二步执行成功、第三步失败,Docker Compose 不会自动回滚第二步。
因此,强烈建议将初始化脚本设计为可重复运行的幂等逻辑

例如在导入数据时:

1
2
3
4
5
6
-- 不推荐:每次无脑插入,可能会导致主键冲突或数据重复
INSERT INTO users (id, name) VALUES (1, 'admin');

-- 推荐:检测到冲突时跳过,保证幂等性
INSERT INTO users (id, name) VALUES (1, 'admin')
ON CONFLICT (id) DO NOTHING;

pre_start 容器的运行规则

根据官方文档说明,pre_start 步骤拉起的临时容器具有以下行为特征:

  • 独立容器:每个步骤都在自己专属的临时容器中运行。
  • 默认继承:默认继承当前宿主服务的 image 定义,无需重复指定镜像。
  • 允许覆盖:可以通过 image 字段指定其他的轻量工具镜像(如 busybox)。
  • 同网共用:会自动加入当前宿主服务所在的网络,因此可以直接访问通过 depends_on 声明的其他依赖服务。
  • 共享卷挂载:会自动共享当前服务定义的所有 volumes 挂载。
  • 强状态约束:必须返回退出码 0,下一个步骤及主服务才会继续运行。

这些特性为我们编写配置带来了很大便利。例如,若迁移命令就存放在应用镜像内,你只需直接声明命令,无需重复书写镜像名称:

1
2
pre_start:
- command: ["pnpm", "db:migrate"]

如果需要使用辅助镜像(如 busybox)来做文件操作,直接在步骤内声明 image 即可进行覆盖:

1
2
3
4
pre_start:
- image: busybox
user: root
command: sh -c 'chown -R 1000:1000 /data'

因为共享了同一网络与 volumes,初始化容器在挂载目录中写入的动态配置文件、修正的权限或是写入的数据,在主容器拉起后会立刻生效。

它会每次 docker compose up 都执行吗?

答案是:不会。这是一个非常关键的细节。

根据 Docker 官方文档:

  • 如果某个 pre_start 步骤在上一次部署中已经成功执行过,且其定义没有发生任何变化,那么后续执行 docker compose up 时,Compose 会自动跳过该步骤。
  • 当服务因 restart 策略发生重启时,不会重新触发 pre_start
  • 只有在以下情况下,初始化步骤才会再次执行:
    1. 步骤的配置定义发生变更;
    2. 上一次执行失败(退出码非 0);
    3. 执行 docker compose up --force-recreate 强制重建服务。

由此可见,pre_start 偏向于“宿主服务生命周期中的一次性部署初始化”,而不是“每次容器进程启动前的强制拦截”。

提示: 如果你有一些需要在每次容器重启、扩容或进程拉起时都必定运行的逻辑(如动态读取最新环境变量),建议依然将其写入镜像的 entrypoint.sh 入口脚本中。

什么时候不适合使用 Init Containers?

Init Containers 虽然方便,但并非银弹。根据官方文档的建议,如果你的需求仅是注入静态配置文件或密钥,应当优先使用 Docker Compose 原生的 configssecrets,因为它们支持直接挂载,并允许精细化配置权限和所属用户。

以下场景不建议使用 pre_start

1. 静态配置文件挂载

对于不需要动态生成的固定配置文件,直接挂载即可,无需启动一个额外的初始化容器去写文件。

推荐方案:

1
2
3
4
5
6
7
8
9
10
configs:
app_config:
file: ./config/app.yml

services:
app:
image: myapp:latest
configs:
- source: app_config
target: /app/config.yml

2. 敏感凭证 (Secret) 注入

数据库密码、API 密钥等敏感数据切忌通过初始化脚本写入 volume 或文件系统,以防泄露。

推荐方案:

1
2
3
4
5
6
7
8
9
secrets:
db_password:
file: ./secrets/db_password.txt

services:
app:
image: myapp:latest
secrets:
- db_password

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 时,建议遵循以下原则:

  1. 绝对的幂等性:初始化脚本一定要做到“多次执行,结果一致”,随时准备应对重试和重建场景。
  2. 严禁包含长驻进程pre_start 的步骤必须是能在短时间内主动退出并返回 0 的任务,否则将无限期阻塞主服务的启动。
  3. 区分生产与开发环境的迁移策略:虽然在 pre_start 中自动运行 migrate 很爽,但在生产环境中自动执行数据库迁移依然存在一定风险。建议重要线上项目通过独立的 CI/CD Pipeline 触发迁移。
  4. 封装复杂逻辑为脚本:如果初始化流程复杂(包含多步判断和环境准备),建议封装为独立脚本(如 ./scripts/init.sh)放入容器,而在 compose.yaml 中只调用该脚本,避免配置文件过长。
1
2
pre_start:
- command: ["./scripts/init.sh"]

总结

Docker ComposeInit Containers 终于解决了容器编排中长久以来的一个痛点:如何优雅地表达“启动前的必要前置步骤”

我们可以将它的应用场景简单归纳为:

适合使用 ✅ 不适合使用 ❌
数据库自动迁移 (migrate) 长期运行的后台任务
初始数据导入与种子填充 定时备份/清理等 Cron 任务
修正宿主卷挂载(Volume)权限 纯静态的配置文件挂载 (使用 configs)
动态生成局部配置 敏感凭证的安全管理 (使用 secrets)
串联多个有顺序的前置校验步骤 必须针对每个副本 (Replica) 重复执行的初始化

最后,让我们用一张直观的对比来看看引入这一新特性后的变化:

1
2
3
4
5
6
7
8
9
10
11
services:
migrate:
image: myapp:latest
command: ["./manage.py", "migrate"]
restart: "no"

app:
image: myapp:latest
depends_on:
migrate:
condition: service_completed_successfully

这就是 Docker Compose Init Containers 的终极奥义 —— 让初始化步骤,优雅地回到它真正属于的地方

评论
分享

评论