KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

07 · 排障、资源限制与选型边界 — keel 龙骨

这一章回答:容器行为不对时,你手上有什么工具?exit code 137 之外的那些退出码怎么读?以及哪些问题根本不该用容器解决?

这一章回答:容器行为不对时,你手上有什么工具?exit code 137 之外的那些退出码怎么读?以及哪些问题根本不该用容器解决?

前面六章都在讲"怎么让容器正确"。这一章讲它已经不对了的时候。

有一条贯穿全章的判断,先给出来:

容器给你的隔离,同时也是你排查时的盲区。 你在容器里"看不到"的东西,不等于它不存在——它在宿主上、在内核里、在另一个容器的命名空间里。

现场

一个服务被反复重启,编排层记录了十几次重启。翻它的日志——空白的。没有异常、没有堆栈、没有任何一行输出,只有启动时那几行。

第一反应通常是"日志配置错了"或"应用崩在日志初始化之前"。有时候是,但这个现场的特征是连启动信息之后什么都没有:说明进程是在正常工作了一段时间之后被从外部终止的,而终止它的那个东西没有给它写日志的机会。

最典型的两个原因是:

两者都表现为"日志空白 + 退出码 137",所以必须先分流,不能直接猜其中一个。

一、退出码是一张分流表

容器进程结束时,你会看到一个数字。Linux 的 shell 约定是"信号致死 = 128 + 信号编号",容器运行时沿用了这套约定:

退出码 算法 含义 典型根因
0 — 正常退出 一次性任务成功;长驻服务被正常停机
1 — 应用自己报的通用错误 配置错、依赖缺、启动即失败
126 — 命令找到但不可执行 权限位没有 +x;可执行文件架构不匹配
127 — 命令找不到 CMD 里的路径写错;文件名大小写错
137 128 + 9(SIGKILL) 被强杀 有两个来源:内存超限 OOM / 停止窗口耗尽
139 128 + 11(SIGSEGV) 段错误 原生扩展崩溃、native addon 与运行时不匹配
143 128 + 15(SIGTERM) 被 SIGTERM 终止 进程没有注册处理器,于是被默认动作终止

137 的两个来源必须分流,因为处置方向完全相反:

flowchart TD
  E["容器异常退出<br/>退出码 137"] --> Q1{"docker inspect 里<br/>State.OOMKilled<br/>是否为 true?"}

  Q1 -->|"true"| OOM["内存超限被杀<br/>① 看限制值 vs 实际用量<br/>② 找内存增长点"]
  Q1 -->|"false"| Q2{"是否在停止流程中<br/>(发布 / 缩容 / 手动 stop)?"}

  Q2 -->|"是"| GRACE["停止窗口耗尽<br/>① 检查 CMD 是否 exec 形式(06 章)<br/>② 检查应用有没有注册 SIGTERM<br/>③ 检查三层时间预算"]
  Q2 -->|"否"| Q3{"ExitCode 是不是 139 / 126 / 127?"}

  Q3 -->|"139 段错误"| NATIVE["原生依赖崩溃<br/>检查 native addon 与基础镜像 libc<br/>(03 章 alpine / musl)"]
  Q3 -->|"126 / 127"| CMD["命令不可执行 / 找不到<br/>检查 CMD 路径、文件名、权限位"]
  Q3 -->|"都不是"| DEEP["回到应用与依赖:<br/>用 exec 进容器看现场状态"]

  OOM --> FIXO["看限制值是否合理<br/>(04 章 resources.limits)"]
  GRACE --> FIXG["把三层时间预算写成单调递增<br/>(06 章)"]

这张分流图的价值在于顺序:先排除 OOM,再看是不是停止流程,最后才怀疑应用。直接跳到"应用有 bug"会浪费很多时间——因为日志空白本身就说明了杀进程的不是应用自己。

143 那一行也值得回看:它是正常的 SIGTERM 到了、但进程没有注册处理器的结果。对照第 06 章"PID 1 的默认动作被忽略"——如果一个非 PID 1 进程收到 SIGTERM 且没注册处理器,它会被默认动作终止,退出码是 143。所以:

这两个数字合起来能定位到第 06 章图上的具体哪一跳。

二、五条命令,各自能看到什么、看不到什么

排障的效率取决于你知道每条命令的视野边界。把它们当"看一眼"的工具,才会出现"查过了,没发现问题"。

命令 能看到 看不到
docker logs <c> 容器的 stdout / stderr 应用写进文件的日志(除非该文件在容器内且你 exec 进去看);被 OOM 杀掉前的内存状态
docker inspect <c> State(ExitCode / OOMKilled / RestartCount / StartedAt)、config(含 Env)、挂载、网络 进程的实时状态;内核侧的记录
docker exec -it <c> sh 容器内活着的文件系统与进程视图 已退出容器的现场(这时它已经没了);宿主上的东西;没有 shell 的镜像(distroless)根本进不去
docker stats 各容器的实时 CPU / 内存 / 网络用量 历史趋势;被 OOM 杀掉那一刻的峰值(要看它得靠长期采集)
docker events 引擎层的事件流:start / die / oom / kill / health_status 应用内部的错误;事件的原因(只给现象,不给解释)

