Docker 容器为什么 stop 半天停不下来?讲透 PID 1、SIGTERM、SIGKILL、僵尸进程与 tini

Docker 里有一个非常典型的现象。

你执行:

bash 复制代码
docker stop myapp

然后终端像卡住了一样。

等了好几秒:

text 复制代码
...

最后 Container 才终于停下来。

甚至你再看:

bash 复制代码
docker ps -a

发现:

text 复制代码
Exited (137)

于是很多人的第一反应是:

137?是不是又 OOM 了?

不一定。

我们上一篇刚讲过:

text 复制代码
Exit 137
=
进程收到 SIGKILL

OOM 只是产生 SIGKILL 的原因之一。

还有一种非常常见的情况:

text 复制代码
docker stop
↓
发送 SIGTERM
↓
应用根本没有正确收到
↓
一直不退出
↓
Docker 等待超时
↓
发送 SIGKILL
↓
Exit 137

所以一个 Container:

能不能优雅停止

其实和:

text 复制代码
PID 1

ENTRYPOINT

CMD

Shell Form

Exec Form

SIGTERM

SIGKILL

Zombie Process

Init Process

tini

这些 Linux 进程概念密切相关。

今天继续围绕:

text 复制代码
https://github.com/Rodert/DockerHub

把 Docker Container 生命周期里很容易被忽略的一块彻底讲清楚。

如果只想先记住一句话:

Docker 容器真正管理的是 PID 1;PID 1 不正确处理信号,或者启动脚本没有把信号传给真正业务进程,就很容易出现 docker stop 等十秒后被强杀的问题。

Docker 官方当前也明确说明:docker stop 会先向容器主进程发送停止信号,默认通常是 SIGTERM;Linux Container 默认等待 10 秒,如果还没有退出,再发送 SIGKILL 强制终止。


一、先看一个最经典的问题

Dockerfile:

Dockerfile 复制代码
FROM node:24-slim

WORKDIR /app

COPY . .

CMD node server.js

看起来:

text 复制代码
非常正常。

构建:

bash 复制代码
docker build \
  -t myapp:v1 \
  .

启动:

bash 复制代码
docker run \
  -d \
  --name myapp \
  myapp:v1

业务也完全正常。

但是:

bash 复制代码
time docker stop myapp

你可能发现:

text 复制代码
real    0m10.xxxs

每次几乎都:

卡 10 秒左右。

这往往不是巧合。


二、Docker stop 到底做了什么?

很多人理解:

bash 复制代码
docker stop myapp

就是:

text 复制代码
立即杀 Container。

实际上不是。

正常流程更接近:

text 复制代码
docker stop
↓
找到 Container PID 1
↓
发送停止 Signal
↓
等待应用自己退出
↓
如果正常退出
结束

如果一直不退出:

text 复制代码
等待超时
↓
SIGKILL
↓
强制终止

Linux Container 当前默认停止等待时间通常为:

text 复制代码
10 秒。

Docker 官方当前文档就是这样定义 docker stop 的。


三、默认先发送什么信号?

通常:

SIGTERM。

Signal Number:

text 复制代码
15

它的意思不是:

现在立刻给我死。

更接近:

系统准备结束你了,请尽快做清理然后退出。

应用收到:

text 复制代码
SIGTERM

以后可以:

text 复制代码
停止接受新请求

等待正在处理的请求完成

关闭数据库连接

Flush Buffer

写最后的日志

释放文件锁

关闭 Worker

然后 Exit 0

这就是:

Graceful Shutdown。


四、为什么不能一上来就 SIGKILL?

因为:

text 复制代码
SIGKILL

完全不给应用处理机会。

例如 API 正在:

text 复制代码
写订单

数据库事务:

text 复制代码
还没完成。

你突然:

text 复制代码
SIGKILL。

进程:

当场消失。

应用根本没有机会:

text 复制代码
停止新流量

提交/回滚事务

Flush 日志

释放资源。

所以 Docker:

先礼后兵。

先:

text 复制代码
SIGTERM。

实在不走:

text 复制代码
SIGKILL。

五、这就是 Grace Period

比如:

bash 复制代码
docker stop \
  --timeout 30 \
  myapp

意味着:

text 复制代码
SIGTERM
↓
最多等 30 秒
↓
仍然不退出
↓
SIGKILL

Docker 当前支持:

text 复制代码
-t
--timeout

修改停止等待时间;如果设为 -1,Daemon 会无限等待进程退出。


六、Compose 也可以配置

比如:

yaml 复制代码
services:

  api:

    image:
      mycompany/api:1.8.3

    stop_grace_period:
      30s

对于:

text 复制代码
HTTP 服务

Worker

数据库

消息消费者

需要时间:

text 复制代码
完成当前工作

时:

很有价值。

但是:

把 10 秒改成 60 秒,不等于修复了信号问题。

如果程序:

text 复制代码
压根收不到 SIGTERM

你只是:

text 复制代码
从等 10 秒
变成等 60 秒。

最后:

text 复制代码
还是 SIGKILL。

七、真正的核心:PID 1

Linux Container 启动以后:

里面一定有一个:

text 复制代码
PID 1。

你可以:

bash 复制代码
docker exec \
  myapp \
  ps -ef

可能看到:

text 复制代码
PID   CMD

1     node server.js

这很理想。

业务程序:

自己就是 PID 1。


八、但也可能看到

text 复制代码
PID   CMD

1     /bin/sh -c node server.js
7     node server.js

现在:

text 复制代码
PID 1

是谁?

不是:

text 复制代码
Node。

而是:

text 复制代码
/bin/sh。

这时候 Docker:

text 复制代码
docker stop

首先把信号:

发给 PID 1 的 Shell。

而不是:

text 复制代码
PID 7 的 Node。

问题就开始出现了。


九、为什么会变成 Shell?

核心就在 Dockerfile 两种写法。

第一种:

Shell Form。

Dockerfile 复制代码
CMD node server.js

Docker 会大致变成:

text 复制代码
/bin/sh -c "node server.js"

也就是说:

text 复制代码
PID 1
=
/bin/sh

业务:

