KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
07 · 排障、资源限制与选型边界 — keel 龙骨
这一章回答:容器行为不对时,你手上有什么工具?exit code 137 之外的那些退出码怎么读?以及哪些问题根本不该用容器解决?
这一章回答:容器行为不对时,你手上有什么工具?exit code 137 之外的那些退出码怎么读?以及哪些问题根本不该用容器解决?
前面六章都在讲"怎么让容器正确"。这一章讲它已经不对了的时候。
有一条贯穿全章的判断,先给出来:
容器给你的隔离,同时也是你排查时的盲区。 你在容器里"看不到"的东西,不等于它不存在——它在宿主上、在内核里、在另一个容器的命名空间里。
现场
一个服务被反复重启,编排层记录了十几次重启。翻它的日志——空白的。没有异常、没有堆栈、没有任何一行输出,只有启动时那几行。
第一反应通常是"日志配置错了"或"应用崩在日志初始化之前"。有时候是,但这个现场的特征是连启动信息之后什么都没有:说明进程是在正常工作了一段时间之后被从外部终止的,而终止它的那个东西没有给它写日志的机会。
最典型的两个原因是:
- 内存超限被内核杀掉(cgroup 的 OOM 处置直接在进程层面杀,不走应用的任何错误处理路径);
- 超过了编排层的停止等待窗口,被
SIGKILL(第 06 章)。
两者都表现为"日志空白 + 退出码 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。所以:
- 看到 143:信号到了,只是没人处理它;
- 看到 137 且不是 OOM:信号到了但超时后才强杀,或者信号根本没到(shell 形式)。
这两个数字合起来能定位到第 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 它的元数据,进不去它的现场。所以:
- 诊断信息必须在进程活着的时候采,或者提前配置好采集(日志外送、指标上报)。
- 依赖"崩溃后进容器看看"的排查方式,在容器世界里结构性失效。这是容器与虚拟机的又一处体验差异(第 01 章那张表的延伸)。
三、资源限制生效后是什么样
第 04 章看到 memory: 512M 被归一化成 536870912 字节。限制生效之后的表现有两种,差别很大:
| 限制 | 超限时的行为 | 应用有机会反应吗 |
|---|---|---|
| 内存 | cgroup 的 OOM 处置直接杀掉进程 | 几乎没有——这就是"日志空白"的来源 |
| CPU | 被限速(额度用完后被推迟调度),不是被杀 | 有:表现为延迟上升、超时 |
这个差别决定了两件事:
- 内存问题的现象是"突然消失",CPU 问题的现象是"变慢"。 同样的代码,两种症状指向完全不同的排查路径。
- 内存限制值需要按应用的真实内存画像定,而不是随便填一个数。一个小程序被限到 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 学习的重点:
- 存活与就绪为什么分开:健康检查的一个布尔值不够用——"进程活着但不能服务"(还在预热、依赖没连上)是一个真实且常见的状态,需要单独表达;
requests与limits为什么分开:前者是"调度器拿它与节点容量做减法"的依据,后者是"运行时强制的上限"。写成一个数会让调度失真;- 存储与声明的分离:解决的问题是"数据和计算的生命周期不同"。
最该放心的一点是第 06 章那套停机语义没有变。 很多人被 K8s 的名词吓住,但实际上最容易出事故的那一层(信号、排空、窗口)你已经掌握了。
生产边界
| 教学替身 | 真实替换点 | 要注意什么 |
|---|---|---|
退出码 + inspect 手工分流 |
引擎事件流接入告警(die / oom 事件) |
手工排查依赖"有人去看";事件流才能覆盖"半夜被杀"的偶发案例 |
docker logs 看 stdout |
集中式日志(日志外送 + 结构化字段) | 容器随时消亡,默认的本地日志会随容器一起消失(除非配置了日志驱动与容量),所以必须外送 |
docker stats 看实时用量 |
长期指标采集(内存峰值、重启次数、停机耗时分布) | 被 OOM 杀掉的峰值只在指标里留得下来,stats 抓不到已经过去的那一刻 |
手工定 512M |
按真实内存画像 + 压测定限制值 | 限制值是"发现问题的灵敏度旋钮",不是"防止问题的手段"(第三节) |
动手
- 对一个反复重启的容器,按第一节的分流图走一遍:拿到
ExitCode与OOMKilled,判定属于哪一支; - 检查你项目的日志写到哪儿:stdout / stderr 还是文件?写到文件的,在容器里等于不存在;
- 检查资源限制值:它是怎么定出来的(拍的还是压测的)?写出你项目实际的内存峰值与限制值的比;
- 判断标准:能回答"如果这个容器现在被 OOM 杀掉,我能从哪里知道它被杀前用了多少内存";
- 完成标志:能用三句话说出一次没有日志的异常退出该怎么查——第一句查什么、第二句分流什么、第三句去哪一层找记录。
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
| 把内存限制压到明显不够(例如远小于实测峰值) | 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 记录 |
能否读到 | 宿主的内核记录在容器里通常不可见,要站在宿主视角查 |
自测
137的两个来源分别是什么?用什么字段可以把它们区分开?143与137在"信号到没到应用"这件事上说明了什么差别?- 一个应用把日志写到容器内的
app.log。docker logs能看到它吗?这个应用在容器外工作正常,为什么? - 内存超限与 CPU 超限的症状有什么本质不同?这个差别如何影响你的排查方向?
- "我在宿主上找不到 OOM 的记录"——这句话想说明什么?应该去哪个视角找?
- 举出三种"不该用容器"的负载,并各给一条判据。
- 从 Compose 到编排系统,有哪三处概念是"被拆分"而不是简单改名的?为什么要拆?
现在能解释什么
- 退出码是一张分流表:
137必须先用OOMKilled区分"内存超限"与"停止窗口耗尽";143说明信号到了但没人处理。 docker logs只收 stdout / stderr——写文件的日志在容器里等于不存在,这是迁到容器时必须改的一件事。docker exec只能进活着的容器,所以"崩溃后进现场看"这种排查方式在容器世界结构性失效。- 内存超限是被静默杀掉(日志空白),CPU 超限是被限速(变慢)——症状差别决定方向。
- 容器里的隔离就是排查的盲区:宿主进程、内核记录、宿主文件系统都看不见,要换视角而不只是换命令。
- 容器不是所有负载的答案:需要内核/硬件、没有第二个实例、产物是库或 CLI 的,都有更合适的形态。
- 编排系统里停机语义完全继承(第 06 章),变化最大的是"存活与就绪拆开""requests 与 limits 拆开""存储与声明分开"。
课程收束
七章走完,这门课实际上只立了三根柱子:
- 边界:上下文、镜像层、运行时;进程命名空间、网络命名空间。判断任何问题的第一步是问"这条信息/这个动作在哪一侧"。
- 时机:层缓存的失效链、停止的三段时序、健康检查与依赖条件的等待。配置里写的顺序与时间,比配置里的值更容易出错。
- 证据口径:
docker compose config展开实际生效值;OOMKilled分流 137;能分清"我验证过"与"我读到的"。
其余的都是这三根柱子在具体场景里的应用。带着它们去看下一门课的 K8s,你会发现换掉的只是名词。