清单里最该记住的一条是 docker logs 的边界:它只收 stdout / stderr。一个把日志写到 app.log 文件的应用,在容器里就是"日志空白"——而且这个应用在容器外工作得很好,因为容器外你会去读那个文件。

所以从"裸机部署"迁到"容器"时,有一件事必须改:日志要往 stdout / stderr 写,让引擎收集。这不是风格问题——写到文件里的日志,在容器里等于不存在。这也是"容器里的应用应该把日志写到标准输出"这条建议的真实理由。

一个必须承认的边界

docker exec 只能进正在运行的容器。一个已经退出的容器,你只能 docker inspect 它的元数据,进不去它的现场。所以:

三、资源限制生效后是什么样

第 04 章看到 memory: 512M 被归一化成 536870912 字节。限制生效之后的表现有两种,差别很大:

限制 超限时的行为 应用有机会反应吗
内存 cgroup 的 OOM 处置直接杀掉进程 几乎没有——这就是"日志空白"的来源
CPU 被限速(额度用完后被推迟调度),不是被杀 有:表现为延迟上升、超时

这个差别决定了两件事:

  1. 内存问题的现象是"突然消失",CPU 问题的现象是"变慢"。 同样的代码,两种症状指向完全不同的排查路径。
  2. 内存限制值需要按应用的真实内存画像定,而不是随便填一个数。一个小程序被限到 32 MB,会在处理一个稍大的请求时被静默杀掉;而给它一个宽松的值,则会在它真的泄漏时更晚触发 OOM——限制值不解决泄漏,它只决定你多快发现泄漏。

CPU 限制还牵出一个容易误判的点:cpus: "1.5" 不是"最多用 1.5 个核所以一定会慢",而是"在竞争时才被限"。空闲时它可以用更多;有竞争时按配额被推迟。所以"CPU 限制导致性能差"这个判断,需要先确认是否存在竞争,否则看到的是别的瓶颈。

还有一类资源限制容易被忽略:文件描述符与磁盘。容器里的 / 层在写满之后,容器的写入会失败——而"磁盘满"在容器里的表现往往是"应用报了一个莫名其妙的写错误",需要先确认可写空间与挂载的卷(第 04 章:卷名带项目前缀,数据写进卷才持久)。

四、容器里看不到什么

这一节是把前面散落的边界收成一张表。它同时也是"什么时候不该用容器"(下一节)的依据:

在容器里想做的事 能不能做 为什么
看到宿主上的其他进程 ❌ PID namespace 隔离——你看到的 PID 1 是自己的进程
读宿主的内核日志(dmesg) 通常 ❌ 需要特权与 CAP_SYSLOG;OOM 的记录在宿主这一侧
访问宿主的文件系统 只有被挂载进去的部分 根目录是镜像的层,不是宿主的
加载内核模块 ❌ 共享宿主内核(第 01 章)
访问宿主硬件(GPU / 串口 / 特定设备) 需要显式设备映射或特权 默认不映射
修改宿主网络配置 ❌ 网络命名空间隔离
重启宿主 ❌ 没有这个接口(也没有这个权限)

这张表解释了一个常见困惑:"我在宿主机上找不到 OOM 的记录"——它可能在宿主的 dmesg / 内核日志里,而你查的是容器的日志。排查 OOM 要站在宿主视角看内核记录,容器视角天然缺失这一层。

五、什么时候不该用容器

容器是"打包一个应用及其运行环境"的方案,它对某些形态的负载不是最优解。判断依据是负载的形状,不是技术流行度:

负载形态 该用什么 理由
需要内核模块 / 特权 / 直接访问硬件 虚拟机或裸机 共享内核是硬边界(第 01 章)
产物是 wheel / jar 的纯库或 CLI 工具 包管理器分发 加一层容器不解决任何问题,只增加"用户得先装容器运行时"的门槛
单机 Nginx + 脚本化部署的静态站点 脚本化部署 已经足够简单;套上容器是把简单问题复杂化
有状态服务(数据库、消息队列) 可以用,但要算清代价 持久化、备份、主从、升级都需要额外的运维设计;托管服务往往更划算
需要极低启动延迟的可抢占负载 看具体场景 容器启动通常够快,但"共享内核 + 运行时"仍有开销;需实测
多服务、需要一致性环境与水平扩展 容器 + 编排 这正是容器的强项

(交付形态的更完整讨论——纯 CLI 工具、多服务 Compose 项目、单机 Nginx 三类形态的讲述分寸——在《构建打包与部署上线》第 02 章第六节,本章不重复。)

一个实用的自检问题:"这个负载需要动内核、需要宿主硬件、或者根本没有第二个实例吗?" 三个都不需要,容器才有意义。

六、从 Compose 到编排系统:哪些概念被继承

学完这门课,你已经掌握了单机容器编排的完整概念。下一门课(Kubernetes)换了一套词,但语义大多是对应的:

本课的概念 在编排系统里的对应 语义是否相同
services Pod / Deployment 被继承:声明"要跑什么"
healthcheck liveness / readiness 探针 被拆分:存活与就绪在 K8s 里是两个不同的问题
stop_grace_period terminationGracePeriodSeconds 完全继承(第 06 章的三段时序不变)
STOPSIGNAL 同名字段 完全继承
depends_on + condition initContainer / 探针 + 重试 换了表达:K8s 不鼓励"等依赖就绪",而是让服务自己重试
volumes PersistentVolume / PersistentVolumeClaim 概念升级:存储与声明分离
environment / .env ConfigMap / Secret 对象 概念升级:密钥成为一等对象,有独立的权限与审计
deploy.resources.limits resources.requests / resources.limits 被拆分:K8s 区分"调度依据(requests)"与"上限(limits)"
项目网络 Service / Ingress 概念升级:稳定的虚拟地址 + 服务发现
docker compose config kubectl --dry-run=client -o yaml 相通的思路:解析出来看实际生效值

这张表里有三处"被拆分/升级",恰好是 K8s 学习的重点:

最该放心的一点是第 06 章那套停机语义没有变。 很多人被 K8s 的名词吓住,但实际上最容易出事故的那一层(信号、排空、窗口)你已经掌握了。

生产边界

教学替身 真实替换点 要注意什么
退出码 + inspect 手工分流 引擎事件流接入告警(die / oom 事件) 手工排查依赖"有人去看";事件流才能覆盖"半夜被杀"的偶发案例
docker logs 看 stdout 集中式日志(日志外送 + 结构化字段) 容器随时消亡,默认的本地日志会随容器一起消失(除非配置了日志驱动与容量),所以必须外送
docker stats 看实时用量 长期指标采集(内存峰值、重启次数、停机耗时分布) 被 OOM 杀掉的峰值只在指标里留得下来,stats 抓不到已经过去的那一刻
手工定 512M 按真实内存画像 + 压测定限制值 限制值是"发现问题的灵敏度旋钮",不是"防止问题的手段"(第三节)

动手

  1. 对一个反复重启的容器,按第一节的分流图走一遍:拿到 ExitCode 与 OOMKilled,判定属于哪一支;
  2. 检查你项目的日志写到哪儿:stdout / stderr 还是文件?写到文件的,在容器里等于不存在;
  3. 检查资源限制值:它是怎么定出来的(拍的还是压测的)?写出你项目实际的内存峰值与限制值的比;
  4. 判断标准:能回答"如果这个容器现在被 OOM 杀掉,我能从哪里知道它被杀前用了多少内存";
  5. 完成标志:能用三句话说出一次没有日志的异常退出该怎么查——第一句查什么、第二句分流什么、第三句去哪一层找记录。

故障注入

注入方式 观察什么 说明的现象
把内存限制压到明显不够(例如远小于实测峰值) ExitCode 与 OOMKilled 字段、日志是否为空 OOM 的特征是日志空白 + 137,因为内核的处置不走应用路径
把 CPU 限制压到很低 请求延迟分布 CPU 超限是限速(变慢),不是被杀——与内存完全不同的症状
让应用把日志写进容器内的文件 docker logs 的输出 logs 只收 stdout/stderr,写文件的日志在容器里"不存在"
用 CMD 指向一个不存在的路径 退出码 127(命令找不到);权限位缺失则是 126
让容器正常运行后 docker stop,且应用不注册 SIGTERM 处理器 退出码 非 PID 1 进程 → 143;PID 1 → 默认动作被忽略,等 SIGKILL 得 137(第 06 章)
在容器里 dmesg / 查 OOM 记录 能否读到 宿主的内核记录在容器里通常不可见,要站在宿主视角查

自测

  1. 137 的两个来源分别是什么?用什么字段可以把它们区分开?
  2. 143 与 137 在"信号到没到应用"这件事上说明了什么差别?
  3. 一个应用把日志写到容器内的 app.log。docker logs 能看到它吗?这个应用在容器外工作正常,为什么?
  4. 内存超限与 CPU 超限的症状有什么本质不同?这个差别如何影响你的排查方向?
  5. "我在宿主上找不到 OOM 的记录"——这句话想说明什么?应该去哪个视角找?
  6. 举出三种"不该用容器"的负载,并各给一条判据。
  7. 从 Compose 到编排系统,有哪三处概念是"被拆分"而不是简单改名的?为什么要拆?

现在能解释什么

课程收束

七章走完,这门课实际上只立了三根柱子:

  1. 边界:上下文、镜像层、运行时;进程命名空间、网络命名空间。判断任何问题的第一步是问"这条信息/这个动作在哪一侧"。
  2. 时机:层缓存的失效链、停止的三段时序、健康检查与依赖条件的等待。配置里写的顺序与时间,比配置里的值更容易出错。
  3. 证据口径:docker compose config 展开实际生效值;OOMKilled 分流 137;能分清"我验证过"与"我读到的"。

其余的都是这三根柱子在具体场景里的应用。带着它们去看下一门课的 K8s,你会发现换掉的只是名词。

进入 keel 阅读