text 复制代码
node

是:

Shell 的子进程。

Docker 官方当前 Build Check 和 Dockerfile Reference 都明确提醒:Shell Form 会把命令作为 /bin/sh -c 的子进程执行,这个 Shell 默认不会把 Unix Signal 正确传递给真正的业务进程。


十、另一种:Exec Form

Dockerfile 复制代码
CMD ["node", "server.js"]

这是:

JSON / Exec Form。

最终:

text 复制代码
PID 1
=
node server.js。

Docker:

text 复制代码
SIGTERM

直接:

text 复制代码
发给 Node。

中间没有:

text 复制代码
/bin/sh。

所以生产 Dockerfile:

CMD / ENTRYPOINT 通常优先使用 Exec Form。


十一、这两个写法看起来只差几个字符

不推荐:

Dockerfile 复制代码
CMD node server.js

推荐:

Dockerfile 复制代码
CMD ["node", "server.js"]

表面:

text 复制代码
只是写法不同。

实际上影响:

text 复制代码
PID 1

Signal Handling

Graceful Shutdown

进程树

Container Stop 行为。

所以 Docker 当前甚至有专门的:

text 复制代码
JSONArgsRecommended

Build Check 来提醒这个问题。


十二、ENTRYPOINT 也是一样

不推荐:

Dockerfile 复制代码
ENTRYPOINT node server.js

推荐:

Dockerfile 复制代码
ENTRYPOINT [
  "node",
  "server.js"
]

Docker 官方当前明确指出,如果 ENTRYPOINT 使用 Shell Form,真正 executable 不会成为 PID 1,也无法直接收到 docker stop 发送给容器的 Unix Signal。


十三、怎么快速验证自己有没有这个问题?

启动 Container 后:

bash 复制代码
docker exec \
  myapp \
  ps -o pid,ppid,comm,args

如果看到:

text 复制代码
PID   PPID   COMMAND

1     0      sh
7     1      node

说明:

text 复制代码
Shell
↓
Node

Node:

不是 PID 1。


如果:

text 复制代码
PID   PPID   COMMAND

1     0      node

说明:

Node 本身就是主进程。

第一种:

text 复制代码
值得重点检查 Signal Forwarding。

十四、可以直接测试 SIGTERM

比如:

bash 复制代码
docker kill \
  --signal=SIGTERM \
  myapp

这个命令:

text 复制代码
不会默认用 SIGKILL。

而是明确:

text 复制代码
向 Container 主进程发送 SIGTERM。

Docker 当前 docker kill 支持通过 --signal 指定 Signal。

然后看:

bash 复制代码
docker logs \
  -f \
  myapp

应用有没有输出:

text 复制代码
Received SIGTERM

Shutting down...

Server closed

如果:

text 复制代码
完全没反应。

Signal Handling:

很可能有问题。


十五、应用本身也必须处理 SIGTERM

即使:

text 复制代码
你的程序已经是 PID 1。

也不代表:

text 复制代码
一定会优雅退出。

Docker 官方当前还特别提醒:

Linux 中 PID 1 的 Signal 行为具有特殊性,一些默认终止型 Signal 如果应用没有显式处理,PID 1 的行为可能与普通进程不同。

所以真正稳妥的做法:

应用显式处理 SIGTERM。


十六、Go 怎么处理?

Go 可以:

go 复制代码
ctx, stop := signal.NotifyContext(
    context.Background(),
    syscall.SIGINT,
    syscall.SIGTERM,
)
defer stop()

<-ctx.Done()

log.Println("shutting down")

HTTP Server:

go 复制代码
shutdownCtx, cancel := context.WithTimeout(
    context.Background(),
    10*time.Second,
)
defer cancel()

server.Shutdown(shutdownCtx)

流程:

text 复制代码
SIGTERM
↓
停止接新请求
↓
等待已有请求结束
↓
退出。

这就是:

真正的 Graceful Shutdown。


十七、Node.js 同样要处理

例如:

javascript 复制代码
process.on("SIGTERM", async () => {

  console.log("SIGTERM received");

  server.close(async () => {

    await database.close();

    process.exit(0);

  });

});

Docker:

text 复制代码
SIGTERM

到了:

text 复制代码
Node PID 1。

Node:

text 复制代码
执行清理。

然后:

text 复制代码
主动退出。

这才是完整链路。


十八、Java 也一样

Spring Boot 等现代 Framework:

text 复制代码
通常已经具备一定 Shutdown Lifecycle。

但是你的:

text 复制代码
自定义线程

Executor

Kafka Consumer

异步任务

如果设计不正确:

text 复制代码
JVM 仍然可能长时间不退出。

所以看到:

text 复制代码
Java Container 每次 stop 都等满 10 秒

不能简单说:

Java 就是慢。

应该看:

text 复制代码
SIGTERM 是否收到

Spring Context 是否关闭

线程池是否 Shutdown

非 Daemon Thread 是否卡住。

十九、Python 也需要注意

比如:

python 复制代码
signal.signal(
    signal.SIGTERM,
    handle_shutdown,
)

Web Framework:

text 复制代码
Gunicorn

Uvicorn

Celery

各自也有自己的:

text 复制代码
Signal / Worker Lifecycle。

所以 Docker:

只是负责发 Signal。

应用怎么响应:

是 Application Responsibility。


二十、为什么使用 npm start 有时也会让信号更复杂?

Dockerfile:

Dockerfile 复制代码
CMD ["npm", "start"]

进程可能变成:

text 复制代码
PID 1
npm
↓
node server.js

Docker:

text 复制代码
SIGTERM

先:

text 复制代码
发给 npm。

npm 是否正确把:

text 复制代码
Signal

传给:

text 复制代码
node

取决于具体进程链和版本行为。

如果你的目标:

text 复制代码
就是运行 server.js

生产 Image 中通常更直接:

Dockerfile 复制代码
CMD ["node", "server.js"]

减少:

中间进程层级。


二十一、真正的问题其实是"谁才是 Container 的主进程?"

Container 生命周期:

text 复制代码
不是看所有进程。

关键:

PID 1。

