用纯汇编编写容器的微型 PID 1(x86-64 + ARM64)
Source: Dev.to
大多数人在构建 Docker 镜像时并不会多想 PID 1。我们只是在 Dockerfile 上随手加个 CMD,运行容器,然后继续下一步——直到有一天:
docker stop永久卡住Ctrl+C无法终止容器- 你发现容器内部堆满了僵尸进程
所有这些症状都指向同一个根本原因:你的应用以 PID 1 运行且没有表现得像 init 进程。在 Linux 中,PID 1 在信号处理和僵尸回收方面有特殊语义,而普通应用很少能正确实现这些行为。
像 Tini 这样的工具完美解决了这个问题:一个运行在 PID 1 的小进程,负责把信号转发给你的应用并回收僵尸进程。Docker 甚至通过 --init 选项内置了 Tini。
在本文中,我将演示一种替代实现:mini-init-asm,一个专为容器设计的 PID 1,完全使用 x86‑64 NASM 与 ARM64 GAS 编写。它并不是要在所有场景下取代 Tini,而是:
- PGID‑first init 用于容器(始终使用独立的会话和进程组)
- 纯汇编实现 同样的核心思路
- 包含一些额外技巧,如 崩溃后自动重启
设计目标
在写下任何汇编代码之前,我先设定了几条约束。
行为像一个负责任的 PID 1
- 将终止信号转发给 整个进程组
- 回收僵尸进程,包括需要时的孙子进程(子收割器模式)
- 以有意义的状态退出(子进程退出码或
128+signal形式)
小巧且易审计
- 不依赖 libc、运行时或隐藏的魔法
- 每种架构仅生成一个静态链接的二进制文件
- 控制流清晰、可审查
友好容器化
- 易于放入
FROM scratch镜像 - 明确支持优雅关闭(宽限期 +
SIGKILL升级) - 可选的重启逻辑,但不是完整的进程管理器
从第一天起支持 amd64 与 arm64
- x86‑64 NASM 用于“普通”Docker 主机
- ARM64 GAS 用于现代 ARM 服务器和 SBC
容器 PID 1 问题示意图
当你的应用直接以 PID 1 运行时,容器内部的一切都挂在它身上:

如果 your‑app:
- 忽略
SIGTERM、SIGINT等,docker stop将无法正常工作,k8s 最终会发送SIGKILL - 从不调用
wait()/waitpid(),则已退出的子进程会变成 僵尸,直到 PID 1 将其清理
像 Tini 或 mini-init-asm 这样的 init 会以 PID 1 的身份插入自身,使你的应用成为“普通的子进程”:

此时 PID 1:
- 将信号转发给 进程组
- 回收僵尸进程
- 决定何时退出以及使用何种状态码
mini-init-asm 的高层架构
mini-init-asm 采用以 PGID 为中心的设计:
- 在 PID 1 中 屏蔽信号。
- 创建子进程,并在新会话 + 进程组下运行(PGID = 子进程 PID)。
- 创建:
- 一个
signalfd,监听HUP, INT, QUIT, TERM, CHLD以及可选的额外信号 - 一个
timerfd,用于优雅关闭的时间窗口 - 一个
epoll实例,监视上述两个 fd
- 一个
在 epoll_wait 上运行 事件循环:
- 软信号(
TERM/INT/HUP/QUIT):转发给整个进程组并启动宽限计时器。 SIGCHLD:使用waitpid(-1, WNOHANG)回收子进程,并追踪主子进程。- 计时器到期:若子进程仍存活,向进程组发送
SIGKILL。
退出时,mini-init-asm 返回:
- 子进程的退出状态(正常退出),或
BASE + signal_number(若子进程因信号终止)。
BASE 可以通过 EP_EXIT_CODE_BASE 自定义,默认值为 128(POSIX shell 约定)。
序列:从 docker run 到优雅关闭
运行 init 的方式如下:
mini-init-amd64 -- ./your-app --flag
下面的示意图展示了从 docker run 到优雅关闭的完整流程:

如果子进程 忽略 SIGTERM 并且在计时器到期时仍然存活,mini-init-asm 将升级为:

纯汇编实现:结构
代码仓库的组织方式旨在保持汇编的可读性和可审查性:
src/amd64/ # NASM 源码(SysV ABI,x86‑64)
src/arm64/ # GAS 源码(AArch64)
include/syscalls_*.inc # 各架构的系统调用号
include/macros*.inc # 系统调用 / 日志的辅助宏
示例系统调用包装(NASM)
; rax = syscall number
; rdi, rsi, rdx, r10, r8, r9 = args
%macro SYSCALL 0
syscall
cmp rax, 0
jge .ok
; 如有需要在 rax 中处理 -errno...
.ok:
%endmacro
Fork 并 exec 子进程(NASM)
; 1) Fork/clone a child
mov eax, SYS_clone
mov rdi, SIGCHLD ; flags
xor rsi, rsi ; child_stack (unused for simple clone)
xor rdx, rdx
xor r10, r10
xor r8, r8
xor r9, r9
syscall
cmp rax, 0
je .in_child
jl .fork_error
; ----- Parent (PID 1) -----
; rax = child_pid
mov [child_pid], rax
; continue with signalfd/epoll setup...
jmp .parent_after_fork
.in_child:
; 2) Create new session and PGID
mov eax, SYS_setsid
syscall
; Optionally setpgid(0, 0)
xor rdi, rdi
xor rsi, rsi
mov eax, SYS_setpgid
syscall
; 3) execve() target program
mov eax, SYS_execve
mov rdi, [target_path]
mov rsi, [target_argv]
mov rdx, [target_envp]
syscall
; If execve returns, it's an error → exit(127)
mov edi, 127
mov eax, SYS_exit
syscall
在 ARM64 端的实现逻辑相同,只是使用 x8 作为系统调用号,x0‑x5 作为参数。
epoll + signalfd + timerfd 循环
主事件循环是大部分逻辑所在。伪 C 代码如下:
for (;;) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (n < 0 && errno == EINTR) continue;
for (int i = 0; i < n; i++) {
if (events[i].data.fd == signalfd_fd) {
struct signalfd_siginfo si;
read(signalfd_fd, &si, sizeof(si));
int sig = si.ssi_signo;
if (is_soft_shutdown(sig)) {
forward_to_pgid(sig);
if (!grace_timer_armed) {
arm_timerfd(grace_seconds);
}
} else if (sig == SIGCHLD) {
reap_children();
}
} else if (events[i].data.fd == timerfd_fd) {
/* Grace period expired – force kill */
killpg(pgid, SIGKILL);
}
}
}
实际的汇编实现使用 epoll_wait、read、kill、waitpid、exit 等系统调用,全部在没有外部库的情况下完成。