Linux-08 信号与进程间通信
前置阅读:Linux-06 进程管理与作业控制
信号是后端工程师每天都在用但很少深究的东西——kill 一个进程、Ctrl+C 中断、服务优雅关闭、nginx -s reload,背后全是信号。
这一篇的重点是优雅关闭。K8s 滚动更新时为什么会有请求 502、为什么 kill -9 会丢数据、为什么容器里的进程收不到 SIGTERM——这些问题都需要理解信号的机制才能讲清楚。后半部分讲 IPC 的选型。
1. 信号是什么
一句话:信号是内核向进程发送的、异步的、极简的通知——只有一个编号,没有任何附带数据。
它是 Unix 最古老的 IPC 机制,可以理解为「软件层面的中断」:
进程正在执行自己的代码
|
| <- 内核投递一个信号(比如 SIGTERM)
v
内核在进程【从内核态返回用户态】的时刻检查是否有未处理信号
|
+- 有 -> 强行插入执行信号处理函数
| 处理完再回到原来被打断的位置继续
+- 无 -> 正常返回
关键特性:
- 异步:随时可能到来,进程不知道什么时候
- 极简:只有一个 int 编号(
SIGRTMIN之后的实时信号可以附带一点数据) - 不排队:普通信号在挂起期间重复到来会被合并成一次(这是个重要的坑,见 §3)
1.1 常用信号表
kill -l 能列出全部 64 个,但实际需要记住的就这些:
| 编号 | 名称 | 默认动作 | 能否捕获 | 典型触发 |
|---|---|---|---|---|
| 1 | SIGHUP |
终止 | 能 | 终端断开;约定用于「重载配置」 |
| 2 | SIGINT |
终止 | 能 | Ctrl+C |
| 3 | SIGQUIT |
终止+core | 能 | Ctrl+\;Java 用它打线程栈 |
| 9 | SIGKILL |
终止 | 不能 | kill -9 |
| 11 | SIGSEGV |
终止+core | 能 | 段错误(访问非法内存) |
| 13 | SIGPIPE |
终止 | 能 | 往已关闭的管道/socket 写 |
| 14 | SIGALRM |
终止 | 能 | alarm() 定时器到期 |
| 15 | SIGTERM |
终止 | 能 | kill 默认发这个;K8s 也发这个 |
| 17 | SIGCHLD |
忽略 | 能 | 子进程退出(用于回收僵尸) |
| 18 | SIGCONT |
继续 | 能 | 恢复被暂停的进程 |
| 19 | SIGSTOP |
暂停 | 不能 | 强制暂停 |
| 20 | SIGTSTP |
暂停 | 能 | Ctrl+Z |
| 10/12 | SIGUSR1/SIGUSR2 |
终止 | 能 | 留给应用自定义 |
**只有 SIGKILL(9) 和 SIGSTOP(19) 无法被捕获、阻塞或忽略。**这是内核的硬保证——否则进程就能做到无法被终止了。
1.2 三种处理方式
① 默认动作(Default)
内核为每个信号规定了默认行为:Term(终止)、Core(终止并 dump)、
Ign(忽略)、Stop(暂停)、Cont(继续)
② 捕获(Catch)
注册一个信号处理函数,信号到来时执行它
③ 忽略(Ignore)
设为 SIG_IGN,信号被丢弃
#include <signal.h>
void handler(int sig) {
// 注意:这里能做的事情非常有限,见 §4
write(STDERR_FILENO, "got signal\n", 11);
}
int main() {
signal(SIGTERM, handler); // 捕获
signal(SIGPIPE, SIG_IGN); // 忽略
signal(SIGINT, SIG_DFL); // 恢复默认
// ...
}
注意:
signal()的行为在不同 Unix 上不一致(有的会在处理完后重置为默认动作),生产代码应该用sigaction()。Go/Java 等语言的运行时已经帮你处理了这一层。
2. SIGTERM vs SIGKILL:优雅关闭的核心
这是本篇最重要的一节。
kill 1234 # 默认发 SIGTERM(15) —— 「请你退出」
kill -9 1234 # 发 SIGKILL(9) —— 「立刻消失」
SIGTERM (15) |
SIGKILL (9) |
|
|---|---|---|
| 语义 | 请求退出 | 强制终止 |
| 能否被捕获 | 能 | 不能 |
| 进程能否清理 | 能(关连接、刷缓冲、保存状态) | 不能 |
| 谁执行 | 进程自己 | 内核直接回收 |
| 会不会丢数据 | 取决于应用实现 | 很可能丢 |
kill -9 会造成什么损失:
进程被 SIGKILL 杀掉时,来不及做的事情:
✗ 把内存里的缓冲区刷到磁盘(日志、数据文件可能丢最后几秒)
✗ 优雅关闭数据库连接(服务端要等 TCP 超时才回收,连接池被占)
✗ 从服务注册中心注销(其他服务继续往这里发请求 -> 502)
✗ 完成正在处理的请求(客户端收到连接重置)
✗ 释放分布式锁(其他实例要等锁过期)
✗ 删除 pid 文件、临时文件(残留垃圾)
生产建议:永远先 SIGTERM,等一段时间,实在不退再 SIGKILL。
# ✅ 标准做法
kill 1234 # 先礼
for i in $(seq 30); do
kill -0 1234 2>/dev/null || { echo "已退出"; exit 0; }
sleep 1
done
echo "超时,强制杀"
kill -9 1234 # 后兵
kill -0 是个技巧——不发送任何信号,只检查进程是否存在(以及你有没有权限给它发信号)。
2.1 systemd 和 K8s 都是这个模式
systemd:
[Service]
KillSignal=SIGTERM # 先发这个(默认值)
TimeoutStopSec=30 # 等 30 秒
# 超时后自动发 SIGKILL
KillMode=mixed # 只给主进程发 TERM,超时后给整个 cgroup 发 KILL
K8s:
spec:
terminationGracePeriodSeconds: 30 # 默认 30 秒
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"] # 关键,见下
K8s 删除 Pod 的完整时序:
1. Pod 被标记为 Terminating
v 【同时并行发生两件事】
2a. kubelet 执行 preStop hook,然后给容器 PID 1 发 SIGTERM
2b. Endpoint Controller 把这个 Pod 从 Service 的 endpoints 里摘掉
-> 但各节点的 kube-proxy/ingress 更新 iptables 需要时间(几百 ms 到几秒)
v
3. 等待 terminationGracePeriodSeconds(默认 30s)
v
4. 时间到还没退出 -> SIGKILL
502 就出在第 2 步的竞态上:应用收到 SIGTERM 立刻关闭监听端口,但此时 ingress 的转发规则还没更新完,仍在往这个 Pod 发请求 → 连接被拒 → 502。
解法是 preStop 里 sleep 几秒,给流量摘除留出时间:
lifecycle:
preStop:
exec:
# 什么都不做,只是等待,让 endpoints 变更传播到所有节点
command: ["sh", "-c", "sleep 5"]
这几秒里应用仍然正常处理请求,等 sleep 结束才收到 SIGTERM 开始关闭。
规律:优雅关闭 = 先停止接收新流量,再处理完存量请求,最后退出。顺序错了就会丢请求。
2.2 容器里的 PID 1 问题
一个很常见的坑:容器里的进程收不到 SIGTERM,每次删 Pod 都要等满 30 秒然后被 SIGKILL。
原因有两个:
原因一:PID 1 的信号语义特殊
内核对 PID 1 有特殊规定:它不会执行信号的默认动作。如果 PID 1 没有显式注册 SIGTERM 处理函数,这个信号就被直接丢弃了。
# ❌ shell 形式的 CMD,PID 1 是 sh,它不转发信号给子进程
CMD ./myapp
# 实际执行:/bin/sh -c "./myapp"
# PID 1: sh <- SIGTERM 发给它
# PID 7: myapp <- 真正的应用,收不到信号
# ✅ exec 形式,应用直接是 PID 1
CMD ["./myapp"]
# ✅ 或者在 shell 里用 exec 替换掉 shell 自己
CMD exec ./myapp
# 验证:进容器看 PID 1 是什么
docker exec -it mycontainer ps -ef
# UID PID PPID CMD
# root 1 0 /bin/sh -c ./myapp <- ❌ 有问题
# root 7 1 ./myapp
# 正确的应该是
# root 1 0 ./myapp <- ✅
原因二:应用没实现 SIGTERM 处理
// ✅ Go 服务的标准优雅关闭
func main() {
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
// 监听信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("收到关闭信号,开始优雅关闭")
// 给存量请求 25 秒处理完(要小于 terminationGracePeriodSeconds)
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
// Shutdown 会:① 立即停止 accept 新连接 ② 等已有请求处理完
if err := srv.Shutdown(ctx); err != nil {
log.Printf("优雅关闭超时: %v", err)
}
// 再关其他资源
db.Close()
redisClient.Close()
log.Println("已退出")
}
注意超时的层次关系:
preStop sleep (5s) + 应用 Shutdown 超时 (25s) < terminationGracePeriodSeconds (30s)
^ 否则会被 SIGKILL 打断优雅关闭
2.3 SIGHUP 用于重载配置
SIGHUP 原本的含义是「终端挂断」,但业界形成了一个约定:给守护进程发 SIGHUP 表示「重新读取配置文件」。
kill -HUP $(cat /var/run/nginx.pid) # nginx reload
nginx -s reload # 等价,nginx 自己封装的
systemctl reload nginx # systemd 里配的 ExecReload
kill -USR1 $(cat /var/run/nginx.pid) # nginx: 重新打开日志文件
SIGUSR1 重开日志文件是 logrotate 的标准配合方式——回到第 2 篇讲的问题:logrotate 把 access.log 改名成 access.log.1 后,nginx 的 fd 仍指向旧 inode,会继续往改名后的文件写。发 USR1 让它重新 open() 一次,才会写到新文件。
# /etc/logrotate.d/nginx
/var/log/nginx/*.log {
daily
rotate 14
compress
postrotate
kill -USR1 $(cat /var/run/nginx.pid) # <- 关键
endscript
}
3. 可靠信号与不可靠信号
这是个高频面试点,也是实际会踩的坑。
1 ~ 31 传统信号(不可靠)—— 不排队,挂起期间重复到达会【合并成一次】
32 ~ 64 实时信号(可靠) —— 排队,每次发送都会被单独处理,且有序
kill -l | head -4
# 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL
# ...
# 34) SIGRTMIN 35) SIGRTMIN+1 ... <- 从这里开始是实时信号
「不排队」意味着什么:
时刻 t1: 内核给进程投递 SIGCHLD,进程正忙 -> 标记为 pending
时刻 t2: 又来一个 SIGCHLD -> 已经 pending 了,【直接丢弃】
时刻 t3: 又来一个 SIGCHLD -> 【又丢弃】
时刻 t4: 进程开始处理 -> 只执行【一次】处理函数
这导致一个经典 bug:用 SIGCHLD 回收子进程时,如果同时有 3 个子进程退出,处理函数可能只被调用 1 次,另外 2 个变成僵尸。
// ❌ 错误:只 wait 一次
void sigchld_handler(int sig) {
wait(NULL); // 只回收了一个,其余变僵尸
}
// ✅ 正确:循环 wait 直到没有可回收的
void sigchld_handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0)
; // WNOHANG 让它非阻塞,回收完就返回
}
**规律:处理 SIGCHLD 必须用 while (waitpid(-1, NULL, WNOHANG) > 0) 循环。**这是 Unix 编程的经典范式。
4. 信号处理函数里能做什么
**答案是:几乎什么都不能做。**这是信号最反直觉的地方。
因为信号处理函数是异步插入执行的——它可能在主流程执行到任何一条指令时插进来,包括在 malloc 内部、在 printf 刷缓冲的中途。如果处理函数里再调用 malloc,就会破坏堆的内部状态,导致死锁或崩溃。
只有「异步信号安全」(async-signal-safe)的函数可以调用,man 7 signal-safety 里有完整列表。常见的:
| ✅ 安全 | ❌ 不安全 |
|---|---|
write() |
printf() / fprintf() |
_exit() |
exit() |
signal() / sigaction() |
malloc() / free() |
kill() |
任何加锁的函数 |
waitpid() |
syslog() |
对 volatile sig_atomic_t 赋值 |
大部分标准库函数 |
// ❌ 不安全:printf 用了缓冲和锁
void handler(int sig) {
printf("got %d\n", sig); // 可能死锁
}
// ✅ 安全:write 是系统调用,无锁无缓冲
void handler(int sig) {
const char msg[] = "got signal\n";
write(STDERR_FILENO, msg, sizeof(msg) - 1);
}
工业界的标准做法是「self-pipe trick」或 signalfd——处理函数里只做一件极简的事,把真正的工作交给主循环:
// 方案一:self-pipe,处理函数只往管道写一个字节
static int sigpipe_fd[2];
void handler(int sig) {
char c = (char)sig;
write(sigpipe_fd[1], &c, 1); // 唯一的操作,安全
}
// 主循环用 epoll 监听 sigpipe_fd[0],在正常的事件循环里处理信号
// 方案二:signalfd(Linux 特有,更优雅)
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigprocmask(SIG_BLOCK, &mask, NULL); // 先阻塞,不走处理函数
int sfd = signalfd(-1, &mask, 0);
// 现在信号变成了一个可读的 fd,能直接扔进 epoll,
// 完全避开了异步信号安全的所有问题
Go 的 signal.Notify 本质就是这个思路——运行时用一个专门的处理函数接收信号,转成 channel 消息,你的代码在普通的 goroutine 里处理,没有任何异步限制。这是 Go 处理信号比 C 简单得多的原因。
5. SIGPIPE:后端最容易忽略的信号
默认动作是终止进程,这一点让它特别危险。
触发场景:往一个读端已关闭的管道或 socket 写数据。
# 经典场景:管道的下游提前退出
yes | head -1
# head 读到一行就退出并关闭读端
# yes 继续写 -> 收到 SIGPIPE -> 被终止
# 这正是期望的行为,否则 yes 会无限跑下去
在网络服务里它是个麻烦:
客户端建立连接 -> 发请求 -> 【超时后主动断开】
v
服务端处理完,往 socket 写响应
v
对端已关闭 -> 收到 SIGPIPE -> 进程被杀
一个客户端的异常断开就能杀死整个服务进程,显然不可接受。所以所有网络服务都必须处理 SIGPIPE:
// C:忽略它,让 write() 返回 -1 并设 errno = EPIPE
signal(SIGPIPE, SIG_IGN);
// 之后在每次 write 后检查返回值和 errno
// Go:runtime 自动忽略了 SIGPIPE(对非标准输出的 fd)
// write 会返回 error(syscall.EPIPE),按普通错误处理即可
n, err := conn.Write(data)
if errors.Is(err, syscall.EPIPE) {
// 客户端已断开,正常记录即可,不要当严重错误
}
规律:写网络服务,第一件事就是忽略 SIGPIPE,把错误交给 write 的返回值处理。
6. 进程间通信的六种机制
信号只能传递「一个编号」,传数据需要真正的 IPC。
6.1 管道
匿名管道:就是 Shell 里的 |。本质是内核里的一段环形缓冲区(默认 64KB),单向。
ps -ef | grep nginx | awk '{print $2}'
特点:
- 单向,要双向通信得建两个
- 只能用于【有亲缘关系】的进程(父子、兄弟)
因为它没有名字,只能靠 fork 时继承 fd 来共享
- 写满会阻塞,读空也会阻塞(天然的流量控制)
- 读端全部关闭后写入 -> SIGPIPE
命名管道(FIFO):有文件名,所以任意进程都能用。
mkfifo /tmp/mypipe
ls -l /tmp/mypipe
# prw-r--r-- 1 lhx lhx 0 Aug 6 22:00 /tmp/mypipe
# ^ 类型是 p (pipe),大小永远是 0 —— 数据在内核里,不落盘
# 终端 A:写(会阻塞,直到有人读)
echo "hello" > /tmp/mypipe
# 终端 B:读
cat /tmp/mypipe
# hello
实用场景:让脚本能被外部触发
# 服务端:一个常驻循环,等命令
mkfifo /tmp/ctrl
while true; do
if read -r cmd < /tmp/ctrl; then
case "$cmd" in
reload) reload_config ;;
stop) break ;;
esac
fi
done
# 客户端:任何进程都能发命令
echo "reload" > /tmp/ctrl
坑:打开 FIFO 会阻塞,直到读写两端都就绪。
cat /tmp/mypipe会一直挂着等人写。用O_NONBLOCK打开可以避免,Shell 里可以exec 3<> /tmp/mypipe同时以读写方式打开来绕过。
6.2 Unix domain socket
这是现代 Linux 上最推荐的本机 IPC 方式,Docker、systemd、MySQL、PostgreSQL 的本地连接都用它。
ls -l /var/run/docker.sock
# srw-rw---- 1 root docker 0 Aug 6 10:00 /var/run/docker.sock
# ^ 类型是 s (socket)
# 用 curl 通过 Unix socket 访问 Docker API
curl --unix-socket /var/run/docker.sock http://localhost/version
相比 TCP 环回(127.0.0.1)的优势:
| 维度 | Unix socket | TCP 127.0.0.1 |
|---|---|---|
| 性能 | 快 30~50%(不走协议栈,无 TCP 握手、无校验和) | 要过完整 TCP/IP 栈 |
| 权限控制 | 用文件权限(chmod/chown/ACL) |
只能靠防火墙或应用层认证 |
| 端口 | 不占端口 | 占一个端口 |
| 能否跨机 | 不能 | 能 |
| 传递 fd | 能(SCM_RIGHTS) |
不能 |
「用文件权限做访问控制」是它最大的优势——把 socket 文件设为 660 且属组为 docker,就实现了「只有 docker 组的用户能调 Docker API」,不需要任何应用层认证代码。
# 本机数据库连接优先用 socket
mysql -S /var/run/mysqld/mysqld.sock # 而不是 -h 127.0.0.1
psql "host=/var/run/postgresql dbname=mydb"
「能传递文件描述符」是个独特能力——一个进程可以把自己打开的 socket/文件的 fd 通过 Unix socket 交给另一个进程。这是 nginx 平滑升级、systemd socket activation 的实现基础。
6.3 共享内存
最快的 IPC,因为完全没有数据拷贝。
进程 A 虚拟地址空间 -+
+--> 同一块物理内存
进程 B 虚拟地址空间 -+
A 写入 -> B 立刻可见,零拷贝,零系统调用
代价是没有任何同步机制,必须自己配合信号量或互斥锁,否则会有竞态。
两套 API:
# System V(老,接口丑,但到处都有)
# shmget / shmat / shmdt / shmctl
ipcs -m # 查看当前的共享内存段
# key shmid owner perms bytes nattch
# 0x00000000 32768 app 600 67108864 2
ipcrm -m 32768 # 手动删除(进程异常退出后可能残留)
# POSIX(新,推荐,本质是 tmpfs 文件)
# shm_open / mmap
ls -l /dev/shm/
# -rw------- 1 app app 67108864 Aug 6 22:00 myapp_cache
# ^ 就是普通文件,可以直接 ls/rm/cp
POSIX 共享内存就是 /dev/shm 下的文件(tmpfs,纯内存),所以它继承了文件的一切优点:能用文件权限控制、能用普通工具查看、进程退出后可以手动清理。
坑:
/dev/shm默认大小是物理内存的一半,而容器里默认只有 64MB。用共享内存做大缓存的应用(PyTorch DataLoader、Chrome headless)在容器里会报No space left on device或Bus error。用--shm-size=1g解决。另外共享内存算进 cgroup 的内存限制,会触发 OOM。
6.4 消息队列
# System V
ipcs -q
# msqid owner perms used-bytes messages
说实话:新项目不要用它。
它的定位(有边界的消息、可按类型接收)现在完全被更好的方案覆盖:本机通信用 Unix socket,跨机用 Kafka/RabbitMQ/NATS,进程内用语言自带的 channel/queue。System V 消息队列的问题是:内核缓冲区小(默认 16KB)、API 难用、不支持 select/epoll、跨机器完全不行。
知道它存在、面试能说清概念就够了。
6.5 信号量
信号量不传数据,它是同步原语——保护共享资源不被并发访问。
ipcs -s # 查看 System V 信号量
实际开发中你几乎不会直接用 System V 信号量,而是用:
- 进程内多线程:
pthread_mutex(本质是 futex) - 跨进程:POSIX 命名信号量
sem_open,或文件锁flock - Shell 脚本:
flock(第 5 篇讲过)
# Shell 里最实用的跨进程互斥:flock
exec 9>/var/run/mytask.lock
flock -n 9 || { echo "已有实例在运行"; exit 1; }
# 锁在进程退出时【自动释放】(fd 关闭),比 pid 文件可靠
6.6 选型对照表
| 机制 | 数据量 | 速度 | 双向 | 跨机 | 亲缘要求 | 实际用途 |
|---|---|---|---|---|---|---|
| 信号 | 1 个编号 | 快 | 否 | 否 | 无 | 控制指令(关闭、重载) |
| 匿名管道 | 流 | 中 | 否 | 否 | 需父子 | Shell 管道 |
| 命名管道 | 流 | 中 | 否 | 否 | 无 | 简单的脚本触发 |
| Unix socket | 流/报文 | 快 | 是 | 否 | 无 | 本机服务通信(首选) |
| 共享内存 | 大块 | 最快 | 是 | 否 | 无 | 高性能缓存、大数据交换 |
| 消息队列 | 报文 | 中 | 是 | 否 | 无 | 已被取代,不推荐 |
| TCP socket | 流 | 较慢 | 是 | 是 | 无 | 跨机通信 |
决策路径:
需要跨机器? -> TCP / gRPC / 消息中间件
只在本机?
+- 传大量数据、要极致性能? -> 共享内存 + 信号量(复杂,慎用)
+- 常规请求-响应? -> Unix domain socket <- 90% 的场景
+- 只是发个控制指令? -> 信号
+- Shell 脚本之间? -> 命名管道 或 直接用文件 + flock
7. 实战:一个完整的优雅关闭
把本篇内容串成一个能上生产的实现。
7.1 应用侧
package main
func main() {
// ① 建立就绪状态标志,供健康检查读取
var ready atomic.Bool
ready.Store(true)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
// liveness:只要进程活着就返回 200
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
// readiness:关闭中返回 503,让 K8s 把流量摘掉
if !ready.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
srv := &http.Server{Addr: ":8080", Handler: mux}
go srv.ListenAndServe()
// v 接下面的代码块(同一个 main 函数,为便于讲解拆成两段)
// ② 等待信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
log.Printf("收到信号 %v,开始优雅关闭", sig)
// ③ 第一步:先让健康检查失败,停止接收【新】流量
// 这一步必须在 Shutdown 之前,且要留出传播时间
ready.Store(false)
time.Sleep(5 * time.Second) // 等 endpoints 变更传播到所有节点
// ④ 第二步:停止 accept,等存量请求处理完
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("优雅关闭超时,仍有请求未完成: %v", err)
}
// ⑤ 第三步:按【依赖的反序】关闭资源
consumer.Stop() // 先停消息消费,不再产生新的 DB 操作
db.Close() // 再关数据库
redisClient.Close()
tracer.Shutdown(context.Background()) // 最后刷 trace 数据
log.Println("已完全退出")
}
三步的顺序是关键:先停新流量 → 再等存量完成 → 最后关依赖。任何一步提前都会丢请求。
7.2 K8s 配置
spec:
terminationGracePeriodSeconds: 45 # 必须 > 5 + 20 + 关资源的时间
containers:
- name: app
readinessProbe:
httpGet: {path: /readyz, port: 8080}
periodSeconds: 2 # 探测要够频繁,否则摘流量慢
failureThreshold: 1 # 一次失败就摘
livenessProbe:
httpGet: {path: /healthz, port: 8080}
periodSeconds: 10
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
时间预算必须自洽:
preStop(5s) + ready=false 传播(5s) + Shutdown(20s) + 关资源(~3s) = 33s
< 45s ✅
如果 terminationGracePeriodSeconds 设小了(比如默认的 30s),优雅关闭会在中途被 SIGKILL 打断,前面所有努力白费。
7.3 Dockerfile
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER 65532:65532
# ✅ exec 形式:应用直接成为 PID 1,能收到 SIGTERM
ENTRYPOINT ["/app"]
# ❌ 绝对不要写 ENTRYPOINT /app 或 CMD ./app(shell 形式)
7.4 验证
# ① 确认容器里 PID 1 就是应用本身
kubectl exec -it mypod -- ps -ef
# PID PPID CMD
# 1 0 /app <- ✅ 正确
# ② 实际测一次滚动更新,观察有没有 5xx
kubectl rollout restart deploy/myapp &
while true; do
curl -s -o /dev/null -w "%{http_code}\n" https://myapp/api/ping
sleep 0.1
done | sort | uniq -c
# 1823 200
# 0 502 <- ✅ 零错误
# ③ 确认进程是被 TERM 正常退出,不是被 KILL
kubectl get events --field-selector reason=Killing
# 如果看到容器退出码是 137 (128+9) = 被 SIGKILL,说明超时了
# 正常优雅退出的退出码应该是 0
退出码是最好的判据:0 = 正常退出,143(128+15)= 被 SIGTERM 且未捕获,137(128+9)= 被 SIGKILL 强杀,优雅关闭失败。
规律:看到容器退出码 137,就说明优雅关闭没生效或超时了。
8. 面试题
Q:kill -9 和 kill -15 有什么区别?为什么不推荐直接用 kill -9?
kill -15 发 SIGTERM,语义是「请求退出」,进程可以捕获它并执行清理逻辑;kill -9 发 SIGKILL,无法被捕获、阻塞或忽略,内核直接回收进程,进程没有任何机会执行代码。所以 kill -9 会导致:内存缓冲区没刷盘(丢日志/数据)、数据库连接没优雅关闭(服务端要等 TCP 超时才回收)、没从注册中心注销(其他服务继续发请求导致 502)、正在处理的请求被中断、分布式锁没释放。正确做法是先 SIGTERM,配合 kill -0 轮询检查,超时后才 SIGKILL 兜底——systemd 的 TimeoutStopSec 和 K8s 的 terminationGracePeriodSeconds 就是这个模式。
Q:哪些信号不能被捕获?为什么?
只有 SIGKILL(9) 和 SIGSTOP(19)。这是内核的硬性保证——如果允许进程捕获或忽略它们,就会出现「无法被终止」和「无法被暂停」的进程,系统失去最终控制权。所以任何「进程杀不掉」的情况都不是因为它屏蔽了 SIGKILL,而是进程处于 D(不可中断睡眠)状态——信号已经 pending 但进程在等 IO,内核不会在这个阶段处理信号,必须等 IO 完成。
Q:什么是可靠信号和不可靠信号?会导致什么实际问题?
编号 1~31 是传统信号(不可靠):不排队,如果一个信号已经处于 pending 状态,同种信号再次到达会被直接丢弃。编号 32~64 是实时信号(可靠):会排队,每次发送都被单独递送且保证顺序。实际问题最典型的是 SIGCHLD——同时有 3 个子进程退出时,处理函数可能只被调用一次,另外两个变成僵尸。所以正确的 SIGCHLD 处理必须写成 while (waitpid(-1, NULL, WNOHANG) > 0); 循环回收,直到没有可回收的子进程。
Q:信号处理函数里为什么不能调用 printf 或 malloc?
因为信号处理函数是异步插入执行的——它可能在主流程执行到任何一条指令时打断,包括正在 malloc 内部操作堆链表、或 printf 正持有 stdio 的锁的时刻。此时处理函数再调用 malloc/printf,会造成堆状态损坏或死锁(自己等自己已持有的锁)。只有异步信号安全的函数可以调用(man 7 signal-safety),常见的安全函数是 write、_exit、kill、waitpid,以及对 volatile sig_atomic_t 变量赋值。工业界做法是「self-pipe trick」或 Linux 的 signalfd——处理函数里只写一个字节到管道,把真正的处理交给主事件循环。Go 的 signal.Notify 本质就是这个思路,所以 Go 里处理信号没有这些限制。
Q:SIGPIPE 是什么?为什么网络服务必须处理它?
往一个读端已经关闭的管道或 socket 写数据时,内核发送 SIGPIPE,默认动作是终止进程。在网络服务里这非常危险:客户端超时主动断开连接后,服务端处理完请求去写响应,就会收到 SIGPIPE——一个客户端的异常断开能杀死整个服务进程。所以所有网络服务都必须 signal(SIGPIPE, SIG_IGN) 忽略它,然后检查 write() 的返回值和 errno == EPIPE 按普通错误处理。Go 的 runtime 已经自动忽略了非标准输出 fd 上的 SIGPIPE,Write 会返回 syscall.EPIPE 错误。顺带一提,Shell 里 yes | head -1 能正常结束靠的正是 SIGPIPE 的默认行为。
Q:K8s 滚动更新时为什么会出现 502?怎么彻底解决?
因为摘流量和发 SIGTERM 是并行发生的,存在竞态:Pod 被标记 Terminating 后,Endpoint Controller 开始把它从 endpoints 移除,同时 kubelet 给容器发 SIGTERM。但 endpoints 变更要传播到所有节点的 kube-proxy/ingress 并更新转发规则,需要几百毫秒到几秒。如果应用一收到 SIGTERM 就立刻关闭监听端口,这段时间内到达的请求会被拒绝 → 502。解决方案是在 preStop hook 里 sleep 几秒(应用此时仍正常服务),或者应用收到 SIGTERM 后先让 readiness probe 返回 503、sleep 一段时间再真正关闭。核心原则是:先停止接收新流量并等待传播,再处理完存量请求,最后退出。同时 terminationGracePeriodSeconds 必须大于「sleep + Shutdown 超时 + 关闭资源」的总和,否则会被 SIGKILL 打断。
Q:容器里的进程收不到 SIGTERM,可能是什么原因?
两个常见原因。① PID 1 的信号语义特殊:内核规定 PID 1 不执行信号的默认动作,如果它没显式注册 SIGTERM 处理函数,信号被丢弃。而 Dockerfile 里写 CMD ./myapp(shell 形式)会让 /bin/sh 成为 PID 1,sh 不会转发信号给子进程,应用永远收不到——必须用 exec 形式 CMD ["./myapp"] 或 CMD exec ./myapp。② 应用本身没实现信号处理。验证方法是 kubectl exec -- ps -ef 看 PID 1 是不是应用本身,以及看容器退出码——137(128+9)说明是被 SIGKILL 强杀的,0 才是正常优雅退出。另外容器里的僵尸进程问题也源于 PID 1 不履行 init 职责,用 docker run --init 或 tini 解决。
Q:本机的两个服务通信,用 TCP 127.0.0.1 还是 Unix domain socket?
优先 Unix domain socket,两个原因。① 性能:不走 TCP/IP 协议栈,没有握手、序号、校验和、拥塞控制的开销,通常比环回 TCP 快 30~50%。② 权限控制:socket 是文件系统里的一个节点,可以直接用 chmod/chown/ACL 控制谁能连接——比如 Docker 把 /var/run/docker.sock 设为 660 属组 docker,就实现了「只有 docker 组能调 API」,不需要写任何认证代码。此外它不占端口,还能通过 SCM_RIGHTS 传递文件描述符(nginx 平滑升级、systemd socket activation 的基础)。唯一的限制是不能跨机器。MySQL、PostgreSQL、Docker、systemd 的本地连接都默认走 Unix socket。
Q:nginx -s reload 的时候,nginx 收到的是什么信号?logrotate 之后为什么要给 nginx 发 USR1?
reload 发的是 SIGHUP。SIGHUP 原意是「终端挂断」,但业界形成了「守护进程收到 SIGHUP 就重新加载配置」的约定。USR1 是 nginx 自定义的「重新打开日志文件」信号——这与第 2 篇讲的文件删除机制直接相关:logrotate 把 access.log 改名为 access.log.1 后,nginx 持有的 fd 仍然指向同一个 inode,会继续往改名后的文件里写,新的 access.log 永远是空的。发 USR1 让 nginx 重新 open() 一次,才会写入新文件。这也是为什么 logrotate 配置里必须有 postrotate kill -USR1 ... 这一段。
上一篇:Linux-07 进程调度与上下文切换 | 下一篇:Linux-09 用户态、内核态与系统调用
xingliuhua