PID 1 退出:

text 复制代码
Container 就结束。

哪怕里面:

text 复制代码
还有某些后台进程。

Container 生命周期仍由:

Main Process

决定。

这也是为什么:

text 复制代码
一个 Container 通常应该围绕一个主要服务进程设计。

二十二、一个错误启动脚本

例如:

sh 复制代码
#!/bin/sh

node server.js &

echo "started"

while true
do
    sleep 60
done

Container:

text 复制代码
PID 1
=
这个 Shell Script。

Node:

text 复制代码
后台子进程。

Docker Stop:

text 复制代码
SIGTERM → Shell。

如果 Shell 没有:

text 复制代码
trap
Signal Forwarding

Node:

可能根本不知道容器正在结束。

这就是:

text 复制代码
Wrapper Script

经常埋的坑。


二十三、正确 Shell Wrapper 最核心的是 exec

比如:

sh 复制代码
#!/bin/sh

set -e

echo "running migrations..."

./app migrate

echo "starting app..."

exec ./app server

最后:

text 复制代码
exec

特别重要。

为什么?

因为:

text 复制代码
exec ./app server

不是:

text 复制代码
Shell 再创建一个 Child。

而是:

用应用进程直接替换当前 Shell。

最终:

text 复制代码
PID 1

从:

text 复制代码
entrypoint.sh

变成:

text 复制代码
./app server。

Docker Signal:

text 复制代码
直接进应用。

二十四、没有 exec 会怎样?

sh 复制代码
#!/bin/sh

./app server

最终进程:

text 复制代码
PID 1:
sh

PID 7:
app

有 exec:

text 复制代码
PID 1:
app

所以以后看 Docker EntryPoint Script:

最后一行有没有 exec "$@"

非常值得关注。


二十五、经典 EntryPoint 写法

sh 复制代码
#!/bin/sh

set -e

echo "prepare..."

mkdir -p /app/data

echo "start..."

exec "$@"

Dockerfile:

Dockerfile 复制代码
COPY docker-entrypoint.sh \
  /usr/local/bin/

ENTRYPOINT [
  "docker-entrypoint.sh"
]

CMD [
  "./app",
  "server"
]

最终:

text 复制代码
entrypoint
↓
做初始化
↓
exec ./app server
↓
App 成为 PID 1。

这就是:

非常标准的模式。


二十六、什么时候 Shell Form 还是有价值?

比如你确实需要:

text 复制代码
环境变量扩展

管道

&&

||

重定向。

例如:

Dockerfile 复制代码
CMD echo "$APP_NAME" \
  && ./app

Shell Form:

text 复制代码
写起来方便。

但 Docker 官方当前建议,如果需要 Shell 能力,可以显式使用 Wrapper Script,同时清楚处理 PID 1 和 Signal Forwarding。

也就是说:

Shell 不是不能用。

而是:

用了 Shell,就要知道多出来了一层进程管理责任。


二十七、接下来是第二个问题:僵尸进程

很多开发者第一次看到:

text 复制代码
Zombie Process

会觉得:

进程都死了,怎么还会有僵尸?

Linux Process:

text 复制代码
结束

以后,

Kernel 仍然会保留少量:

text 复制代码
Exit Status
Process Metadata

让父进程调用:

text 复制代码
wait()
waitpid()

获取。

父进程完成:

text 复制代码
wait

以后:

text 复制代码
Process Entry

才真正被清理。


二十八、如果父进程不 wait 呢?

子进程已经:

text 复制代码
退出。

但父进程:

text 复制代码
从来不回收。

Process Table 中:

text 复制代码
留下一个 Zombie。

例如:

text 复制代码
STAT
Z

或者:

text 复制代码
<defunct>

这就是:

Zombie Process。


二十九、为什么 Container 特别容易碰到这个问题?

传统 Linux:

text 复制代码
PID 1

通常是:

text 复制代码
systemd

init

它承担一个非常重要的职责:

回收孤儿和僵尸进程。

但 Container:

text 复制代码
PID 1

可能直接是:

text 复制代码
node

python

java

你的 Go 程序。

这些应用:

不一定是为充当完整 Linux init 设计的。

如果它:

text 复制代码
fork Child Process

但:

text 复制代码
没有正确 wait

Zombie:

text 复制代码
可能逐渐积累。

三十、为什么 Zombie 多了会出问题?

一个 Zombie:

text 复制代码
几乎不占什么 RAM。

但它仍然:

text 复制代码
占 Process Table Entry。

如果你的应用:

text 复制代码
不断创建 Child
↓
不断产生 Zombie

最终:

text 复制代码
PID 数

越来越多。

可能导致:

text 复制代码
fork failed

resource temporarily unavailable

无法创建新进程。

所以:

Zombie 不是内存问题,而是进程生命周期问题。


三十一、怎么检查 Zombie?

进入 Container:

bash 复制代码
docker exec \
  myapp \
  ps -eo \
  pid,ppid,state,comm

查:

text 复制代码
STATE = Z

或者:

bash 复制代码
docker exec \
  myapp \
  ps aux

看到:

text 复制代码
<defunct>

就值得关注。


三十二、这就是 --init 存在的意义之一

Docker 当前支持:

bash 复制代码
docker run \
  --init \
  myimage

官方当前定义非常清楚:

text 复制代码
--init

会在 Container 中运行一个轻量 Init Process,

负责:

text 复制代码
Signal Forwarding

Reaping Zombie Processes。

Docker 默认安装中的 docker-init 当前由 tini 支撑。


三十三、加 --init 后进程树会发生什么?

原来:

text 复制代码
PID 1
App

加:

text 复制代码
--init

以后:

text 复制代码
PID 1
tini / docker-init
        │
        ↓
PID 7
App

现在:

text 复制代码
Docker
↓
Signal
↓
tini
↓
App

同时子进程结束:

text 复制代码
tini

负责:

Reap。

这就是小型 init 的核心价值。


三十四、Compose 也支持 init

例如:

yaml 复制代码
services:

  api:

    image:
      mycompany/api:1.8.3

    init:
      true

这个配置很适合:

