Docker 要点:镜像分层、容器网络与数据持久化
本文目录
Docker 的日常使用不难,难的是理解它每一步背后的机制——为什么 Dockerfile 改一行要重新跑五分钟、为什么数据库容器删了数据也没了、为什么容器一启动 Node 进程收不到 SIGTERM。这篇文章按"镜像 → 构建 → 网络 → 持久化 → 资源与进程 → 编排"的顺序,把生产环境真正会踩到的机制讲清楚,每条结论都能在 docs.docker.com 官方文档中对照。
镜像与容器:分层与写时复制
镜像(image)是只读的分层文件系统,容器(container)是在镜像顶部加一个可写层的运行实例。核心机制两条:
- 分层共享:每条 Dockerfile 指令生成一层,多个镜像共享相同的基础层,拉取和磁盘占用都按层去重。
- 写时复制(copy-on-write):容器读文件走镜像层;修改文件时先把该文件从镜像层复制到可写层再改。删文件是在可写层打"白障"(whiteout)标记,不真正删除底层内容。
这直接推出构建优化的第一原则:变化频率不同的内容放不同层,变化少的层放前面。
FROM node:20-alpine
WORKDIR /app
# 先只拷依赖清单:只要 package.json 不变,npm ci 层就命中缓存
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# 源码最后拷:改代码不会废掉上面的依赖层
COPY . .
CMD ["node", "server.js"]
如果把 COPY . . 放在 npm ci 前面,改任何一行代码都会使源码层失效,进而依赖层缓存全废,每次构建都重新下载依赖。缓存 key 是指令内容 + 相关文件的校验和,.dockerignore 的价值也在这:不把 node_modules、.git 拷进构建上下文,既是提速也是避免本地脏东西进镜像。
多阶段构建:只带产物进运行时
编译期需要完整工具链,运行期只需要产物。多阶段构建用两个 FROM 把两者切开:
# 阶段一:构建
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# 阶段二:运行
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
# 只拷构建产物 + 生产依赖
COPY --from=build /app/dist ./dist
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
USER node
CMD ["node", "dist/server.js"]
效果是运行镜像里没有 devDependencies、没有源码、没有构建工具,体积通常从 1GB 级降到 100–200MB。攻击面和拉取时间同步缩小。若运行镜像只跑静态产物,甚至可以用 alpine 或 distroless 作最终阶段,连 shell 都不给。
容器网络:bridge、端口暴露与互联
| 网络模式 | 特点 | 典型用途 |
|---|---|---|
| bridge(默认) | 每容器独立网络栈,经 NAT 访问外网 | 单机多容器 |
| host | 与宿主机共用网络栈,无 NAT 开销 | 对网络性能极致敏感的本机服务 |
| none | 无网络 | 离线任务、安全隔离 |
| 自建 bridge | 同一网络内可用容器名做 DNS 互访 | compose 编排的标准做法 |
# 创建一个用户自定义网络:同网络内容器名即域名
docker network create app-net
# 后端与数据库都挂到这个网络
docker run -d --name api --network app-net -p 8080:8080 my-api:1.0
docker run -d --name db --network app-net -e MYSQL_ROOT_PASSWORD=change-me mysql:8.4
# api 容器内直接用容器名连接,不需要知道对方 IP
# 连接串形如 mysql://root@db:3306/appdb
两个容易混淆的点:
-p 8080:8080暴露到宿主机(默认绑定所有网卡,写-p 127.0.0.1:8080:8080只绑回环)。想限制访问面时用127.0.0.1:前缀,再由前置反代统一对外。- 默认 bridge 网络里容器名不能互访,只有自建网络才有内嵌 DNS。这就是"两个容器都起来了却连不上"的常见根因。
数据持久化:bind mount 与 volume
容器可写层随容器删除而消失,这是设计如此而非缺陷。两种主流挂载方式:
| 维度 | bind mount(-v /host/path:/container/path) | volume(-v myvol:/container/path) |
|---|---|---|
| 存储位置 | 宿主机任意路径,路径写死 | Docker 管理区(Linux 下 /var/lib/docker/volumes/) |
| 可移植性 | 依赖宿主机目录结构 | 随主机迁移、可被外部工具备份 |
| 性能 | macOS/Windows 上跨 VM 边界明显慢 | 略好,仍受虚拟化影响 |
| 适用 | 配置文件、源码热挂载(开发) | 数据库、状态数据(生产) |
数据库容器为什么必须挂卷:
# 不挂卷:删容器 = 数据全没
docker run -d --name db mysql:8.4
# 挂卷:数据在卷里,容器可以随意销毁重建
docker run -d --name db \
-v mysql-data:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD=change-me \
mysql:8.4
MySQL 数据目录是 /var/lib/mysql,官方镜像里它被声明为 VOLUME,即使不显式挂卷 Docker 也会建匿名卷——但匿名卷在 docker rm(不带 -v)时不会删,长期运行会积累一堆无主卷,用 docker volume ls 和 docker volume prune 定期治理。开发环境挂 bind mount 时,文件变更事件(如热重载)在 macOS 上跨文件系统边界不一定触发,需要轮询方案兜底。
资源限制:memory、cpus 与 OOM
不设限的容器在宿主机资源紧张时互相挤兑,设了限才能预测行为:
docker run -d \
--name api \
--memory=512m \
--memory-swap=512m \
--cpus=1.5 \
my-api:1.0
关键点在 --memory-swap:swap 等于 memory 时表示禁用 swap,容器内存到 512m 后触发内核 OOM killer 直接杀进程(退出码 137)。Node 进程对内存限制有一个著名坑:容器内 os.totalmem() 读的是宿主机内存而不是 cgroup 限制,旧版 V8 会按宿主机内存设定堆上限,结果堆还没到顶整个容器先被 OOM。现代 Node 可用 --max-old-space-size 显式对齐,JVM 圈则用 -XX:MaxRAMPercentage 让运行时感知容器配额。
内存限制与 CPU 限制不同类:CPU 被超卖通常表现为变慢,内存到顶是进程被杀。所以内存上限必须按容器内进程实际峰值的 1.5 倍左右设定并留观测余量,且 cgroup v1/v2 下 /sys/fs/cgroup 的路径结构不同,自研 agent 读配额时要兼容两者。
健康检查与依赖等待:不要用 sleep
容器"启动了"不等于"就绪了"。两层机制各有分工:
# Dockerfile 层:声明进程级健康检查
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
| 机制 | 层级 | 回应的问题 |
|---|---|---|
| Dockerfile HEALTHCHECK / compose healthcheck | 容器内 | 进程活着吗?接口能答吗? |
| compose depends_on + condition | 编排层 | 什么时候可以启动下一个服务? |
services:
db:
image: mysql:8.4
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
interval: 5s
timeout: 3s
retries: 10
api:
image: my-api:1.0
depends_on:
db:
condition: service_healthy # 等 db 健康后才启动 api
反模式是 command: sh -c "sleep 30 && node dist/server.js" 或应用里 await sleep(10s) 再连库:数据库初始化时长不可预测,sleep 要么不够(照样连不上)要么浪费(固定拖慢每次部署)。正确姿势是应用侧做带退避的连接重试(指数退避 1s→2s→4s,上限 30–60s),编排侧用 healthcheck 做顺序控制,两道防线互为补充。
日志:驱动与轮转
容器日志默认走 json-file 驱动,落盘在宿主机,docker logs 读取。不配置轮转就是定时炸弹:
# 方式一:单容器指定
docker run -d \
--log-driver json-file \
--log-opt max-size=10m \
--log-opt max-file=3 \
my-api:1.0
# 方式二:compose 全局默认
services:
api:
image: my-api:1.0
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
集群规模改用集中式驱动(fluentd、syslog 等)或节点级采集 Agent 直接读文件。另外两点边界:docker logs 只采集 stdout/stderr,应用写文件的日志它看不见,要么改打到标准输出(推荐,十二要素应用的做法),要么单独挂卷收集;docker logs -f 在日志量大时是全量回放,排查线上问题优先 --since 限定窗口。
信号处理:PID 1 与僵尸进程
docker stop 的默认流程:发 SIGTERM → 等 10 秒(-t 可调)→ 还没退就 SIGKILL。能否优雅退出取决于你的应用是不是 PID 1 且注册了信号处理:
# 应用收不到 SIGTERM 的典型写法
CMD npm start
# npm 不是信号透传的可靠角色,SIGTERM 先到 npm,转给 node 的行为不确定
# 推荐写法:exec 形式,node 直接成为 PID 1
CMD ["node", "dist/server.js"]
| 写法 | 进程树 | 信号能否到达应用 |
|---|---|---|
CMD node dist/server.js(shell 形式) | sh → node | sh 默认不转发,node 收不到 |
CMD ["node", "dist/server.js"](exec 形式) | node 即 PID 1 | 直达,需在代码里处理 SIGTERM |
CMD npm start | npm → node | 依赖 npm 版本行为,不可靠 |
ENTRYPOINT ["sh", "-c", "node app.js"] | sh → node | sh 不转发(除非显式 exec) |
Node 应用里要显式监听并做收尾:关闭 HTTP 监听、等在途请求完成(超时上限对齐 docker stop -t)、关闭数据库连接池后再 process.exit(0)。另一类隐患是孤儿僵尸:如果你的容器里要拉起子进程(如 ffmpeg 转码),PID 1 有收割(reap)僵尸进程的责任,普通应用不处理就会累积。简单方案是加 init: true(compose 里)或用 tini 作入口,让专门的 init 进程负责收割。
compose 生产要点
services:
api:
image: registry.example.com/my-api:1.4.2 # 明确版本,不用 latest
restart: unless-stopped # 崩溃自动拉起;加 always 则连手动 stop 都会重启
networks: [app-net]
deploy:
resources:
limits: { memory: 512M, cpus: "1.5" }
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz || exit 1"]
interval: 15s
timeout: 3s
retries: 3
depends_on:
db:
condition: service_healthy
db:
image: mysql:8.4
volumes:
- mysql-data:/var/lib/mysql
networks: [app-net]
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} # 从 .env 注入,不写进文件
networks:
app-net: {}
volumes:
mysql-data: {}
生产上还要记住:单机 compose 的 deploy.replicas 不生效(那是 Swarm 语法),要多副本就上多实例或改用 Kubernetes;restart 策略挡不住 OOM 循环——如果进程启动五秒就被 OOM 杀掉,restart 会无限循环拉起,必须有告警盯着重启次数;compose up -d 与 compose pull 的时机要分开,避免滚动时新旧镜像混跑导致的版本漂移。真正要求不中断的服务,compose 单机方案的滚动更新能力有限,这是它的能力边界,该上 K8s 或 Nomad 就上。
镜像安全:非 root、固定 digest、扫描
# 1. 非 root 运行:官方镜像都带低权限用户
USER node
# 2. 文件属主显式收紧
COPY --chown=node:node --from=build /app/dist ./dist
# 3. 引用用 digest 固定,杜绝 tag 被覆盖后拉到不同内容
# 下方为示例格式的 64 位摘要;真实值用 docker inspect --format='{{index .RepoDigests 0}}' 查询
docker pull mysql@sha256:e3f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e
# 4. 发布前扫描(Trivy 为例)
trivy image registry.example.com/my-api:1.4.2
# 5. 检查容器内实际用户
docker exec api id
# uid=1000(node) gid=1000(node) —— 目标状态
标签(tag)是可变的名字,digest 是内容的哈希——同一 tag 可以被重新推送成完全不同的内容。供应链安全的基本动作:基础镜像用 digest 固定,CI 里定期扫描 CVE 并把升级做成例行任务,镜像仓库启用认证。另外三条常被忽略:构建参数 --build-arg 会进镜像层历史(docker history 可见),不要用它传密钥;多阶段构建的中间层不会进最终镜像,但传参时仍应只经环境变量在运行时注入密钥;.dockerignore 里加上 .git,历史记录里的旧密钥会跟着 .git 目录一起进镜像。
注意事项与边界
docker exec进容器改文件是排障手段,不是部署手段——改动在可写层,容器重建即丢,一切变更必须回到 Dockerfile/compose。- bind mount 挂宿主机目录时注意 UID 对齐:容器内 node 用户(uid 1000)与宿主机用户不一致会导致"文件读写没权限",可用
--user显式指定。 - 镜像里
ENV的值会随镜像分发与docker inspect暴露,密钥一律运行时注入(compose 的${VAR}从环境取,.env文件不进版本库)。 - macOS/Windows 上 Docker 跑在虚拟机里,bind mount 性能下降和文件事件丢失是已知边界,跨平台团队优先用 volume 做数据、bind mount 只做配置。
- 健康检查探测内部端点要轻量:一个重 SQL 会让 healthcheck 本身拖垮数据库。
- 容器并非天然安全边界:共享内核意味着内核级漏洞会影响同宿主机全部容器,多租户场景该用 VM/裸金属隔离就别省。
总结
Docker 生产化可以归成四句话:构建靠分层缓存与多阶段瘦身,网络用自建网络 + 容器名互访,数据靠显式 volume 而不是可写层,进程靠 PID 1 信号处理 + 资源限制 + 健康检查兜底。每一条机制都可以用 docker inspect、docker history、docker network inspect、docker volume ls 直接验证——养成"先验证再相信"的习惯,容器化收益才不会在一次半夜事故里吐回去。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。