刚接手一台别人搭好的机器,docker ps一屏幕容器名全是随机生成的形容词加科学家名字,端口映射靠翻聊天记录,日志文件把磁盘吃满,某个容器隔三差五被系统杀掉还查不出原因——这种场面我见过太多次。Docker 这玩意儿入门门槛低得离谱,docker run一行命令就能跑起来,但真正让一套容器环境稳定运行半年不出事,靠的从来不是那行命令,而是对概念的理解和一堆看起来琐碎的配置。这篇东西就是把我这些年关于 Docker 概念梳理和配置落地的经验摊开讲,从镜像分层到底层隔离机制,从daemon.json每一项该不该改,到 Dockerfile 怎么写才能让构建缓存真正生效,再到网络模式选型、资源限制、日志轮转、故障排查。不管你是刚装完 Docker 还在纠结-p参数顺序的新手,还是已经在维护几十个容器、被 OOM Kill 和磁盘告警折磨过的老手,下面这些内容应该都能抠出点能直接抄的东西。
1. 先把概念捋直:Docker 到底解决了什么问题
1.1 从"我这跑得好好的"这句经典台词说起
环境不一致这件事,本质上是把"程序"和"程序运行所依赖的一切"这两件事拆开交付造成的。传统部署交付的是程序和一份说明书(依赖清单),部署方照着说明书装环境,装出来的东西和开发方那台机器上的必然有差异:系统库版本、环境变量、时区、编码、内核参数、甚至某个补丁包。Docker 的思路很粗暴也很有效——把程序连同它依赖的整个用户态文件系统一起打包,交付一个自包含的单元,运行的时候直接把这个文件系统当成根目录用。
这就引出了 Docker 最核心的三个概念:镜像(Image)、容器(Container)、仓库(Registry)。镜像是一个只读的、分层的文件系统快照加上一份启动配置;容器是镜像的一个运行实例,在只读层之上叠加一个可写层;仓库是存放和分发镜像的地方。很多人把镜像理解成"安装包"、把容器理解成"装好的软件",这个类比能帮你入门,但会在后面坑你——因为容器不是一台小虚拟机,它的进程直接跑在宿主内核上。
我见过最常见的误解是:以为容器里改了东西,下次用同一个镜像启动还在。不在了。可写层随容器生命周期存在,容器删掉,可写层就没了。这个特性逼着你把所有需要持久化的东西往外挂,而这恰恰是容器化部署最健康的一种约束——容器内的文件系统天然应该是无状态的、随时可丢弃的。理解到这一层,你对数据卷、配置注入、日志外落这些设计的必要性就不会再有疑问。
1.2 镜像分层:为什么拉同一个基础镜像第二次几乎不耗时
镜像不是一个整块的 tar 包,而是一串层的叠加。Dockerfile 里每一条会改变文件系统的指令(RUN、COPY、ADD)都会产生一个新层,每条层的元信息里记录了它的父层 ID 和校验和。多个镜像共享同一个基础层时,本地只存一份,磁盘上省空间,网络上拉取时也只需要拉缺失的层。
这个机制最直接的实际影响是构建速度。举个具体场景:一个 Java 服务的 Dockerfile,常见写法是先把整个工程目录COPY . .,再RUN mvn package。结果就是——你改一行代码,COPY层的校验和变了,后面所有层全部失效,依赖重新下载一遍,一次构建五分钟。反过来先COPY pom.xml再RUN mvn dependency:go-offline,最后才COPY源码,改代码时依赖层命中缓存,构建时间从五分钟压到三十秒。这不是玄学,是分层缓存的必然结果,后面第 4 节会详细展开。
存储上还有一个容易被忽略的点:镜像层是只读且共享的,容器层才是可写的。所以同一台机器上跑十个基于同一个镜像的容器,镜像部分只占一份空间,每个容器额外占的只是自己可写层里的改动量。这也是为什么docker ps -s里看到的 SIZE 有两个数字,前者是可写层大小,后者是虚拟大小。
1.3 容器、虚拟机、进程:三个层级的正确定位
把容器说成"轻量级虚拟机"是最容易误导人的一句话。虚拟机模拟的是完整的硬件,里面跑一个独立内核,隔离性强但资源开销大、启动慢;容器只是被内核的隔离机制圈起来的一组普通进程,共享宿主内核,启动时间和启动一个进程差不多。这个区别决定了三件事:
第一,容器里不能跑内核模块,不能改内核参数(sysctl里那些 net 相关的命名空间隔离项除外),不能加载自定义驱动。你需要一个不同内核版本的环境,容器解决不了。
第二,容器的"操作系统"其实只是用户态的那套库和工具链。一个ubuntu:22.04镜像里没有内核,只有 Ubuntu 的文件系统和包管理器。所以容器镜像可以做到几十兆,虚拟机镜像做不到。
第三,也正因为共享内核,容器的安全边界比虚拟机弱。内核提权漏洞一旦被利用,容器逃逸的风险是真实存在的。这也是为什么生产环境里跑不受信任的负载时要额外加固:非 root 用户运行、丢 capabilities、只读根文件系统、加 seccomp 配置。这些配置我在第 4 节和第 6 节会具体给写法。
2. 底层原理拆解:Namespace 与 Cgroups 到底做了什么
2.1 Namespace:让容器"看不见"外面的世界
容器看起来像一台独立机器,靠的是 Linux 的 Namespace 机制。每一种 Namespace 隔离一类系统资源,创建容器时给进程套上一组新的 Namespace,它看到的世界就变了。
| Namespace | 隔离内容 | 一个直观的现象 |
|---|---|---|
| PID | 进程号空间 | 容器内ps只有自己的 1 号进程,看不到宿主的几百个进程 |
| NET | 网络设备、端口、路由表、防火墙规则 | 容器内监听 80 端口,和宿主的 80 端口不冲突 |
| MNT | 挂载点 | 容器看到的根目录是镜像那一套,不是宿主机的/ |
| UTS | 主机名和域名 | 容器有自己的 hostname |
| IPC | 信号量、共享内存、消息队列 | 容器间的共享内存互不干扰 |
| USER | 用户和用户组 ID 映射 | 容器内是 root,宿主上可能映射成普通用户 |
| CGROUP | cgroup 视图 | 容器内看到的是自己的资源限制 |
这里有个实战意义很大的推论:端口冲突只在同一个 NET Namespace 内才成立。所以你在一个宿主上跑十个容器,每个都监听 80,完全不冲突,冲突的只可能是宿主侧映射出去的端口。新手经常问"我是不是要给每个容器改内部端口",不需要,改外面的映射就行。
另一个推论是 PID 1 的特殊性。容器里的 1 号进程是用户指定的启动命令,它没有内核帮你处理信号转发。如果你的启动命令是bash -c "start.sh",那么docker stop发的 SIGTERM 会打到 bash 上,bash 通常不会转发给子进程,结果就是等完 10 秒超时后被 SIGKILL 强杀,业务进程收到的是硬杀,来不及优雅退出、来不及刷盘。这个问题极其常见,解决办法是用exec形式启动(Dockerfile 里用 JSON 数组形式写ENTRYPOINT),或者引入一个极简的 init 进程来转发信号和回收僵尸进程。
2.2 Cgroups:让容器"用不了"太多资源
Namespace 管"看见什么",Cgroups 管"能用多少"。CPU、内存、块设备 IO、PID 数量,这些都可以通过 cgroup 给一组进程设上限。
实际配置里最常打交道的是 CPU 和内存两项:
CPU 限制用--cpus最直观。--cpus=1.5的意思是这段时间内平均最多用 1.5 个核。底层它被翻译成两个参数:--cpu-period默认 100000 微秒(100ms),--cpu-quota则等于1.5 × 100000 = 150000微秒。也就是说每 100ms 的周期里,这组进程最多能跑 150ms 的 CPU 时间——在多核机器上这完全可能,两个核各跑 75ms 就达到了。理解这个换算关系,你就能明白为什么--cpus=1的容器在 8 核机器上依然能吃满一个核而不被限流。
内存限制用-m或--memory。这里有个必须知道的隐藏规则:当你只设置--memory而不设置--memory-swap时,swap 上限默认是内存值的两倍(即 swap 部分等于 memory 值)。所以-m 512m实际意味着内存 512M + swap 512M,总共 1G。如果你想彻底禁掉 swap,得显式写--memory-swap等于--memory的值。这个默认值坑过不少人,尤其是排查"为什么内存超了还没被 OOM"的时候。
超过内存限制时,内核的 OOM killer 会挑一个进程干掉,容器直接退出,退出码是137(128 + 9,SIGKILL)。所以看到 137 不要第一反应是"程序有 bug 崩了",先去看docker inspect里的OOMKilled字段是不是 true,是的话就是内存给少了。这个判断路径后面第 7 节会再细讲。
2.3 一个必须重视的容器感知问题
JVM 类应用有个经典坑:老版本 JDK 读的是宿主机/proc/meminfo里的总内存来算堆大小,完全无视 cgroup 限制。结果就是宿主机 64G,容器限了 1G,JVM 一看有 64G,默认最大堆设成 16G,启动没多久就被 OOM Killer 干掉,日志里连个像样的报错都没有。
从 JDK 8u191 和 JDK 10 开始默认开启容器感知(-XX:+UseContainerSupport),会正确读取 cgroup 限制。所以现在的最佳实践是:基础镜像用较新的 JDK,并且显式设置-XX:MaxRAMPercentage=75.0这类百分比参数,而不是写死-Xmx的绝对值。写死绝对值的话,你在本地 2G 限制下调好了,上生产 4G 限制还是那个值,资源白白浪费;用百分比则跟着容器内存限制自动缩放。
同样的道理也适用于 Node.js 的--max-old-space-size、.NET 的 GC 堆配置、Python 某些数值库的线程池大小。凡是有"自动探测系统资源"行为的运行时,容器化之后都要检查一遍探测逻辑是否尊重 cgroup 限制。
3. 安装与 daemon.json:把地基打稳
3.1 装完之后第一件事不是 run,是改配置
Docker 守护进程的配置集中在/etc/docker/daemon.json。很多教程只让你配个镜像加速地址就完事了,实际上这里有几个项如果不改,后面一定会出问题。
先说配置的加载方式。这个文件在守护进程启动时读取,改完之后必须重启服务才生效。重启的方式在不同发行版上不一样,用systemctl管理的系统上是systemctl reload-or-restart docker。注意reload和restart的区别:reload只重载部分配置(比如 debug 开关),restart会停掉所有运行中的容器,虽然容器会被重新拉起(取决于重启策略),但业务会有一次短暂中断。所以改配置的时间点要挑,别在业务高峰期动。
一个典型的、我认为值得作为起点的配置长这样:
{ "data-root": "/data/docker", "storage-driver": "overlay2", "log-driver": "json-file", "log-opts": { "max-size": "50m", "max-file": "3" }, "default-ulimits": { "nofile": { "Name": "nofile", "Hard": 65535, "Soft": 65535 } }, "live-restore": true, "registry-mirrors": ["https://<你的专属加速地址>"], "max-concurrent-downloads": 5, "insecure-registries": [] }逐项说一下为什么。
>FROM eclipse-temurin:17-jre-jammy WORKDIR /app COPY pom.xml . RUN mvn -B dependency:go-offline COPY src ./src RUN mvn -B -DskipTests package EXPOSE 8080 ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/target/app.jar"]
改代码的时候,COPY pom.xml和依赖下载这两层稳稳命中缓存,只有源码编译和之后的层重建。实测下来,从五分钟压到三十秒左右是常见的。
Python 项目同理:先COPY requirements.txt再RUN pip install -r requirements.txt,最后才COPY . .。Node 项目是先COPY package.json package-lock.json再npm ci,最后拷源码。
顺带提一句ADD和COPY的区别:ADD额外支持自动解压本地 tar 和从 URL 下载。这两个"额外功能"通常被认为是反模式——自动解压让人猜不到行为,从 URL 下载则让构建依赖网络且无法利用缓存。所以除非你真的需要解压 tar,一律用COPY。
4.2 多阶段构建:把编译工具链挡在最终镜像外面
一个典型的编译型语言镜像,如果单阶段构建,会把 Maven、GCC、完整 JDK、源码、缓存目录全塞进去,最终镜像动辄八百兆一G,而真正需要运行的只有几十兆的产物和运行时。
多阶段构建的写法是定义多个FROM,后面的阶段可以从前面阶段COPY --from取文件,中间阶段不进入最终镜像:
FROM golang:1.22-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/server FROM alpine:3.19 RUN apk add --no-cache ca-certificates tzdata && \ adduser -D -u 10001 app USER app WORKDIR /app COPY --from=builder /out/server /app/server EXPOSE 8080 ENTRYPOINT ["/app/server"]这个例子最终镜像大概十几兆。几个细节:CGO_ENABLED=0让产物静态链接,不依赖 glibc,才能在 alpine 上跑;-ldflags="-s -w"去掉调试符号,体积能再小一截;装了ca-certificates才能发 HTTPS 请求,装了tzdata才能正确用非 UTC 时区——这两个是 alpine 镜像的经典缺失项,不装的话报错莫名其妙。
另外adduser -D -u 10001 app加USER app是我强烈建议保留的。默认容器以 root 运行,一旦应用有文件上传之类的漏洞,攻击者在容器内就是 root,往下利用宿主漏洞的门槛低很多。用一个固定的、非 1000 以下的高位 UID(10001 这类),配合只读根文件系统,安全性会有实质提升。用固定 UID 而不是让系统随机分配,是为了让挂载出来的数据卷权限稳定,不会因为重建容器换了 UID 导致读写失败。
4.3 .dockerignore 和构建上下文
docker build的第一步是把构建上下文(默认是当前目录)打包发给守护进程。如果你的工程目录里有node_modules、.git、target、dist、日志文件,这些全都会被传过去——几百兆的上下文,每次构建都要传一遍,慢还占磁盘。
.dockerignore就是解决这个的,语法跟.gitignore类似:
.git node_modules target dist *.log .env这里有个安全角度的考虑:.env、密钥文件、本地数据库文件都不该进上下文。虽然不写进 Dockerfile 它们就不会进镜像,但上下文会被临时保存,而且一旦有人误写COPY . .,这些东西就直接打进镜像层里了——镜像层是删不掉的,即使后来在 Dockerfile 里RUN rm,文件依然在历史层里可以被提取出来。这一点必须记住,凭据泄露事故有不少就是这么来的。
4.4 健康检查:让编排系统知道你的服务真的活着
容器状态是Up不代表服务可用。进程还在但死锁了、数据库连接池耗尽了、内部线程池打满了,这些情况进程都不会退出。HEALTHCHECK让 Docker 定期探测,把健康状态反映到docker ps的 STATUS 列里。
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1几个参数的含义值得说清楚:interval是探测间隔;timeout是单次探测超时,超过就算失败;start-period是启动宽限期,这段时间内的失败不计入重试次数——这个参数非常关键,不设的话,应用启动慢的容器会在启动阶段就被判成 unhealthy,被编排系统反复杀掉重启,形成死循环;retries是连续失败几次才标记为 unhealthy。
探测命令尽量用镜像里已有的工具。alpine 里没有curl但有wget,distroless 镜像里什么都没有,就得在构建阶段带一个小的探测二进制进去,或者干脆依赖外部探活。这点在小体积镜像上是常见的小麻烦。
5. 网络与数据卷:容器怎么连、数据往哪放
5.1 五种网络模式的选型逻辑
| 模式 | 网络隔离 | 典型场景 | 注意点 |
|---|---|---|---|
| bridge(默认) | 独立 NET NS + NAT 出网 | 绝大多数普通服务 | 默认 bridge 不支持容器名解析 |
| 自定义 bridge | 独立 NET NS + 内嵌 DNS | 需要容器间按名字互访 | 推荐用它替代默认 bridge |
| host | 共享宿主网络栈 | 极致网络性能、大量端口的场景 | 端口直接占用宿主,无隔离 |
| none | 只有 loopback | 纯计算任务、安全隔离 | 需要自己配网络 |
| container:xxx | 共享另一个容器的网络栈 | 边车代理模式 | 生命周期要跟被共享容器绑定 |
这里最关键的一条经验是:别用默认的 bridge 网络跑多容器应用。默认 bridge 只提供 IP 互通,容器之间没法用容器名互相解析,你只能写死 IP——而容器重建后 IP 会变,配置就废了。自定义 bridge 网络自带内嵌 DNS,同一个网络里的容器可以直接用容器名或网络别名当主机名访问。
创建和使用:
docker network create --driver bridge --subnet 172.20.0.0/16 app-net docker run -d --name mysql --network app-net mysql:8.0 docker run -d --name api --network app-net my-api:latest # 此时 api 容器内可以直接 ping mysql 或用 mysql 作为 hostname 连接--subnet建议显式指定。不指定的话 Docker 会从 172.17.0.0/16 开始自动分配,多台机器、多个网络叠加时容易跟已有的内网网段冲突。一旦撞上公司内网段或者云上的 VPC 段,表现是路由异常、访问超时,排查起来非常费劲。选定一个冷门网段(比如 172.20 到 172.31 之间挑)能省很多事。
host模式不是不能用,但要清楚代价。它省掉了 NAT 的一次转发,性能上确实有优势,尤其是在高包速率的场景。但端口全部暴露在宿主上,无法做端口映射,多个容器也不能用同一个端口,隔离性基本没有了。适合的场景是那些对网络延迟极敏感、且一台机器只跑一个实例的服务。
5.2 端口映射的两种写法和一个常见坑
-p 8080:80和-p 127.0.0.1:8080:80的区别在于绑定的宿主地址。前者等价于-p 0.0.0.0:8080:80,监听所有网卡;后者只监听回环,外部访问不到。默认是前者,这意味着任何能连到这台机器 8080 端口的人都能访问这个容器。如果你的服务只打算给本机的反向代理用,一定要绑127.0.0.1,否则相当于把一个没有鉴权的服务直接暴露在网络上。这是安全审计里最常见的一类问题。
协议方面,-p默认映射 TCP,UDP 要显式写-p 53:53/udp。另外映射一段端口用-p 8000-8010:8000-8010,容器数量多的时候能省不少命令行长度。
5.3 三种数据挂载方式的取舍
| 类型 | 声明方式 | 存储位置 | 适用场景 |
|---|---|---|---|
| 匿名卷 | -v /容器内路径 | Docker 管理的 volumes 目录下随机名 | 临时保存,不建议用于需要管理的场景 |
| 具名卷 | -v mydata:/容器内路径 | Docker 管理的 volumes 目录下指定名 | 数据库数据、需要备份迁移的持久数据 |
| 绑定挂载 | -v /宿主路径:/容器内路径 | 你指定的宿主目录 | 配置文件、开发时挂源码、日志外落 |
具名卷的优势在于它完全由 Docker 管理,备份、迁移、跨容器共享都方便,而且不受宿主文件系统的权限和 SELinux 影响。数据库类服务我一律建议用具名卷,而不是绑定挂载到宿主的某个目录——后者虽然直观,但会遇到 SELinux 标签、目录权限、NFS 不支持某些文件操作等一堆麻烦。
绑定挂载的优势在于配置文件的注入。最典型的用法是只读挂载配置文件:
docker run -d --name nginx \ -v /etc/nginx/conf.d/default.conf:/etc/nginx/conf.d/default.conf:ro \ -p 80:80 nginx:1.25注意那个:ro,只读挂载能防止容器内的进程意外改坏宿主上的文件。这个后缀一定要加,代价为零,收益很实在。
还有一个坑:绑定挂载宿主目录时,如果宿主目录不存在,Docker 会自动创建一个属主为 root的空目录。如果你用非 root 用户跑容器(USER 10001),对这个目录没有写权限,应用启动就会失败,报权限拒绝。解决办法是提前在宿主上mkdir -p并chown 10001:10001,或者用--user $(id -u):$(id -g)让容器以当前用户身份跑(开发环境常用,生产不推荐)。这个问题在挂载日志目录时特别常见。
6. 资源限制与运行时配置的实操细节
6.1 一个完整的资源配置示例和它的计算过程
光给参数不给计算过程就是耍流氓。假设我们有一个 Java 服务,宿主机是 8 核 16G,我们打算给它分配 2 核和 2G 内存,配置如下:
docker run -d --name api \ --cpus=2 \ --memory=2g \ --memory-swap=2g \ --memory-reservation=1536m \ --pids-limit=512 \ --restart=unless-stopped \ my-api:1.0逐项算一算:
--cpus=2换算成--cpu-quota=200000(周期 100000 微秒乘以 2)。这表示每个 100ms 周期最多使用 200ms 的 CPU 时间,可以是两个核各满跑,也可以是一个核跑两倍速——后者不可能,所以实际是两个线程满载。
--memory=2g是硬限制,超过就被 OOM kill。--memory-swap=2g与 memory 相等,意味着 swap 可用量为零(swap 总量 = memory-swap - memory)。这一步很关键,Java 服务被换出到 swap 上会导致 GC 停顿时间飙升,宁可让它 OOM 快速失败重启,也不要让它卡在 swap 上假死。如果你确实希望留一点缓冲,可以设--memory-swap=3g,那就有 1G 的 swap 空间。
--memory-reservation=1536m是软限制,它影响的是内存紧张时内核回收的优先级。当系统内存不紧张时,容器可以用超过 1536m 而不到 2g;一旦系统整体内存吃紧,内核会优先回收超过软限制的部分。这个参数在多容器共享宿主的时候很有用,能让资源分配更公平。
--pids-limit=512限制容器内最大进程/线程数。这个参数是为了防 fork 炸弹,同时也是个不错的异常检测手段:如果你的服务正常只有几十个线程,突然冲到 512,说明有线程泄漏或者连接池配置炸了。
Java 侧的对应配置是-XX:MaxRAMPercentage=75.0,2G 限制下最大堆约 1.5G,剩下的 500M 留给元空间、线程栈、直接内存(Netty 的堆外缓冲很容易吃掉几百兆,这点必须留够)。
6.2 重启策略:不同场景选不同值
--restart有四个取值,选错会导致各种诡异现象。
no是默认值,容器退出后不重启。开发调试时用它,避免改完代码重启容器时被自动拉起来。
on-failure[:max-retries]只在非零退出码时重启。适合批处理任务和一次性作业。带上重试次数能防止一个必然崩溃的容器无限重启耗资源。
always无论退出码是什么都重启,包括你手动docker stop之后——注意,手动停止的容器在 Docker 守护进程重启后会被重新拉起来。这个行为经常让人困惑。
unless-stopped跟always类似,区别是手动docker stop之后,即使守护进程重启也不拉起来。这是生产环境我推荐的默认值,因为它尊重运维的手动意图。
一个常见误解是认为重启策略能解决应用启动失败的问题。实际上restart配合崩溃循环会形成"启动-崩溃-重启"的死循环,日志飞速轮转,CPU 被占满。遇到这种情况,正确做法是先把策略改成no或者设max-retries,把问题查清楚再改回去。
7. 常见问题排查实录
7.1 速查表:从现象反推原因
| 现象 | 最可能原因 | 第一步验证命令 |
|---|---|---|
| 容器退出码 137 | OOM Kill 或手动 SIGKILL | docker inspect <name> | grep OOMKilled |
| 容器退出码 143 | 收到 SIGTERM 正常退出 | 检查是否被docker stop或编排系统驱逐 |
| 容器 Up 但访问不通 | 端口未映射或绑定了 127.0.0.1 | docker port <name> |
| 容器间不能用名字互访 | 用了默认 bridge 网络 | docker inspect <name> | grep NetworkMode |
| 构建卡在 Sending build context | 上下文太大,没有 .dockerignore | du -sh .看目录大小 |
| 磁盘持续增长 | 日志无轮转或镜像层堆积 | docker system df -v |
| 容器内时间差 8 小时 | 容器默认 UTC,未挂时区 | docker exec <name> date |
| 容器内 DNS 解析失败 | 宿主 resolv.conf 有问题或被覆盖 | docker exec <name> cat /etc/resolv.conf |
7.2 磁盘占用暴涨的完整排查路径
docker system df是第一条命令,它把占用分成四类:Images、Containers、Local Volumes、Build Cache。看到哪一类异常大,再往下钻。
镜像占大头的话,先看是不是有很多<none>:<none>的悬空镜像——这些是构建过程中产生的中间层,标签被新构建覆盖后就成了悬空状态。清理用docker image prune,加-a会删掉所有没有被容器引用的镜像(这个要小心,会把你还想留着的旧版本镜像也删掉)。
容器占大头,八成是可写层里堆了日志或临时文件。docker ps -s能看到每个容器的大小,找出异常的那个,进去看是哪个目录大。这时候要区分两种日志:docker logs能看到的(走 stdout,存在 json-file 里)和应用自己写的文件(在可写层里)。前者配log-opts轮转,后者要么挂出来,要么在应用侧做滚动。
卷占大头,docker volume ls列出所有卷,但注意卷不会因为容器删除而自动删除(除非用docker run --rm -v的匿名卷)。这是有意设计——数据比容器重要。代价是长期下来会有大量孤立卷。docker volume prune能清理没有被任何容器引用的卷,但执行前务必确认没有需要保留的数据。
构建缓存占大头是最近几个版本才凸显的问题(BuildKit 默认开启后缓存会持久化)。docker builder prune清理,加--filter until=168h可以只清理一周前的。
注意:
docker system prune -a --volumes会删掉所有未被使用的镜像、容器、网络和卷,是核弹级操作。在没有确认过数据位置之前,绝对不要在还跑着业务的生产机器上顺手执行。
7.3 时间不对和 DNS 不对:两个高频小问题
容器默认时区是 UTC。应用日志时间戳比本地时间差 8 小时,问题就出在这。解决办法有两个:一是在 Dockerfile 里装tzdata并设ENV TZ=Asia/Shanghai;二是运行时挂载宿主时区文件-v /etc/localtime:/etc/localtime:ro加-e TZ=Asia/Shanghai。前者更彻底(镜像自带),后者更灵活(不用重新构建镜像)。我一般两个都做,镜像里带 tzdata 保证可用,运行时用环境变量控制实际值,方便同一镜像在不同区域部署。
DNS 问题的表现是容器内ping域名失败但pingIP 正常。原因通常是容器内的/etc/resolv.conf继承自宿主,而宿主的配置在某些环境下指向了不可达的地址。可以在daemon.json里全局配"dns": ["223.5.5.5", "8.8.8.8"],或者在docker run时用--dns单独指定。注意如果用了自定义网络,Docker 的内嵌 DNS 会接管 127.0.0.11,这个地址不能改,外部 DNS 是在它转发不到时才用的。
还有一个隐蔽的情况:容器内/etc/hosts被挂载或者被--add-host改了之后,解析顺序可能和预期不一致。排查时直接cat /etc/nsswitch.conf和cat /etc/hosts,别猜。
7.4 构建失败的几个典型原因
COPY failed: file not found一般不是文件真不存在,而是被.dockerignore排除了,或者路径相对于构建上下文的位置搞错了。前者检查 ignore 文件,后者记住COPY的源路径是相对于构建上下文根目录,不是相对于 Dockerfile 所在目录。
网络相关的失败(拉基础镜像超时、apk add失败)在构建阶段很常见。构建时的网络走的是守护进程的网络,不是宿主 shell 的网络,所以你在 shell 里配的代理对docker build不一定生效。要么在构建时用--build-arg传进去,要么确认registry-mirrors配对了。
RUN指令里用apt-get install不带-y,或者装完不清理/var/lib/apt/lists,前者会让构建卡住等输入(非交互环境下直接失败),后者会让镜像白白大几十兆。正确写法是一行搞定安装和清理:
RUN apt-get update && \ apt-get install -y --no-install-recommends ca-certificates curl && \ rm -rf /var/lib/apt/lists/*--no-install-recommends能省掉一堆推荐包,实测下来在 Debian 系基础镜像上能少几十兆。
8. Compose:把一坨 docker run 收敛成文件
8.1 什么时候该上 Compose
当你的服务超过两个容器、并且它们之间有依赖关系(应用依赖数据库、应用依赖缓存),继续用docker run一条条敲就开始失控了:启动顺序、网络、卷、环境变量散落在 shell 历史里,换台机器就得重新回忆一遍。
Compose 的价值不是"少敲几个命令",而是把整个运行拓扑变成可版本控制的声明式文件。一个典型的编排文件大致是:
services: api: image: my-api:1.0 build: context: . dockerfile: Dockerfile ports: - "127.0.0.1:8080:8080" environment: - TZ=Asia/Shanghai - DB_HOST=db env_file: - .env depends_on: db: condition: service_healthy volumes: - ./logs:/app/logs restart: unless-stopped deploy: resources: limits: cpus: "2" memory: 2g healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"] interval: 30s timeout: 3s start_period: 40s retries: 3 db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} MYSQL_DATABASE: app volumes: - dbdata:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"] interval: 10s retries: 5 start_period: 60s volumes: dbdata:几个值得单独拎出来说的点。
version字段在新版 Compose 规范里已经不再需要了,写了会有一条警告。老教程里普遍有version: "3.8",看到直接删掉。
depends_on的短语法(只写服务名)只保证启动顺序,不保证被依赖的服务已经就绪。数据库容器Up了但还没初始化完,应用连上去照样失败。长语法加上condition: service_healthy,配合被依赖服务的healthcheck,才能真正等到就绪。这个改动能消灭掉一大类"重启一下就好了"的启动顺序问题。
env_file和environment可以同时用,后者优先级更高。密码类的值一律放.env文件,environment里只放非敏感的配置。.env要加进.gitignore,别提交。这个习惯看着基础,但因为提交.env泄露凭据的事故每年都有。
ports里我写的是127.0.0.1:8080:8080,把服务限制在回环上,让宿主上的反向代理去对外。如果整个环境只在内网用,这样最省心。
8.2 环境变量注入与配置分离的实操套路
容器化配置管理的核心原则是:同一个镜像,在不同环境用不同的配置跑,镜像本身不变。做到这一点靠的就是外部注入。
注入方式有三种,优先级从高到低:命令行-e、Compose 的environment、env_file、镜像里 Dockerfile 的ENV。理解这个顺序,能解释很多"为什么我改了文件没生效"的问题——很可能是命令行里有个-e覆盖了。
对于复杂的配置(比如一份几十行的application.yaml),用环境变量一条条注入不现实。这时候用挂载:
volumes: - ./config/application.yaml:/app/config/application.yaml:ro但要注意,配置文件挂载进去之后,容器重建时这个文件是宿主的,改了不用重建容器,重启进程就行。这比重新打包镜像快得多,也是把配置从镜像里剥离出来的最大收益。
还有一个进阶做法是用配置模板加启动时渲染。镜像里放一个application.yaml.tmpl,容器启动时用一个很轻的脚本把环境变量替换进去,输出到实际配置路径再启动主进程。这样做的好处是同一份模板 + 不同的环境变量 = 不同环境的配置,既保留了声明式,又不用挂文件。缺点是多了一层启动脚本,调试时要多看一眼渲染结果。
我在实际使用中踩过的一个坑是:渲染脚本里用了sed替换,而某个密码里恰好包含/和&这类 sed 的特殊字符,替换结果直接把整行搞乱了。后来统一改成用envsubst,并且把模板里变量的写法限定成${VAR}这种明确形式,避免误替换。这类小问题不遇到一次根本想不到,但遇到了能卡你半天。