text 复制代码
应用会启动 Child Process

运行 Shell / Worker

浏览器自动化

FFmpeg

ImageMagick

各种外部 Command

的服务。

因为这些应用:

比纯单进程 HTTP Server 更容易涉及 Child Lifecycle。


三十五、是不是所有 Container 都必须 init: true?

不一定。

如果你的:

text 复制代码
Go API

就是单一进程,

自己:

text 复制代码
正确处理 SIGTERM

几乎不 spawn Child Process。

那:

text 复制代码
直接作为 PID 1

完全可以。

--init:

是工具,不是强制仪式。


三十六、什么时候特别值得使用 init?

比如:

text 复制代码
Node 应用会运行 ffmpeg

或者:

text 复制代码
Python 服务不断 subprocess.Popen()

或者:

text 复制代码
浏览器自动化启动 Chrome Child Processes

或者:

text 复制代码
Worker 会 fork 大量外部命令。

这时候:

text 复制代码
Zombie Reaping

就非常值得关注。

我会更倾向:

yaml 复制代码
init:
  true

三十七、为什么不是直接在 Container 里跑 systemd?

因为绝大多数应用 Container:

text 复制代码
不需要完整 Init System。

为了:

text 复制代码
转发 Signal

回收 Zombie

装一整套:

text 复制代码
systemd

过于重。

tini:

text 复制代码
就是非常轻量地完成
Container 最需要的 init 职责。

所以:

Small Init > Full OS Init

在普通 Application Container 里通常更合理。


三十八、Tini 不是进程管理器的替代品

这点也要讲清楚。

tini:

主要:

text 复制代码
Signal Forwarding
+
Child Reaping。

它不是:

text 复制代码
完整 Supervisor

不会帮你:

text 复制代码
自动启动 20 个业务服务

做复杂依赖管理

进程崩了按高级策略恢复

服务发现。

所以:

不要因为有 tini,就把一个 Container 当虚拟机使用。


三十九、为什么"一容器一个进程"不是绝对规则?

大家经常听:

一个 Container 只能跑一个进程。

严格来说:

这并不是技术限制。

一个 Container 完全可以:

text 复制代码
有很多 Process。

例如:

text 复制代码
Web Server
↓
Worker Child
↓
Helper

很常见。

更准确的原则是:

一个 Container 通常围绕一个主要职责/主服务生命周期设计。

而不是:

text 复制代码
PID 数必须永远等于 1。

四十、例如 Nginx 自己就不只一个进程

你可能看到:

text 复制代码
PID 1 nginx master

PID 8 nginx worker

PID 9 nginx worker

这完全正常。

因为:

text 复制代码
Master

负责:

text 复制代码
管理 Worker。

重要的是:

主进程生命周期清晰,并且正确 Reap/Handle Signal。


四十一、为什么 PID 1 特别特殊?

传统 Linux 中:

text 复制代码
PID 1

不是普通应用进程。

Kernel 对它:

text 复制代码
有特殊处理。

Docker 当前 docker run 文档明确提醒,运行在 Container PID 1 的进程对默认终止类 Signal 的处理具有特殊性,如果程序没有显式处理 SIGINT / SIGTERM,不一定会像普通进程那样退出。

所以最安全:

明确写 Signal Handler。

不要:

text 复制代码
只赌 Runtime 默认行为。

四十二、STOPSIGNAL 又是什么?

Dockerfile 可以:

Dockerfile 复制代码
STOPSIGNAL SIGTERM

或者:

Dockerfile 复制代码
STOPSIGNAL SIGQUIT

它告诉 Docker:

docker stop 时优先给这个 Image 使用什么停止 Signal。

如果 Image 没配置:

text 复制代码
默认通常 SIGTERM。

Docker 当前 docker stop 和 docker run --stop-signal 都支持覆盖这一行为。


四十三、什么时候需要自定义 Stop Signal?

某些程序:

text 复制代码
对特定 Signal
有自己的优雅关闭约定。

比如某些 Server:

text 复制代码
SIGQUIT

可能代表:

text 复制代码
Graceful Shutdown。

而:

text 复制代码
SIGTERM

是另一种处理方式。

这时候:

Dockerfile 复制代码
STOPSIGNAL SIGQUIT

可以让:

text 复制代码
docker stop

更符合该程序原生约定。

但不要:

凭感觉乱改。

先看应用自己的:

text 复制代码
Signal Documentation。

四十四、Compose 也可以配置 stop_signal

例如:

yaml 复制代码
services:

  api:

    image:
      myapi

    stop_signal:
      SIGTERM

    stop_grace_period:
      30s

这两个参数:

text 复制代码
一个决定发什么

一个决定等多久。

可以简单记成:

text 复制代码
stop_signal
=
礼貌地怎么通知你
text 复制代码
stop_grace_period
=
最多给你多少时间收拾东西

四十五、千万不要把 stop_signal 配成 SIGKILL

比如:

yaml 复制代码
stop_signal:
  SIGKILL

技术上:

text 复制代码
很干脆。

但这意味着:

直接失去 Graceful Shutdown。

你配置:

text 复制代码
stop_grace_period: 60s

也没有意义。

因为:

text 复制代码
SIGKILL

应用:

根本不能处理。


四十六、怎么判断 Stop 卡住在哪?

最简单:

bash 复制代码
time \
  docker stop \
  myapp

如果:

text 复制代码
几乎稳定 10 秒。

这就是一个非常明显的:

Signal / Graceful Shutdown Warning。

Docker Compose 官方 FAQ 当前也直接说明:如果 docker compose stop 总是在默认 10 秒超时后才结束,说明 Container 没有在收到 SIGTERM 后正常 Shutdown;官方第一条建议就是检查 CMD/ENTRYPOINT 是否使用 Exec Form。


四十七、第二步:看 PID 1

bash 复制代码
docker exec \
  myapp \
  ps -o \
  pid,ppid,state,args

确认:

text 复制代码
PID 1

是谁。

如果:

text 复制代码
/bin/sh -c ...

重点检查:

text 复制代码
Dockerfile Shell Form。

四十八、第三步:主动发 SIGTERM

