Appearance
22 容器与 Linux 运行时
详细程度:速通 · 约 30 分钟 材料:旧章
notes/p23-linux-runtime.md、notes/p25-containers-cloud.md· 本节命令在本机(go1.26.4,docker 29.4,arm64)复核过 前置:06 并发一、07 并发二(context 与优雅关停)
先看一个让人愣住的对照
同一个容器,两种 PID 1。第一种入口是 shell 脚本,里面再启动 Go 程序;第二种 ENTRYPOINT 直接是 Go 二进制。docker stop 的表现:
text
$ docker run -d --name c1 alpine:3.22 sh -c 'sleep 500'
$ docker stop -t 12 c1 # 卡满 12 秒才结束
$ docker run -d --name c2 goapp-image
$ docker stop c2 # 1 秒内退出,日志里有 shutdown 记录〔实测〕本机 docker 29.4 上测的:shell 做 PID 1 的容器,docker stop -t 12 花了 12.3 秒;exec 形式直起 Go 程序的容器不到 1 秒就停了(复核时测得 0.45 秒),容器日志里能看到 got signal, draining 和 shutdown err: <nil>。
为什么差一个数量级?因为信号送到了谁手里。docker stop 先给 PID 1 发 SIGTERM。shell 自己是 PID 1,它收到 SIGTERM 后既不退出也不转发,你的 Go 程序什么都没收到;等够超时时间,docker 只能发 SIGKILL,在途请求全断。exec 形式里 Go 程序自己就是 PID 1,注册过 handler,收到信号走优雅关停。
这一个现象背后是本节的全部内容:容器里的 Go 进程在操作系统眼里只是个普通进程,外面套了三层东西:命名空间管它看得见什么,cgroup 管它能用多少,镜像文件系统管它的根目录是什么。要操心的只有四件事:怎么收到停止信号、文件描述符用得对不对、被限了多少资源、编排器怎么判断它活着。前两件是普通 Linux 进程的知识,后两件是容器加的。
和你已知语言的差异
| 问题 | Java / Kotlin | Rust | Go |
|---|---|---|---|
| 信号默认行为 | JVM 装了 shutdown hook | 默认终止,要 signal-hook 或 tokio::signal | 默认终止,用 signal.NotifyContext 转成 ctx.Done() |
| 容器里的 CPU 感知 | JVM 自己读 cgroup | 线程数由代码或库决定 | Go 1.25 起 runtime 读 cgroup CPU 限额 |
| 交付物 | jar 加 JRE 镜像 | 静态二进制(要配 musl target) | 静态二进制(默认就行),镜像可以只有几十 MB |
| 内存上限 | JVM 堆参数 | 无 GC,不需对齐 | GOMEMLIMIT 软限制,GC 对齐 |
第三行是 Go 在容器时代最省心的地方:CGO_ENABLED=0 编出来的二进制不链任何系统库,扔进空白的 alpine 就能跑。Java 要带一整个运行时,Rust 配 musl target 也能静态,但默认 target 链 glibc,丢进 alpine 会缺库。
一、信号:SIGTERM 送达之后发生了什么
先看两种行为差多少。同一个程序做两个实验:起一个不装 handler 的自己,发 SIGTERM,等它死;再起一个装了 handler 的自己,发同一个信号,看结果:
go
package main
import (
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"syscall"
"time"
)
// 子进程分支:当靶子。os.Args[2] 是 raw(不装 handler)或 ctx(装)。
func main() {
if len(os.Args) > 2 && os.Args[1] == "term" {
if os.Args[2] == "ctx" {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
defer stop()
syscall.Kill(os.Getpid(), syscall.SIGTERM)
<-ctx.Done()
fmt.Println("ctx: captured, exit normally")
return
}
time.Sleep(10 * time.Second) // raw:安静等信号,默认处置接管
fmt.Println("raw: survived")
return
}
cmd := exec.Command(os.Args[0], "term", "raw") // 实验 1:默认处置
_ = cmd.Start()
time.Sleep(200 * time.Millisecond)
syscall.Kill(cmd.Process.Pid, syscall.SIGTERM)
err := cmd.Wait()
fmt.Println("raw mode wait err:", err)
cmd2 := exec.Command(os.Args[0], "term", "ctx") // 实验 2:装 handler
_ = cmd2.Start()
time.Sleep(200 * time.Millisecond)
syscall.Kill(cmd2.Process.Pid, syscall.SIGTERM)
err2 := cmd2.Wait()
fmt.Println("ctx mode wait err:", err2)
}text
raw mode wait err: signal: terminated
ctx mode wait err: <nil>两个实验,同一个信号,两种结局。不装 handler 的进程被 SIGTERM 直接杀死(signal: terminated),装了 handler 的进程把信号变成 ctx.Done(),从容退出(<nil>)。这就是默认处置和捕获的区别:SIGTERM 的默认处置是直接终止进程,Go 代码一行都不会执行,一行日志都不会留下。退出码也有约定:被信号杀死的进程,shell 和 docker 报告的退出码是 128 加信号编号(内核本身只记录"被哪个信号杀死",加 128 是 shell 的换算)。〔实测〕shell 里 kill -TERM 得 143(128+15),kill -9 得 137(128+9)。
所以优雅关停必须先捕获信号。Go 的做法是把信号变成 context 的取消:
go
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: "127.0.0.1:18222", Handler: mux}
go srv.ListenAndServe()
<-ctx.Done() // 收到信号,从这里醒来
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := srv.Shutdown(sctx) // 停止接新连接,等在途请求收尾〔实测〕我在本机写了个 /slow 接口(睡 1.5 秒)验证完整时序:请求发出后 300 毫秒发 kill -TERM,客户端仍然收到了响应体 done,进程依次打印 got signal, draining; inflight= 1、shutdown err: <nil> 后退出。对照组同一时刻发 kill -9:进程当场消失,客户端拿到连接错误(curl 状态码 000)。上面的片段就是这条路径的形状,不是可运行程序(mux 没定义)。
三条规则,前一条是后一条的前提:
SIGKILL和SIGSTOP不能被捕获,这是内核保留的。docker stop先发SIGTERM,等默认 10 秒(docker stop -t可改)才发SIGKILL。你的Shutdown超时必须小于这个窗口,否则容器照样被强杀。〔实测〕本机docker kill -s KILL杀掉的容器ExitCode是 137。信号 handler 里只通知,清理逻辑放在主流程。
NotifyContext把信号变成ctx.Done(),你阻塞在<-ctx.Done()的那个 goroutine 被唤醒,该干什么在主流程里干。C 语言里 handler 只能调 async-signal-safe 函数,Go 把这件事整个搬进了普通 goroutine 的世界,没有那条限制。PID 1 特殊:内核不给容器里的 PID 1 套默认信号处置,PID 1 自己没有为
SIGTERM装 handler,信号就被忽略。容器的 ENTRYPOINT 就是 PID 1,sh没装这个 handler,所以开头那个 shell 容器才会卡满超时。〔实测〕docker stop -t 12对 shell-PID-1 容器耗时 12.3 秒,其中 12 秒是在等超时;不加-t实测 10.2 秒,印证默认宽限期是 10 秒。这条规则对 Go 二进制要小心套用:Go runtime 启动时就替进程装好了自己的信号处理,所以"没人装 handler"在 Go 里并不成立。〔实测〕把一个不调用
signal.Notify的 Go 程序直接当 PID 1,docker stop不会卡,0.2 秒就返回,退出码是 2(不是 143):进程被 runtime 就地终止,没有排空、没有日志。所以 shell 入口的问题是"信号被 shell 吃掉",Go 入口的问题是"信号到了,但没有人把它变成优雅关停"。
开头的结论现在能完整推出来了:exec 形式的 ENTRYPOINT 让你的 Go 程序当 PID 1,装上 handler 才接得住 docker stop 的 SIGTERM。缺 exec 形式,信号到不了 Go 程序,容器卡满宽限期被强杀;缺 handler,信号到了也只是被当场终止。两种缺法,在途请求都会断。
二、文件描述符:一切 I/O 的把手
Unix 里"一切皆文件"落到实处就是 fd 表:0 是 stdin,1 是 stdout,2 是 stderr,之后每开一个文件、socket、管道就占一个新号。进程能开的 fd 有上限(ulimit -n),只开不关的话,fd 只增不减,直到撞上上限。下面这段程序自己数给自己看:
go
package main
import (
"fmt"
"os"
)
func countFDs() int {
entries, _ := os.ReadDir("/dev/fd") // macOS 和 Linux 都有这个目录
return len(entries)
}
func main() {
fmt.Println("start:", countFDs())
var keep []*os.File
for i := 0; i < 30; i++ {
f, err := os.Open("/etc/hosts")
if err != nil {
fmt.Println("open err:", err)
return
}
keep = append(keep, f) // 拿到 fd 从不 Close
}
fmt.Println("after 30 opens:", countFDs())
for _, f := range keep {
f.Close()
}
fmt.Println("after close all:", countFDs())
}text
start: 5
after 30 opens: 35
after close all: 5〔实测〕go run . 在本机输出上面三行:每开一个文件 fd 表加一行,Close 之后如数奉还。上限是 ulimit -n,〔实测〕把循环改成无限,在 bash 里 ulimit -n 64 后跑同一个程序,大约开到第 60 个就被内核拒绝(运行时自己也占了几个 fd,具体数字随平台略有出入),报 open /etc/hosts: too many open files。两条最常见的泄漏源都在 Go 代码里:http.Response.Body 没关(每次请求泄漏一个 fd 加连接),os.Open 之后没有 defer f.Close()。
排查方法:lsof -p <pid> 看这个进程的 fd 表,隔几秒再看一次,数量单调增长就是泄漏。日志为什么该写 stderr?因为 docker 的日志驱动收集的就是容器 PID 1 的 fd 1 和 fd 2 字节流,你写进 stderr 的每一行都会出现在 docker logs 里。
三、cgroup:限额与 GOMAXPROCS 的联动
容器限了 CPU,你的程序知道吗?先看限额长什么样。--cpus 1.5 落到 cgroup 里是一个文本文件:
text
$ docker run -d --name demo --cpus 1.5 goapp-image
$ docker exec demo cat /sys/fs/cgroup/cpu.max
150000 100000〔实测〕docker exec demo cat /sys/fs/cgroup/cpu.max 打出 150000 100000,150000/100000 = 1.5 核,这是 cgroup v2 的"每 100ms 配额 150ms"写法。
关键联动在 Go 侧:Go 1.25 起,runtime 默认读这个文件来设 GOMAXPROCS,并周期性更新(GODEBUG=containermaxprocs 与 updatemaxprocs 默认开)。〔实测〕本机 go doc runtime.GOMAXPROCS 的 Default 与 Implementation details 两节写明:默认值取逻辑核数、亲和性掩码、cgroup 配额三者的最小值,配额不是整数时向上取整,且不会低于 2(机器本身少于 2 核除外)。
我在 1.5 核限额的容器里跑了探针程序(GOMAXPROCS(0) 加 NumCPU),〔实测〕输出 GOMAXPROCS= 2 NumCPU= 10:限额向上取整成 2,而宿主的 10 个核只看不用。〔实测〕同法测的其余组合:--cpus 0.5 也是 2(下限兜底)、--cpus 4 是 4、不限额是 10、--memory 64m 不影响它(还是 10)。
为什么要做这个联动?因为调度器按 GOMAXPROCS 开 P(逻辑处理器)。限额 1.5 核却开 10 个 P,就是 10 个执行流抢 1.5 核的配额:cgroup 节流(throttling)一旦触发,整个进程在窗口期内全部暂停,延迟毛刺就是这么来的。runtime 自动对齐之后,1.5 核就开 2 个 P,抢配额的现象从根上消失。
一个很容易漏掉的细节:这个行为只在 go.mod 的 go 行是 1.25 及以上时默认开启。containermaxprocs 和 updatemaxprocs 在语言版本 1.24 及以下默认是关的。〔实测〕A/B 验证过:同一个容器(--cpus 1.5,cpu.max 同为 150000 100000)、同一套工具链,只把 go.mod 从 1.26.4 换成 1.24.0 重新编译,GOMAXPROCS 从 2 变回 10。升级了工具链但 go.mod 还写 1.24,容器里仍然会用宿主核数。
内存是另一套机制:GOMEMLIMIT(或代码里的 debug.SetMemoryLimit)是软限制,它告诉 GC 在接近上限时更积极,但不会拦住分配。〔实测〕设了 10MB 限制的程序继续分配到 100MB 照样活着,输出 allocated MB: 100 still running, limit not enforced。它也不覆盖全部内存:按 go doc runtime/debug.SetMemoryLimit 的说法,cgo 分配的内存、内核替进程持有的内存都不算在内。给容器设内存限额时,把 GOMEMLIMIT 设成限额的九成左右留出余量,是常见做法(九成是经验值,不是文档规定)。
四、镜像:多阶段构建,构建环境不进交付
Go 编译出静态二进制,交付镜像里除了基础层只需要这一个文件。标准形状是多阶段构建:
dockerfile
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # 依赖层:go.mod 不变就命中缓存
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app .
FROM alpine:3.22
COPY --from=build /out/app /usr/local/bin/app
USER nobody
ENTRYPOINT ["/usr/local/bin/app"]COPY --from=build 是两个阶段之间唯一的桥,builder 里的 Go 工具链、模块缓存、源码都不进交付。大小怎么变?旧章 notes/p25-containers-cloud.md 在同款结构上量过:构建镜像 golang:1.26-alpine 351MB,交付镜像 26.6MB。这组数字来自旧章,这次复核没有重新构建镜像,未在本机复核;基础层 alpine:3.22 本机显示 14.4MB,可以看出交付镜像的大头不是你的二进制。
〔实测〕交叉编译这一步单独验证过:CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build 产物用 file 看是 ELF 64-bit ... statically linked;对一个只用 fmt 和 os/signal 的小程序,加 -trimpath -ldflags="-s -w" 把二进制从 2.4MB 压到 1.6MB(去掉符号表和调试信息,-trimpath 去掉的是源码路径)。
静态链接是整个玩法的前提。它保证二进制不链 glibc 或 musl 的任何 so,alpine 才装得下它。漏了 GOOS=linux 会得到什么?〔实测〕把本机(macOS)编的 Mach-O 二进制挂进 alpine 容器执行,直接失败,退出码 126。Docker Desktop 给出的报错是 Cannot run macOS (Mach-O) executable in Docker: Exec format error;在原生 Linux 上你看到的是内核的 exec format error(旧章 notes/p23-linux-runtime.md 的记录)。file 命令一眼定性:ELF 是 Linux 的,Mach-O 是 macOS 的。
三个习惯,动机都能从层机制推出来:
- 先 COPY 依赖清单再 COPY 源码:镜像是一叠只读层,指令不变加输入不变就直接复用。依赖层前置,改源码不会触发
go mod download。 - 非 root 运行:
USER nobody写进镜像元数据,容器里id显示uid=65534(nobody)。〔实测〕这一条本机复核过。 - 用
.dockerignore排除.git等:构建上下文越大传输越慢,上下文是docker build的目录参数。
五、健康检查:活着不等于能用
进程在跑(PID 1 活着)不等于能服务(端口通、业务逻辑没死锁)。探针就是用来区分这两件事的,编排器可以据此决定要不要发流量、要不要重启:
text
$ docker run -d --name hc --health-cmd "nc -z localhost 9999 || exit 1" \
--health-interval 1s --health-retries 2 alpine:3.22 sleep 300
$ docker ps --format "{{.Status}}"
Up 4 seconds (unhealthy) # 没人监听 9999
$ docker exec -d hc sh -c 'while true; do nc -l -p 9999; done'
$ docker ps --format "{{.Status}}"
Up 8 seconds (healthy) # 端口通了〔实测〕上面是本机 docker 的输出(秒数每次略有浮动):探针命令连续失败达到 retries 次判 unhealthy,恢复后翻回 healthy。监听用 while 循环是因为 busybox 的 nc -l 接受一次连接就退出,单次监听只能让探针成功一次,随后又会翻回 unhealthy。另一个观察:容器一直处于 unhealthy,docker inspect 里状态仍是 running、RestartCount 为 0。单机 docker 只负责把状态标出来,不会替你重启;"据此摘流量、重启"是 Swarm 或 K8s 这类编排器的事(K8s 的探针语义本节未在本机验证)。
设计原则有三条,核心是探自己,不探下游(这是业界经验,不是某份文档的规定):
- 别在探针里查数据库或别的服务:如果探针失败会触发重启,下游挂了你也被判死并重启,故障被放大成雪崩。
- 别探得太频繁:探针流量也是流量。
- 探法上,
nc -z只证明"端口有 listener 在 accept",抓不到 handler 死锁(端口还在,请求进去没人处理);打一个真实的轻量端点(比如GET /healthz)覆盖到 handler 链路,代价是探针请求进业务日志,用固定前缀方便过滤。
小结
- 信号默认处置是直接终止,Go 代码一行不执行;优雅关停靠
signal.NotifyContext把信号变成ctx.Done(),清理放在主流程。〔实测〕机制见go doc os/signal.NotifyContext。 - 退出码 128 加信号编号:143 是 SIGTERM,137 是 SIGKILL(也是容器 OOM 或强杀的标志)。
- PID 1 对没装 handler 的信号不做默认处置,shell 当 PID 1 会吃掉
SIGTERM;ENTRYPOINT必须 exec 形式(JSON 数组),让 Go 程序自己当 PID 1。Go 程序没装 handler 时不会卡,而是被 runtime 当场终止(实测退出码 2)。 - fd 是一切 I/O 的把手,泄漏到
ulimit -n就是too many open files;lsof -p看趋势。 - cgroup 的
cpu.max是"每 period 配 quota";Go 1.25 起 runtime 自动按它设GOMAXPROCS(向上取整、下限 2、周期更新),前提是go.mod的go行在 1.25 及以上。 GOMEMLIMIT是 GC 的软目标,不是硬墙;不含 cgo 和内核内存。- 多阶段构建把构建环境留在第一阶段,交付镜像只有基础层加静态二进制;依赖层前置保缓存,非 root 运行。
- 探针探自己不探下游;
nc -z抓不到 handler 死锁,真实端点才覆盖业务链路。
常见坑
- 以为
kill会让 Go 程序优雅退出:不捕获信号就直接终止,一行日志都没有。 - 入口用 shell 形式(
ENTRYPOINT ["sh", "-c", "go-app"]):信号被 shell 吃掉,docker stop卡满宽限期后强杀。有的 shell 对-c后只有一条命令会自动 exec 替换自己,有的不会,行为取决于具体 shell,别赌。 go.mod里go行停在 1.24:容器里GOMAXPROCS仍然等于宿主核数,限了 CPU 也白限。Shutdown超时设得比容器强杀窗口还长:等不到排空就被SIGKILL。- 把
GOMEMLIMIT当硬限制:它是 GC 的软目标,超限不会被运行时拦住。 http.Response.Body不关、os.Open不defer Close:fd 缓慢泄漏,长跑必死。
判断题与答案
先自己答,再看后面。
- 为什么
kill -9下连一行日志也打不出来? - 容器限 1.5 核,Go 1.25 以后
GOMAXPROCS是多少?go.mod写go 1.24呢? - 探针里调用下游数据库有什么问题?
答案
SIGKILL不可捕获,默认处置由内核执行,进程没有机会跑任何用户代码;被信号杀死的进程退出码是 128 加信号号,137 就是这么来的。- 1.5 核向上取整是 2(下限也是 2)。
go.mod是 1.24 时containermaxprocs默认关闭,runtime 退回宿主核数,实测同一个容器里从 2 变回 10。 - 下游挂了探针也失败,编排器把健康的你也判死并重启,故障从下游放大到整个服务;探针应该只测自己的端口或一个不打下游的端点。
在项目里
dnsproxi-go/main.go 的关停路径就是本节的形状:main 用 signal.NotifyContext 接住 SIGTERM/SIGINT 转 ctx 取消,Proxy.Run(ctx) 里一个只等 <-ctx.Done() 的 goroutine 关闭 UDP 监听口,阻塞在 ReadFromUDP 的读循环立刻返回,先 close(done) 停掉 expirer 再 wg.Wait() 等在途查询排空。sigterm 优雅退出与关停死锁的回归测试在 dnsproxi-go/sigterm_test.go 和 dnsproxi-go/shutdown_repro_test.go。容器内行为未在本机验证(需要 Linux 容器),机制同第一节。
想深入去哪查
- 旧长版:
notes/p23-linux-runtime.md(进程、信号、fd、容器三件套)、notes/p25-containers-cloud.md(多阶段构建、限额实测、健康检查)。 - 原材料:无直接对应单元,本节事实来自旧章的实测记录与本机复核。
- 官方:
go doc runtime.GOMAXPROCS(Default 与 Implementation details 两节)、$GOROOT/doc/godebug.md(containermaxprocs、updatemaxprocs)、go doc runtime/debug.SetMemoryLimit、go doc os/signal.NotifyContext。