1. 简介

Docker 是一个用于创建、配置、组织和运行容器的平台。在容器内部,初始进程(PID 1)扮演着容器中所有进程的“祖先进程”角色。这个进程决定了容器的生命周期,一旦它终止,整个容器通常也会随之终止。此外,Docker 会在特定情况下向该进程发送信号(如 SIGTERM),终止该进程通常意味着终止整个容器。

在本教程中,我们将探讨如何使用脚本来初始化容器,同时让真正需要运行的主进程成为 PID 1。我们将依次了解:

  • 容器初始化的基本机制
  • 使用脚本进行初始化
  • 使用 exec 替换脚本进程以优化容器启动
  • 构建一个更通用的入口脚本

我们使用的测试环境为 Debian 12(Bookworm)和 GNU Bash 5.2.15,除非特别说明,代码在大多数 POSIX 兼容环境中都应能正常运行。


2. 容器初始化机制

我们先从容器初始化的基本流程入手。

首先创建一个最简单的 Dockerfile

$ cat Dockerfile
# syntax=docker/dockerfile:1

FROM debian:latest
CMD ["sleep", "666"]

接着构建镜像:

$ docker build --tag xnit:latest .

然后运行容器:

$ docker run --rm --detach xnit:latest

使用 docker ps 查看容器状态:

$ docker ps
CONTAINER ID  IMAGE        COMMAND      CREATED         STATUS        PORTS  NAMES
9f751e202bfb  xnit:latest  "sleep 666"  10 seconds ago  Up 9 seconds         youthful_shannon

可以看到,容器正在运行 sleep 666 命令。由于使用了 --rm 参数,一旦该命令执行完毕,容器将被自动删除。

为了简化流程,可以使用以下单条命令完成构建并运行:

$ docker run --rm --detach $(docker build --no-cache --quiet --file=- . <<< '
# syntax=docker/dockerfile:1

FROM debian:latest
CMD ["sleep", "666"]
')

但这种方式的问题在于,容器只是运行了一个临时命令,完成后即销毁,实际用途有限。


3. 使用脚本初始化容器

更常见的做法是使用一个脚本来完成容器的初始化工作。

我们来看一个简单的初始化脚本示例:

$ cat docker-entrypoint.sh
#!/usr/bin/env sh

echo 'Initialization...' > xnit
sleep 666

该脚本模拟了初始化操作,写入了一个文件,并执行了 sleep 666

接下来,在 Dockerfile 中使用该脚本作为容器的启动命令:

$ docker run --rm --detach $(docker build --quiet --file=- . <<< '
# syntax=docker/dockerfile:1

FROM debian:latest
WORKDIR /xnit
COPY ./docker-entrypoint.sh .
CMD ["sh", "/xnit/docker-entrypoint.sh"]
')

查看容器状态:

$ docker ps
CONTAINER ID  IMAGE         COMMAND                 CREATED         STATUS        PORTS  NAMES
fcc2cf8083dd  1e79a51d26d4  "sh /xnit/docker-ent…"  10 seconds ago  Up 9 seconds         modest_engelbart

此时,容器的主进程是 sh,它运行了 docker-entrypoint.sh

我们可以通过 ps 查看进程树:

$ docker exec fcc2cf8083dd sh -c 'apt-get update && apt-get install --yes procps && ps -HA'
[...]
PID TTY          TIME CMD
  8 ?        00:00:00 sh
197 ?        00:00:00   ps
  1 ?        00:00:00 sh
  7 ?        00:00:00   sleep

问题来了:

✅ 脚本完成了初始化
❌ 但 sleepsh 的子进程
⚠️ 信号可能无法正确传递给 sleep
⚠️ 容器主进程不是最终要运行的服务


4. 使用 exec 替换进程

在 Bash 或其他 shell 中,exec 是一个特殊命令,它不会创建子进程,而是替换当前 shell 进程本身

这意味着:

✅ 原始 shell 进程被替换
✅ 新进程成为 PID 1
✅ 信号将直接发送给该进程

我们修改脚本如下:

$ cat docker-entrypoint.sh
#!/usr/bin/env sh

echo 'Initialization...' > xnit
exec sleep 666

再次构建并运行容器:

$ docker run --rm --detach $(docker build --quiet --file=- . <<< '
# syntax=docker/dockerfile:1

FROM debian:latest
WORKDIR /xnit
COPY ./docker-entrypoint.sh .
CMD ["sh", "/xnit/docker-entrypoint.sh"]
')

查看进程树:

$ docker exec 942bb64858b6 sh -c 'apt-get update && apt-get install --yes procps && ps -HA'
[...]
 PID TTY          TIME CMD
2154 ?        00:00:00 sh
2253 ?        00:00:00   ps
   1 ?        00:00:00 sleep

sleep 成为了 PID 1
✅ 所有信号将直接发送给它
✅ 容器生命周期由它控制


5. 动态指定启动命令

为了让脚本更通用,我们可以让它接收参数,并通过 exec "$@" 动态执行。

修改脚本如下:

$ cat docker-entrypoint.sh
#!/usr/bin/env sh

echo 'Initialization...' > xnit
exec "$@"

构建并运行容器时传入参数:

$ docker run --rm --detach $(docker build --quiet --file=- . <<< '
# syntax=docker/dockerfile:1

FROM debian:latest
WORKDIR /xnit
COPY ./docker-entrypoint.sh .
CMD ["sh", "/xnit/docker-entrypoint.sh", "sleep", "666"]
')

这样,我们就可以通过修改 CMD 参数来控制容器最终运行的命令,而无需修改脚本本身。


6. 总结

在本教程中,我们探讨了如何使用脚本来初始化 Docker 容器,并通过 exec 替换脚本进程,使主服务成为容器的 PID 1。

关键点总结如下:

特性 说明
✅ PID 1 主进程应为容器的 PID 1
✅ 信号处理 保证主进程能接收到 Docker 发送的信号
✅ 简洁性 避免多余的 shell 进程
✅ 可配置性 使用 exec "$@" 支持动态命令传参

通过合理使用 exec 和入口脚本,我们可以更优雅地控制容器的初始化流程,提升容器的健壮性和可维护性。


原始标题:Docker Container Initialization and Script Process Replacement With exec