bash 复制代码
docker kill \
  --signal=TERM \
  myapp

看:

bash 复制代码
docker logs \
  -f \
  myapp

如果应用:

text 复制代码
完全无日志

说明:

text 复制代码
Signal

很可能:

没到业务进程或者业务没处理。


四十九、第四步:看真正业务进程

例如:

bash 复制代码
docker top \
  myapp

可以从 Host 查看:

text 复制代码
Container 内进程。

如果你看到:

text 复制代码
sh -c java -jar app.jar

java -jar app.jar

说明:

text 复制代码
Shell Parent

确实存在。

Dockerfile:

text 复制代码
值得马上检查。

五十、第五步:查看 Image 配置

bash 复制代码
docker inspect \
  myapp \
  --format \
  'Path={{.Path}} Args={{json .Args}}'

还可以:

bash 复制代码
docker inspect \
  myapp \
  --format \
  '{{json .Config.Entrypoint}} {{json .Config.Cmd}}'

看看:

text 复制代码
最终 Entrypoint

最终 Cmd

到底是什么。

因为实际 Container 配置:

text 复制代码
可能还被 Compose 覆盖。

不要只看:

Dockerfile 源码。


五十一、Compose command 也可能影响进程结构

例如 Dockerfile:

Dockerfile 复制代码
ENTRYPOINT ["/app"]

Compose:

yaml 复制代码
command:
  - server

这通常:

text 复制代码
仍然比较清晰。

但如果你:

yaml 复制代码
entrypoint:
  - /bin/sh
  - -c

然后:

yaml 复制代码
command:
  - ./app server

你重新:

引入了一层 Shell。

所以 Runtime 最终配置:

text 复制代码
也要看。

五十二、Shell Script 里有多个后台程序怎么办?

例如:

sh 复制代码
worker &

scheduler &

web

如果 Shell:

text 复制代码
作为 PID 1

你要处理:

text 复制代码
Signal Forwarding

wait

Child Exit

Zombie Reaping

很快就开始:

自己写一个简陋 init/supervisor。

所以这类场景:

要么:

text 复制代码
拆成多个 Container

要么:

text 复制代码
明确使用成熟 Supervisor / init。

不要:

靠一个十行 Shell 脚本硬顶复杂进程管理。


五十三、为什么多个服务通常应该拆 Container?

例如:

text 复制代码
Nginx
+
API
+
Worker
+
Cron

全塞一个 Container。

任何一个组件:

text 复制代码
崩溃

主 Container:

text 复制代码
要不要退出?

Restart Policy:

text 复制代码
应该重启谁?

Health Check:

text 复制代码
检查谁?

日志:

text 复制代码
怎么区分?

Signal:

text 复制代码
怎么转发?

复杂度迅速上升。

拆成:

text 复制代码
nginx

api

worker

scheduler

生命周期:

清晰太多。


五十四、但是拆 Container 后 Shutdown 顺序也要考虑

例如:

text 复制代码
API
↓
MySQL

正常发布:

最好:

text 复制代码
先停止接流量
↓
让 API 完成请求
↓
API 退出
↓
再处理后端依赖。

如果:

text 复制代码
数据库先断

API Graceful Shutdown:

text 复制代码
还没执行完事务

可能:

text 复制代码
大量错误。

所以 Container Signal:

最终还是整个应用生命周期设计的一部分。


五十五、反向代理环境尤其需要 Graceful Shutdown

假设:

text 复制代码
Nginx
↓
API Container A
API Container B

更新 A:

如果:

text 复制代码
SIGKILL

A 中正在处理:

text 复制代码
2 秒请求

用户:

text 复制代码
Connection Reset。

如果 A:

text 复制代码
收到 SIGTERM
↓
停止接新请求
↓
完成已有请求
↓
退出

体验:

完全不同。

所以 Graceful Shutdown:

不是"优雅不优雅"的代码洁癖。

它直接影响:

text 复制代码
发布期间用户是否掉请求。

五十六、消息消费者更需要

比如 Worker:

text 复制代码
拿到一个订单任务
↓
处理中

突然:

text 复制代码
SIGKILL。

消息系统:

text 复制代码
可能重新投递。

如果任务:

text 复制代码
不是幂等的

甚至:

text 复制代码
可能产生重复扣款
重复发送。

而收到:

text 复制代码
SIGTERM

以后:

text 复制代码
停止拉新消息

完成当前任务

Commit Offset / Ack

退出

才是:

正确生命周期。


五十七、数据库也非常依赖停止语义

数据库通常:

text 复制代码
自己就有成熟 Signal Handling。

不要:

text 复制代码
为了"停得快"

每次:

bash 复制代码
docker kill database

强制 SIGKILL。

这相当于:

text 复制代码
不断模拟断电。

现代数据库有:

text 复制代码
WAL

Redo

Crash Recovery

能尽量恢复,

但:

正常运维不应该把 Crash Recovery 当 Shutdown Strategy。


五十八、为什么有时候 docker stop 很快,但应用还是不优雅?

因为:

text 复制代码
快速退出

不等于:

text 复制代码
优雅退出。

例如程序收到:

text 复制代码
SIGTERM

直接:

text 复制代码
os.Exit(0)

当然:

text 复制代码
0.01 秒停掉。

但:

text 复制代码
正在处理请求

消息

缓冲数据

全部放弃。

真正的指标:

不只是 Stop Duration。

还应该看:

text 复制代码
请求有没有完成

连接有没有 Drain

任务有没有 Ack

数据有没有 Flush。

五十九、所以需要设置一个合理的 Grace Period

比如普通 API:

text 复制代码
15s
30s

可能够。

长任务 Worker:

text 复制代码
可能需要 60s
甚至几分钟。

但:

text 复制代码
Grace Period

也不能无限长。

否则部署:

text 复制代码
一个卡死任务

可能:

text 复制代码
永远停不下来。

所以最终:

text 复制代码
SIGTERM
↓
给合理时间
↓
超时 SIGKILL

这套机制:

本身就是正确兜底。


六十、137 和 OOM 应该如何区分?

再复习一次。

