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
问题来了:
✅ 脚本完成了初始化
❌ 但 sleep
是 sh
的子进程
⚠️ 信号可能无法正确传递给 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
和入口脚本,我们可以更优雅地控制容器的初始化流程,提升容器的健壮性和可维护性。