2637 字
约 8 分钟
14
S15-X10 · 容器化部署:镜像、资源限制与健康检查

本地跑得好好的容器,上服务器常常同时坏三件事:构建一次比一次慢、进程莫名消失、健康检查把活着的服务"检查死"。三件事分别落在镜像层、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. 自测

  1. 为什么把 COPY requirements.txt 单独放一行能提升构建速度?哪类改动仍会让依赖层失效?
  2. 设了 --memory 2g,怎么确认 JVM 真的按 2G 算堆上限?
  3. 退出码 137 有两种可能,怎么区分是内核杀的还是被别人 SIGKILL 的?
  4. 为什么 liveness 探针不该去查数据库?
  5. --memory-swap 设为与 --memory 相等,语义是什么?
  6. 镜像里没有 curl,用一条不额外装工具的命令完成 HTTP 探针。
S15-X10 · 容器化部署:镜像、资源限制与健康检查
https://www.clxhxhhr.top/posts/5867/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。