看到:

text 复制代码
Exited (137)

先:

bash 复制代码
docker inspect \
  myapp \
  --format \
  'Exit={{.State.ExitCode}} OOM={{.State.OOMKilled}}'

如果:

text 复制代码
OOM=true

偏向:

内存。

如果:

text 复制代码
OOM=false

而且:

text 复制代码
每次 docker stop
都等十秒
↓
然后 137

非常值得怀疑:

Graceful Shutdown / Signal 问题。

这就是为什么:

Exit Code 不能脱离上下文看。


六十一、Docker Events 也能帮助建立时间线

例如:

bash 复制代码
docker events \
  --filter container=myapp

你可能看到:

text 复制代码
kill signal=15

过一会:

text 复制代码
kill signal=9

然后:

text 复制代码
die exitCode=137

这条链:

text 复制代码
SIGTERM
↓
没有退出
↓
SIGKILL
↓
137

就很明显。


六十二、一个最简单的 Signal 测试镜像

可以写一个 Bash:

sh 复制代码
#!/bin/sh

trap '
  echo "SIGTERM received";
  exit 0
' TERM

echo "started"

while true
do
  sleep 1
done

Dockerfile:

Dockerfile 复制代码
FROM alpine

COPY app.sh /app.sh

RUN chmod +x /app.sh

CMD ["/app.sh"]

执行:

bash 复制代码
docker run \
  --rm \
  --name signal-demo \
  signal-demo

另一个终端:

bash 复制代码
docker stop signal-demo

你会看到:

text 复制代码
SIGTERM received

然后:

正常退出。

非常适合用来理解 Container Signal。


六十三、再做一个错误版

Dockerfile:

Dockerfile 复制代码
CMD /app.sh

变成 Shell Form。

再:

bash 复制代码
docker stop signal-demo

比较:

text 复制代码
Signal

行为。

这两个实验:

特别适合拿来做教程视频。

用户能非常直观地理解:

text 复制代码
Exec Form
vs
Shell Form。

六十四、如何查看 Container PID 1 在 Host 上是谁?

可以:

bash 复制代码
docker inspect \
  myapp \
  --format '{{.State.Pid}}'

比如:

text 复制代码
32541

Host:

bash 复制代码
ps \
  -fp \
  32541

就能看到:

text 复制代码
Container PID 1

在 Host PID Namespace 中:

text 复制代码
对应哪个进程。

这对:

text 复制代码
Signal Debug
Process Tree Debug

非常有帮助。


六十五、为什么 Container 内是 PID 1,Host 上却可能是 32541?

因为:

PID Namespace。

Container:

text 复制代码
自己的 Process Namespace。

里面:

text 复制代码
应用 PID:
1

Host:

text 复制代码
看整个系统

它可能是:

text 复制代码
32541。

所以:

text 复制代码
PID 1

是:

Namespace-relative。

这也是 Container Isolation 的基础之一。


六十六、可以看进程树

Host:

bash 复制代码
pstree \
  -p \
  "$(docker inspect -f '{{.State.Pid}}' myapp)"

或者 Container:

bash 复制代码
docker exec \
  myapp \
  ps -ef

如果看到:

text 复制代码
sh
└── npm
    └── node

这种三层:

你已经应该问:

Docker SIGTERM 最终到底怎么传到 Node?

这就是进程树分析的价值。


六十七、为什么最理想的生产进程树往往很简单?

比如:

text 复制代码
docker-init
└── app

或者:

text 复制代码
app

而不是:

text 复制代码
sh
└── npm
    └── bash
        └── app

进程链越多:

text 复制代码
Signal Forwarding

Exit Code Propagation

Zombie Reaping

越容易复杂。

所以 Container:

越简单越好。


六十八、Exit Code 也可能被 Wrapper Script 吃掉

比如:

sh 复制代码
#!/bin/sh

./app

echo "app exited"

exit 0

应用:

text 复制代码
崩溃
Exit 1。

Shell Script:

text 复制代码
最后 Exit 0。

Docker:

text 复制代码
Container Exit Code
=
0。

Restart Policy:

yaml 复制代码
restart:
  on-failure

看到:

text 复制代码
0

于是:

不重启。

你明明:

text 复制代码
配置了 on-failure

结果:

程序崩了为什么没恢复?

因为:

Wrapper 把真正 Exit Code 吞了。


六十九、exec "$@" 还能解决 Exit Code Propagation

sh 复制代码
exec "$@"

以后:

text 复制代码
最终业务进程

直接成为:

text 复制代码
Container Main Process。

它:

text 复制代码
Exit 1

Container:

text 复制代码
Exit 1。

Restart Policy:

text 复制代码
能正确看到。

所以 exec:

不只是信号问题。

还影响:

text 复制代码
Exit Code
Lifecycle。

七十、这和 Restart Policy 又串起来了

正确链:

text 复制代码
App Crash
↓
App Exit 1
↓
Container Exit 1
↓
restart: on-failure
↓
Docker Restart

错误 Wrapper:

text 复制代码
App Crash
↓
App Exit 1
↓
Shell 忽略
↓
Shell Exit 0
↓
Container Exit 0
↓
Docker 不 Restart

所以一个:

text 复制代码
看起来很简单的 startup.sh

也可以把:

Restart 语义彻底改掉。


七十一、--init 能解决业务不处理 SIGTERM 吗?

不能完全解决。

tini:

text 复制代码
可以正确把 Signal
转发给 Child。

但如果 Child:

text 复制代码
收到 SIGTERM
以后完全不处理

最终:

text 复制代码
还是可能无法按你的业务要求优雅退出。

所以:

text 复制代码
--init

解决:

text 复制代码
Signal Delivery

Zombie Reaping。

应用本身:

仍然应该实现 Graceful Shutdown。


七十二、可以简单理解责任分工

Docker:

text 复制代码
负责发 Signal。

tini:

text 复制代码
负责传 Signal
+
回收 Child。

应用:

text 复制代码
负责收到 Signal 后
正确关闭业务。

三层:

各有职责。


七十三、一个比较推荐的 Compose

比如 API:

