用纯汇编编写容器的微型 PID 1(x86-64 + ARM64)

发布: (2025年12月7日 GMT+8 02:43)
7 min read
原文: Dev.to

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 NASMARM64 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 运行时,容器内部的一切都挂在它身上:

Container PID 1 problem

如果 your‑app

  • 忽略 SIGTERMSIGINT 等,docker stop 将无法正常工作,k8s 最终会发送 SIGKILL
  • 从不调用 wait() / waitpid(),则已退出的子进程会变成 僵尸,直到 PID 1 将其清理

像 Tini 或 mini-init-asm 这样的 init 会以 PID 1 的身份插入自身,使你的应用成为“普通的子进程”:

Init inserts itself as PID 1

此时 PID 1:

  • 将信号转发给 进程组
  • 回收僵尸进程
  • 决定何时退出以及使用何种状态码

mini-init-asm 的高层架构

mini-init-asm 采用以 PGID 为中心的设计:

  1. 在 PID 1 中 屏蔽信号
  2. 创建子进程,并在新会话 + 进程组下运行(PGID = 子进程 PID)。
  3. 创建:
    • 一个 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 到优雅关闭的完整流程:

Docker run → graceful shutdown

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

Escalation to SIGKILL

纯汇编实现:结构

代码仓库的组织方式旨在保持汇编的可读性和可审查性:

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_waitreadkillwaitpidexit 等系统调用,全部在没有外部库的情况下完成。

Back to Blog

相关文章

阅读更多 »

我放弃做 FinOps 咨询

几个月前,我开始支持不同的客户实施资源和基础设施优化策略。这是一个复杂的决定……