本地跑得好好的容器,上服务器常常同时坏三件事:构建一次比一次慢、进程莫名消失、健康检查把活着的服务"检查死"。三件事分别落在镜像层、cgroup 限制、探针配置上。按部署顺序拆。
1. 镜像分层:COPY 的顺序决定构建速度
每条指令一层,层按内容哈希复用。某层哈希变了,它和它之后的所有层全部重建。
COPY . /app # 差:改一行源码就重装依赖
RUN pip install -r requirements.txt
COPY requirements.txt /app/ # 好:依赖单独一层
RUN pip install -r requirements.txt
COPY src/ /app/src/
| 指令 | 缓存命中条件 | 常见失效原因 |
|---|---|---|
FROM |
基础镜像 digest 一致 | 基础镜像被重推,latest 悄悄变了 |
COPY |
文件内容哈希一致 | 源码改动、.dockerignore 写漏 |
RUN |
上层 ID + 命令字符串一致 | 多一个空格、顺序换了 |
构建上下文同样算在内:非 BuildKit 场景下 daemon 会收到整个上下文目录,先写 .dockerignore 再谈缓存。
2. 多阶段构建:工具链留在上一阶段
第一个阶段装 Maven、编译打 jar,第二个阶段从一个只带 JRE 的基础镜像重新开始,用 COPY --from=build 把产物取过来。
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -B -q dependency:go-offline
COPY src ./src
RUN mvn -B -q -DskipTests package
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY --from=build /src/target/app.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
第二个 FROM 之后是全新镜像链,编译阶段的 Maven、源码、.m2 缓存都不进最终镜像。体量差别通常是量级上的:带工具链的数百 MB 到 GB 级,只带 JRE 加 jar 的通常百 MB 上下,按基础镜像核对。
裁剪的前提是运行时真用不到:jcmd、jstack、tzdata、ca-certificates 都属于"编译不需要、排障突然需要"的类型。
3. 标签:latest 与不可变引用
latest 只是一个默认标签名,没有"最新"语义。同名标签被重推后指向变了,使用方却只在 pull 时才发现,回滚时更无从定位。
$ docker build -t registry.local/app:2026.11.09-1 .
$ docker inspect --format '{{index .RepoDigests 0}}' registry.local/app:2026.11.09-1
registry.local/app@sha256:9f2c...e41d
| 引用形式 | 是否可变 | 回滚时能否精确定位 |
|---|---|---|
app:latest |
可变 | 不能,只能靠构建历史反推 |
app:2026.11.09-1 |
约定不可变 | 能,与流水线编号对应 |
app@sha256:... |
不可变 | 能,最严格 |
生产上常用「构建号标签 + 部署记录存 digest」:编排文件写人可读的构建号,审计时用 digest。
4. 资源限制的四个参数
$ docker run -d --name app \
--memory 2g --memory-swap 2g \
--cpus 1.5 --pids-limit 512 \
registry.local/app:2026.11.09-1
| 参数 | 作用 | 不写会怎样 |
|---|---|---|
--memory |
cgroup 内存上限(含 page cache) | 可吃光宿主机内存,触发全局 OOM |
--memory-swap |
内存 + swap 总上限 | 默认是 --memory 的两倍,允许换出 |
--cpus |
CPU 时间配额,小数表示核数 | 忙时与邻居容器抢 CPU |
--pids-limit |
进程/线程数上限 | fork 炸弹或线程泄漏拖垮宿主机 |
--memory-swap 两个特殊值:等于 --memory 表示禁用 swap,-1 表示不限。Java 服务通常直接设成与 --memory 相等,避免被换出后响应抖动。
cgroup v2 下这些限制落在容器内的只读文件上:/sys/fs/cgroup/memory.max、cpu.max、pids.max。容器里 cat 一下,比看编排文件更可信。
5. 限制了内存,JVM 却不认识
最经典的一类事故:容器 --memory 2g,JVM 却按宿主机内存(比如 32G)算默认堆,可能开到 8G。JVM 觉得宽裕,容器已被 cgroup 判死。
$ docker exec app java -XX:+PrintFlagsFinal -version | grep -Ei 'MaxHeapSize|MaxRAM|UseContainerSupport'
| 参数 | 作用 | 典型写法 |
|---|---|---|
-XX:+UseContainerSupport |
让 JVM 读 cgroup 限制(JDK 10+ 默认开) | 一般不用写,但要确认没被关掉 |
-XX:MaxRAMPercentage |
最大堆占容器内存的比例 | 70.0 |
-Xmx |
绝对上限,优先级最高 | 只在需要钉死时用 |
两条规则。一是用 MaxRAMPercentage 而不是写死 -Xmx:写死等于把"容器内存"和"堆上限"各维护一份,改限制时容易漏改,后果正是第 6 节那种 137。二是堆外用量要预留:元空间、线程栈(每线程约 1M 虚拟内存)、NIO 直接内存、JIT 缓存、GC 结构。70 是常见起点。
验证方式是两个数字一起看:MaxHeapSize 与 /sys/fs/cgroup/memory.max。堆上限明显超过容器内存,多半是显式 -Xmx 覆盖了比例。
6. OOM 的两种结局:exit 137 与 OutOfMemoryError
同样是"内存不够",触发者不同,处置完全不同。
$ docker inspect --format '{{.State.ExitCode}} {{.State.OOMKilled}}' app
137 true
$ dmesg -T | grep -i -E 'oom|killed process'
| 现象 | 触发者 | 日志在哪 | 处置方向 |
|---|---|---|---|
退出码 137、OOMKilled=true |
内核 cgroup OOM killer | dmesg/journalctl -k |
提高 --memory,或降堆上限 |
进程活着但报 OutOfMemoryError |
JVM 自己 | 应用日志 | 查泄漏、查是哪块内存 |
退出码 137、OOMKilled=false |
收到 SIGKILL(如 docker stop 超时) |
docker events |
调 stop_grace_period |
区分只有一条:看 OOMKilled 字段与内核日志,别凭应用日志里有没有 OutOfMemoryError 下结论。JVM 内部 OOM 后进程通常还活着、能打出栈;一声不吭变成 137 才是被内核杀的,此时应用日志里什么都没有。
再留一手证据:启动时加 -XX:+HeapDumpOnOutOfMemoryError 与 GC 日志落盘(-Xlog:gc*:file=gc.log:filecount=5,filesize=20M)。cgroup OOM 不一定触发堆转储,堆内泄漏那条路径一定会。
7. 三种探针:各管各的事
| 探针 | 回答的问题 | 失败后果 | 该查什么 |
|---|---|---|---|
| liveness | 进程还需要重启吗 | 重启容器 | 只查进程自身(端口、事件循环) |
| readiness | 现在能接流量吗 | 从负载均衡摘掉 | 查外部依赖(库、缓存、下游) |
| startup | 启动完成了吗 | liveness 暂不生效 | 给慢启动服务留时间 |
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
retries: 3
start_period: 60s
Compose 只有一种 healthcheck,语义更接近 readiness;Kubernetes 里三者分开写,路径也分开(/actuator/health/liveness 与 /actuator/health/readiness)。
最高频的误配是让 liveness 依赖数据库:库一抖 → liveness 连续失败 → 编排层重启全部容器 → 无人处理请求、库压力更大 → 恢复更慢,即雪崩式重启。liveness 只判活,依赖检查放 readiness。
第二个是 start_period / startupProbe 缺失:JVM 冷启动常见几十秒,interval: 10s + retries: 3 会在应用没起来时判死,形成反复重启的循环。
第三个是探针命令用的工具不在镜像里。-slim、distroless 镜像往往没有 curl,改用 shell 内建做 TCP 探测:bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080';busybox 系镜像还有 wget -q -O /dev/null URL。
8. 日志驱动与磁盘上限
容器默认把 stdout/stderr 写进宿主机的 json 文件,默认不轮转,话多的服务几天就能写满磁盘。
| 驱动 | 输出去向 | 适用场景 |
|---|---|---|
json-file(默认) |
宿主机文件,需显式设轮转 | 单机、采集器读文件 |
local |
二进制,默认带轮转 | 单机、不要求可读 |
none |
丢弃 | 高频临时任务 |
在 daemon.json 里设 "log-opts": {"max-size": "50m", "max-file": "5"}。上限是两个旋钮相乘,单容器 50m × 5,几十个容器就是数 GB。改的是 daemon 默认值,已存在的容器要重建才生效。
9. 重启策略与容器内外差异
| 策略 | 行为 | 什么时候用 |
|---|---|---|
no(默认) |
不自动重启 | 一次性任务、调试 |
on-failure[:N] |
非 0 退出时重启,可限次 | 批处理、允许失败的作业 |
unless-stopped |
除手动停止外都重启 | 长驻服务的默认选择 |
两者只差"手动 stop 之后重启 daemon"这一种情形,而它恰好最常见。
容器与宿主机不是同一个视角:
| 项 | 容器内来源 | 与宿主机的差异 |
|---|---|---|
| 时区 | 基础镜像的 /etc/localtime,多数为 UTC |
日志时间戳差 8 小时 |
| DNS | daemon 生成的 /etc/resolv.conf |
默认继承宿主机,可被 --dns 覆盖 |
| 主机名解析 | Docker 注入的 /etc/hosts |
只有容器名/服务名 |
| 网络接口 | 独立 network namespace | 127.0.0.1 指容器自己 |
时区是最像"应用 bug"的配置问题。精简镜像常常没有 tzdata,设了 TZ 也不生效;挂载 -v /etc/localtime:/etc/localtime:ro 或在镜像里装 tzdata 并软链。Java 服务再用 -XshowSettings:properties 确认 user.timezone。
10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 每次构建都重装依赖 | COPY . 在 RUN install 之前,或缺 .dockerignore |
把依赖清单的 COPY 提前 |
| 容器 ExitCode 137 | 内核 OOM 杀,或 docker stop 超时后 SIGKILL |
看 OOMKilled 与 dmesg |
| JVM 报 OOM 但容器内存没满 | 元空间/直接内存耗尽 | 设 MaxMetaspaceSize、MaxDirectMemorySize |
--cpus 1.5 后并发度没降 |
availableProcessors() 与 --cpus 不同口径 |
显式设 -XX:ActiveProcessorCount,按版本核对 |
11. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 需要可复现的构建 | BuildKit + 固定 digest 基础镜像 | 缓存命中与复现性都更好 |
| 多机编排、滚动发布 | Kubernetes Deployment | Compose 重启策略不覆盖调度 |
| 内存极度敏感 | 直接为堆设 -Xmx 并留足堆外 |
比例法在多进程容器里不适用 |
12. 自测
- 为什么把
COPY requirements.txt单独放一行能提升构建速度?哪类改动仍会让依赖层失效? - 设了
--memory 2g,怎么确认 JVM 真的按 2G 算堆上限? - 退出码 137 有两种可能,怎么区分是内核杀的还是被别人
SIGKILL的? - 为什么 liveness 探针不该去查数据库?
--memory-swap设为与--memory相等,语义是什么?- 镜像里没有
curl,用一条不额外装工具的命令完成 HTTP 探针。