yaml 复制代码
services:

  api:

    image:
      mycompany/api:1.8.3

    init:
      true

    restart:
      unless-stopped

    stop_signal:
      SIGTERM

    stop_grace_period:
      30s

    healthcheck:

      test:
        [
          "CMD",
          "/app",
          "healthcheck"
        ]

      interval:
        10s

      timeout:
        3s

      retries:
        3

再确保 Image:

Dockerfile 复制代码
ENTRYPOINT ["/app"]

CMD ["server"]

程序:

text 复制代码
自己处理 SIGTERM。

这套 Runtime Lifecycle:

已经比较完整。


七十四、Health 和 Shutdown 又是什么关系?

Health Check:

text 复制代码
服务运行过程中
还能不能正常工作。

Graceful Shutdown:

text 复制代码
服务准备结束时
怎么安全退出。

两者:

一个管生,一个管死。

真正成熟的 Container:

text 复制代码
启动
↓
Ready
↓
Healthy
↓
收到 SIGTERM
↓
Draining
↓
Exit

应该有:

完整生命周期。


七十五、生产发布其实就是在反复走这条生命周期

比如:

text 复制代码
Old Container
Healthy

发布:

text 复制代码
New Container
Start
↓
Healthy

然后旧:

text 复制代码
SIGTERM
↓
停止接流量
↓
完成旧请求
↓
退出。

如果旧 Container:

text 复制代码
完全不处理 SIGTERM

发布:

text 复制代码
每次都等 10 秒
↓
SIGKILL。

不仅:

text 复制代码
发布慢。

还可能:

text 复制代码
掉请求。

所以 Stop Behavior:

其实是 Deployment Quality 的一部分。


七十六、这对反向代理尤其重要

比如 Nginx:

text 复制代码
API A
API B

如果:

text 复制代码
API A

准备下线,

最理想:

text 复制代码
先从流量池摘掉
↓
SIGTERM
↓
完成已有连接
↓
Exit。

而不是:

text 复制代码
SIGKILL
↓
正在连接的用户一起断。

Docker 自身:

text 复制代码
不会替你的 Load Balancer
完成所有 Drain 逻辑。

这需要:

部署平台和应用配合。


七十七、Rodert/DockerHub 可以增加一个 PID 1 Doctor

现在这个项目已经一路覆盖:

text 复制代码
Pull

Build

Storage

Volume

Network

Port

Health

OOM

Permission

Env。

很适合继续增加:

Process Doctor。

例如:

text 复制代码
docker-doctor process api

输出:

text 复制代码
Container:
api

PID 1:
sh

Command:
/bin/sh -c node server.js

Child:
node server.js

Init:
NO

Stop Signal:
SIGTERM

Stop Timeout:
10s

然后:

text 复制代码
🔴 Signal Forwarding Risk

提示:

text 复制代码
The application is not PID 1.

Dockerfile likely uses
Shell Form CMD/ENTRYPOINT.

Recommended:

CMD ["node", "server.js"]

非常实用。


七十八、如果已经是 PID 1

例如:

text 复制代码
PID 1:
java

Init:
NO

工具可以:

text 复制代码
🟢 Direct PID 1

然后进一步:

text 复制代码
SIGTERM Test:
No exit after 10s

提示:

text 复制代码
Application receives or owns PID 1
but does not shut down
within the configured grace period.

Check application-level
SIGTERM handling.

这样:

不会把所有 Stop 问题都怪 Dockerfile。


七十九、如果 Zombie 很多

工具:

text 复制代码
Zombie Processes:
137

提示:

text 复制代码
🔴 Zombie Process Accumulation

The application is PID 1
and appears not to reap child processes.

Consider:
- fix child wait logic
- run with init: true

这类功能:

很适合长期运行 Worker。


八十、如果检测到 npm 是 PID 1

例如:

text 复制代码
PID 1:
npm

Child:
node

可以提示:

text 复制代码
🟡 Extra Process Layer

If npm is only used to launch the
production Node.js application,
consider running Node directly:

CMD ["node", "server.js"]

This simplifies signal delivery
and process lifecycle.

这种建议:

很贴近真实项目。


八十一、Dockerfile Analyzer 也可以直接检测 Shell Form

例如:

Dockerfile 复制代码
CMD java -jar app.jar

提示:

text 复制代码
🔴 Shell-form CMD

Docker will run this command
through /bin/sh -c.

Your Java process may not be PID 1
and may not receive SIGTERM directly.

Prefer:

CMD ["java", "-jar", "app.jar"]

Docker 当前官方 Build Check 本身也会给出类似 JSONArgsRecommended 警告。


八十二、再检测 EntryPoint Script

例如:

Dockerfile 复制代码
ENTRYPOINT [
  "/entrypoint.sh"
]

脚本:

sh 复制代码
#!/bin/sh

./app "$@"

工具可以提示:

text 复制代码
🟠 Missing exec

The wrapper script starts
the application as a child process.

Consider:

exec ./app "$@"

这个:

自动分析价值非常高。


八十三、如果脚本用后台进程

例如:

sh 复制代码
./worker &

./api

工具:

text 复制代码
🟠 Multiple Process Lifecycle

提示:

text 复制代码
The entrypoint starts background
child processes.

Verify:
- signal forwarding
- wait/reaping
- exit-code propagation

Consider init: true
or splitting responsibilities.

这比:

text 复制代码
"一个容器不能多个进程"

这种过度简化的说法:

更准确。


八十四、还可以检测 Stop Duration

比如 Doctor:

text 复制代码
Graceful Stop Test

SIGTERM sent:
10:22:01

Container exited:
10:22:11

Duration:
10.04s

Exit:
137

结论:

text 复制代码
🔴 Forced Shutdown Detected

Container reached the default
stop timeout and was killed
with SIGKILL.

这几乎:

直接把根因摆在用户面前。


八十五、生产监控甚至可以记录强杀次数

比如:

text 复制代码
Graceful Exit:
98.7%

Forced SIGKILL:
1.3%

如果发布:

text 复制代码
每次都有大量 SIGKILL。

这是:

一个非常值得优化的稳定性指标。

