目录

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 deviceBus 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 -9kill -15 有什么区别?为什么不推荐直接用 kill -9

kill -15SIGTERM,语义是「请求退出」,进程可以捕获它并执行清理逻辑;kill -9SIGKILL无法被捕获、阻塞或忽略,内核直接回收进程,进程没有任何机会执行代码。所以 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:信号处理函数里为什么不能调用 printfmalloc

因为信号处理函数是异步插入执行的——它可能在主流程执行到任何一条指令时打断,包括正在 malloc 内部操作堆链表、或 printf 正持有 stdio 的锁的时刻。此时处理函数再调用 malloc/printf,会造成堆状态损坏或死锁(自己等自己已持有的锁)。只有异步信号安全的函数可以调用(man 7 signal-safety),常见的安全函数是 write_exitkillwaitpid,以及对 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 --inittini 解决。

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 用户态、内核态与系统调用