容器不是一个小型虚拟机,它是被 namespace 隔离、被 cgroup 限制的一个进程。理解了这点,下面这些问题都能自己解释:为什么容器一启动就退出、为什么 JVM 以为自己有 64G 内存、为什么 exit code 是 137、为什么 docker stop 之后请求还是被硬生生掐断。
一、容器的三个底座
| 机制 | 作用 | 具体隔离/限制了什么 |
|---|---|---|
| namespace | 隔离"看得见什么" | pid(进程树)、mnt(挂载点)、net(网卡/端口)、ipc、uts(主机名)、user(uid 映射) |
| cgroup | 限制"能用多少" | CPU、内存、blkio、pids、设备 |
| 联合文件系统 | 提供文件系统视图 | 见镜像篇的只读层 + 容器层 |
关键推论:容器里 PID 1 那个进程退出,容器就退出。没有"后台守护"这回事。
二、生命周期与状态
docker create → created
docker start → running ←→ paused(docker pause/unpause)
进程退出 → exited
docker rm → deleted其中 restarting 是重启策略介入时的临时状态。看状态用:
docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"三、docker run 常用参数
| 参数 | 作用 | 备注 |
|---|---|---|
-d | 后台运行 | 不加就是前台,Ctrl+C 容器就停 |
-it | 分配交互终端 | docker run -it ubuntu bash 调试必备 |
--name | 指定名字 | 不指定会随机生成,脚本里很难用 |
--rm | 退出自动删除 | 临时容器专用,省得手动清理 |
-p 8080:80 | 端口映射 | 宿主机端口:容器端口 |
-v /data:/var/lib/mysql | 挂载卷 | 见网络与存储篇 |
-e KEY=VALUE | 环境变量 | 密码建议用 env_file 或 secrets |
--network | 指定网络 | bridge / host / none / 自定义 |
--restart | 重启策略 | 见第六节 |
--memory 1g | 内存上限 | 见第四节 |
--cpus 2 | CPU 上限 | 见第四节 |
--user 10001 | 指定运行 uid | 生产别用 root |
--read-only | 根文件系统只读 | 配合 --tmpfs /tmp |
--health-cmd | 健康检查命令 | 见第七节 |
--init | 用 tini 做 PID 1 | 解决僵尸进程与信号转发 |
--privileged | 放开所有限制 | 几乎永远不该用 |
四、资源限制:不限制就是裸奔
docker run -d \
--name app \
--memory 1g --memory-swap 1g \
--cpus 1.5 \
--pids-limit 300 \
--restart unless-stopped \
myapp:1.0| 参数 | 含义 | 说明 |
|---|---|---|
--memory(-m) | 物理内存硬上限 | 超了就是 OOM Kill,exit 137 |
--memory-swap | 内存 + swap 总量 | 等于 --memory 表示禁用 swap;设为 -1 表示不限 swap |
--memory-reservation | 软限制 | 宿主机紧张时才生效,用于超卖 |
--cpus | 可用 CPU 核数(可为小数) | cgroup v2 下最直观 |
--cpu-shares | 相对权重 | 只在 CPU 打满时按比例分配,不是硬限制 |
--cpuset-cpus | 绑定具体核 | 减少上下文切换,也用于隔离 |
--pids-limit | 最大进程数 | 防 fork 炸弹拖垮宿主机 |
--blkio-weight / --device-write-bps | 磁盘 IO 限制 | 避免一个容器把磁盘打满 |
JVM 容器感知:为什么容器 2G,JVM 以为有 64G
这是容器化 Java 最经典的一个坑。早期 JVM 读的是宿主机的 /proc/meminfo,看不到 cgroup 限制,于是按宿主机内存算默认堆(物理内存的 1/4)。宿主机 256G,容器限 2G,JVM 就敢把堆开到 64G,起步就 OOM。
# 验证容器里看到的内存到底是谁的
docker run --memory 2g eclipse-temurin:21-jre java -XX:+PrintFlagsFinal -version | grep -i maxheapsize
free -m # 在容器里执行,看到的往往是宿主机的数字解法:
- JDK 8u191+ / JDK 11+:
-XX:+UseContainerSupport默认开启,会按 cgroup 限制算堆;再用-XX:MaxRAMPercentage=75.0指定占比(别用-Xmx写死,那样容器扩容要改代码)。 - JDK 8u131-8u190:
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap。 - 更老的版本:只能显式
-Xmx,并且让它跟--memory对齐。 - CPU 同理:
-XX:ActiveProcessorCount可以手动指定,避免 JVM 按宿主机核数开线程池。
java -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -jar app.jar完整的排查过程见:Java 搬进 Docker 就"水土不服":容器 2G 内存,JVM 却以为自己有 64G。
注意:堆只是内存的一部分。限了 -Xmx 之后还有元空间、线程栈、直接内存、GC 开销。堆外内存泄漏同样会被 cgroup 判死刑,这类案例见 堆明明没满,容器却被 OOMKilled。
五、退出码速查
| 退出码 | 含义 | 怎么查 |
|---|---|---|
| 0 | 正常退出 | — |
| 1 | 应用自身报错 | docker logs |
| 125 | docker 命令本身失败(参数错、镜像名非法) | 命令行输出 |
| 126 | 命令存在但不可执行(权限问题) | 检查 entrypoint 文件权限 |
| 127 | 命令不存在 | 基础镜像里没这个命令(alpine 没有 bash) |
| 137 | 被 SIGKILL:OOM Kill 或 docker kill | docker inspect 看 OOMKilled;dmesg | grep -i killed |
| 139 | 段错误(SIGSEGV) | 多为 native 库问题 |
| 143 | 收到 SIGTERM 后退出(docker stop 的正常结果) | — |
docker inspect app --format '{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.Error}}'
dmesg | grep -i -E 'killed|oom' | tail -20六、优雅停机:docker stop 之后为什么请求还是断了
docker stop 的流程是:发 SIGTERM → 等 10 秒(--time 可调)→ 还不退就 SIGKILL。要优雅停机,得满足两个条件:
- 应用真的能收到 SIGTERM:ENTRYPOINT 必须是 exec 格式(见镜像篇)。shell 格式下 PID 1 是
/bin/sh,它不会把信号转给子进程。 - 应用会处理 SIGTERM:Spring Boot 要开优雅关闭。
# Spring Boot 配置
server:
shutdown: graceful # 等待在途请求处理完
spring:
lifecycle:
timeout-per-shutdown-phase: 30s还有一层:K8s / Compose 停止容器前,上游可能还在往里发请求。真正的无损下线要把从注册中心摘除排在 SIGTERM 之前(Spring Boot 的 graceful shutdown 配合 preStop hook)。
七、健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -fsS http://localhost:8080/actuator/health || exit 1# 或者运行时指定
docker run -d --health-cmd='curl -fsS http://localhost:8080/health || exit 1' \
--health-interval=30s --health-timeout=3s --health-retries=3 --health-start-period=40s \
myapp:1.0
docker inspect --format '{{.State.Health.Status}}' app三个状态:starting → healthy → unhealthy。--start-period 是给慢启动应用的宽限期,避免启动阶段就被判死。
健康检查端点要轻:只查进程活着 + 关键依赖能连,不要查全库、不要做重计算。健康检查把应用打死是真实发生过的事。
八、重启策略与"重启风暴"
| 策略 | 行为 | 适用 |
|---|---|---|
no | 不自动重启(默认) | 一次性任务 |
on-failure[:N] | 非 0 退出才重启,最多 N 次 | 有 bug 会崩的服务,避免无限重启 |
always | 退出就重启,Docker 重启后也会拉起 | 常驻服务,但要小心死循环 |
unless-stopped | 同 always,但手动 stop 过就不再拉起 | 推荐,尊重人工操作 |
典型事故:内存限得太小 → 应用启动到一半 OOM(exit 137)→ always 立刻重启 → 再 OOM,容器每 40 秒重生一次,日志刷满、告警淹没。重启策略要配合资源限制和健康检查一起调,别只开一个。完整案例见 容器每 40 秒"重生"一次:Restarting、exit 137 与一场 restart always 引发的重启风暴。
九、命令清单
docker run -d --name app -p 8080:8080 myapp:1.0
docker ps -a # 看状态
docker logs -f --tail 200 app # 实时日志
docker logs --since 10m app # 最近十分钟
docker exec -it app sh # 进容器(alpine 用 sh,没有 bash)
docker exec -it app env # 看环境变量
docker stats app # 实时资源占用
docker top app # 容器内进程
docker inspect app --format '{{.State.Health.Status}}'
docker update --memory 2g --cpus 2 app # 运行中调整限制(无需重建)
docker stop --time 30 app # 给 30 秒优雅退出
docker kill -s SIGTERM app
docker rm -f app
docker events --since 1h # 看这一小时发生了什么
docker system df -v # 磁盘占用明细备忘
- 容器 = namespace + cgroup + 一个进程;PID 1 退出容器就退出。
- Java 必须确认
UseContainerSupport生效,用MaxRAMPercentage而不是写死-Xmx。 - exit 137 = 被 SIGKILL,八成是 OOM;139 是段错误;143 是正常 SIGTERM。
- 优雅停机两要素:exec 格式 ENTRYPOINT + 应用处理 SIGTERM。
- 重启策略别单独用 always,配合资源限制和健康检查。
- 健康检查端点要轻量,配
--start-period给慢启动留宽限。
一起聊聊