很多团队只监控:

text 复制代码
CPU
Memory
HTTP 500。

却完全不关心:

text 复制代码
服务到底是怎么被关闭的。

实际上:

Shutdown Quality 同样影响可靠性。


八十六、容器生命周期可以最终整理成一张图

text 复制代码
docker start
     │
     ↓
   PID 1
     │
     ↓
Application Running
     │
     ↓
docker stop
     │
     ↓
SIGTERM / STOPSIGNAL
     │
     ├── App handles signal
     │       ↓
     │   Graceful cleanup
     │       ↓
     │     Exit
     │
     └── App ignores / can't receive
             ↓
        Grace period expires
             ↓
           SIGKILL
             ↓
         Exit 137

如果还有 init:

text 复制代码
Docker
↓
tini PID 1
↓
Application

tini:

text 复制代码
Forward Signal
+
Reap Children。

这就是:

Docker Process Lifecycle 的核心模型。


八十七、为什么理解这一套很重要?

因为它同时解释很多问题:

text 复制代码
docker stop 为什么总要 10 秒?
text 复制代码
为什么 Container 最后 Exit 137?
text 复制代码
为什么 restart 偶尔会强杀?
text 复制代码
为什么 shell script 启动的程序收不到 SIGTERM?
text 复制代码
为什么 Container 里有一堆 defunct?
text 复制代码
为什么 on-failure 没有按预期重启?
text 复制代码
为什么更新服务时用户请求被突然中断?

这些看起来是:

text 复制代码
七八个问题。

根源很多时候都在:

PID 1 与进程生命周期。


总结

Docker Container 并不是:

"里面所有进程一起被 Docker 管。"

真正决定 Container 生命周期的:

是它的主进程,也就是 Container Namespace 中的 PID 1。

当你执行:

bash 复制代码
docker stop myapp

Docker 当前默认会先给主进程发送:

text 复制代码
SIGTERM

Linux Container 通常等待:

text 复制代码
10 秒。

如果进程没有正常退出:

text 复制代码
再发送 SIGKILL

强制终止。

所以如果:

bash 复制代码
time docker stop myapp

每次都稳定接近:

text 复制代码
10 秒

甚至最后:

text 复制代码
Exit 137

第一件事不应该只怀疑:

text 复制代码
OOM。

而应该检查:

text 复制代码
PID 1 到底是谁?
SIGTERM 有没有到达真正应用?
应用有没有实现 Graceful Shutdown?

Dockerfile:

Dockerfile 复制代码
CMD node server.js

这种 Shell Form 会通过:

text 复制代码
/bin/sh -c

启动应用,真正 executable 不会直接成为 PID 1,而且 Shell 默认不会替你正确转发 Signal。Docker 官方当前明确建议需要正常 Signal Handling 的 CMD/ENTRYPOINT 优先使用 Exec/JSON Form。

因此更推荐:

Dockerfile 复制代码
CMD [
  "node",
  "server.js"
]

如果必须使用启动脚本:

sh 复制代码
#!/bin/sh

prepare_something

exec "$@"

最关键的:

是最后的 exec。

让真正应用:

text 复制代码
替换 Shell
成为主进程

从而正确接收:

text 复制代码
SIGTERM

并把真正 Exit Code 传给 Docker。

如果应用还会:

text 复制代码
创建大量 Child Process

则可以考虑:

bash 复制代码
docker run \
  --init \
  ...

或者 Compose:

yaml 复制代码
init:
  true

Docker 当前 --init 会运行一个轻量 Init Process,负责 Signal Forwarding 和 Zombie Reaping,默认 Docker Init 实现由 tini 支撑。

但要记住:

text 复制代码
tini

只能帮你:

text 复制代码
把 Signal 送到应用
+
回收 Child。

它不能替你的应用决定:

text 复制代码
收到 SIGTERM 后
如何停止 HTTP

如何完成任务

如何关闭数据库

如何提交消息

如何 Flush 数据。

这仍然是:

Application Graceful Shutdown Design。

如果只记住一句话:

Docker stop 卡满 10 秒,往往不是 Docker "停得慢",而是 PID 1 没有在 SIGTERM 后正常退出;先把 CMD/ENTRYPOINT、exec、Signal Handler 和 Zombie Reaping 设计正确,再去调整 stop_grace_period。

项目地址:

text 复制代码
https://github.com/Rodert/DockerHub

这个项目下一步很适合继续增加:

text 复制代码
PID 1 Doctor

Signal Forwarding Checker

Shell Form Detector

Missing exec Analyzer

Zombie Process Detector

Graceful Stop Benchmark

这样用户碰到:

"为什么我的 Container 每次 stop 都卡十秒?"

就可以直接判断,到底是:

text 复制代码
Dockerfile 写法

Wrapper Script

应用 Signal Handler

还是 Child Process 管理

出了问题。

相关推荐
学IT的周星星2 小时前
记忆系统:让 Agent 记住你是谁、上次聊到哪
运维·服务器·人工智能·windows·大模型·llm·agent
en.en..2 小时前
MRS/MSR 指令操作 xPSR 寄存器
linux·运维·服务器
Brilliantwxx2 小时前
【Linux】 自主shell编写巩固复习篇
linux·运维·服务器·开发语言
Qt云程序员2 小时前
零代码网页数据采集教程:不写爬虫,把网页数据搬进 Excel
运维·自动化
Android系统攻城狮3 小时前
Linux Gstreamer深度解析之gst_audio_ring_buffer_parse_caps调用流程与实战(八十)
linux·运维·服务器·gstreamer音视频·音视频进阶
Lsetea4 小时前
OpenSSL指定-cipher仍协商TLS1.3:证书排查中的套件与协议边界
运维·https·ssl证书·openssl·tls
先知后行。4 小时前
进程间通信(IPC)
linux·运维·服务器
weixin_571667415 小时前
Windows 通过 SSH 反向隧道向 Ubuntu 转发代理
linux·运维·windows·代理
乔旭伟5 小时前
CentOS 停更迁移实战:vault 源配置、目标选型与迁移工程清单
linux·运维·centos