Docker 这个技术,我这些年带过不少人上手,也见过更多人半途离场。有意思的是,放弃的人几乎都停在同一批位置:hello-world 跑通了,docker run敲过了,docker ps也会看了,可一旦要自己写 Dockerfile、要挂数据卷、要让两个容器互相通上话,问题就来了——报错看不懂,网上抄的命令又不灵,折腾两晚上没结果,最后默默回到原来那套手动部署的老路子,还得安慰自己一句“容器这玩意儿不适合我们这种小项目”。
“从入门到放弃”这句话挂在 Docker 身上真不算冤枉它,但冤枉的其实不是 Docker,而是大部分教程的讲法:只告诉你敲什么命令,不告诉你这条命令在系统里到底改了什么。命令是背不完的,原理懂了命令自然就想得起来。这篇东西我想换个写法,不按“镜像、容器、仓库”的教科书顺序讲,而是按一个真实学习者会撞上的坑来排序,先说你为什么会卡住,再说这块东西的底层是怎么回事,最后落到能直接抄走的配置。
适合谁看:装过 Docker 但用得不利索的、写 Dockerfile 基本靠抄的、准备把手上服务容器化但心里没底的。不需要你有多深的 Linux 功底,但至少得知道ps、cd、chmod这些基础命令在干什么,不然下面有些排查过程你会跟丢。
1. 放弃这件事,通常发生在哪几个瞬间
我带人的时候有个习惯,不看对方记住了多少命令,而是看他能不能解释清楚“为什么这么写”。绝大多数人卡住的地方高度重合,把它们提前摆出来,能省掉不少自我怀疑——不是你笨,是这些坎本来就集中出现在学习路径的前三分之一。
1.1 第一道坎:把容器当成一台小虚拟机
这是最普遍也最致命的误解。很多人第一次进容器是这样操作的:docker run -it ubuntu bash,进去之后apt update、apt install nginx,装完启动一下,发现能用,于是得出结论——容器就是个轻量虚机,那我以后在容器里装东西就行了。
问题在于,这些改动全都写在容器的可写层里。容器一删,可写层跟着消失,你装的 nginx 一根毛都留不下。这不是 Docker 的缺陷,这是它的设计:容器被定位成一个一次性、可丢弃的运行单元,它的一切都应该能从镜像重新长出来。
正确的思路是反过来的:你不是“进容器里装软件”,而是“把要装的东西写进 Dockerfile,构建成镜像,再从镜像起容器”。容器里只跑进程,不承载状态。这个观念一旦转过来,后面 80% 的困惑会自动消失。
我一般会用一个类比帮人理解:镜像是模具,容器是模具压出来的一个产品。你可以把产品砸了重新压一个,但你不能指望着砸了产品之后模具自动长出新的花纹。
1.2 第二道坎:数据放在容器里,删容器那天才发现
第二个高发点是数据。典型的场景是本地跑个数据库试试水,docker run -d --name mysql -e MYSQL_ROOT_PASSWORD=xxx mysql:8,用了一周,攒了一堆测试数据。某天想升级版本,习惯性地docker rm -f mysql,然后重新 run 了一个——数据没了。
这时候人通常会有两种反应:一种是觉得 Docker 不靠谱,彻底放弃;另一种是开始满世界找“数据恢复”,浪费更多时间。实际原因很简单,容器内的文件系统是临时的,你从来没有把数据“放出去”过。
解决方式就是挂载,但挂载有两种,选错了照样有坑,第 4 章会详细拆。这里先记住一句话:任何需要跨越容器生命周期存在的东西,都必须落在容器外面。数据库文件、上传的附件、日志、配置文件,全在此列。
1.3 第三道坎:docker run 后面跟了十几个参数
第三道坎是从“能跑”到“能维护”之间的那道墙。当你把端口、环境变量、数据卷、网络、重启策略都堆到一条docker run上,它大概会长成这样:
docker run -d --name api \ -p 127.0.0.1:8000:8000 \ -e DATABASE_URL=postgres://app:pass@db:5432/app \ -e LOG_LEVEL=info \ -v /data/uploads:/app/uploads \ -v /data/conf:/app/conf:ro \ --network appnet \ --restart unless-stopped \ --health-cmd "curl -f http://localhost:8000/health || exit 1" \ --health-interval 30s \ api:1.2.0这条命令你今天记得住,下个月呢?换台机器呢?同事接手呢?靠文档记录迟早会漂移,靠记忆更不现实。到了这一步,很多人不是学不会,而是被这种“每次都要小心翼翼拼一条长命令”的体验劝退了。
出路是把这些参数搬进compose.yaml,一条docker compose up -d搞定,配置进版本库,可 review、可回溯。第 6 章会完整拆一份能用的 compose 文件。所以你看,“放弃”的三个关键节点,本质上都不在命令层面,而在心智模型层面:容器是什么、数据放哪、配置怎么管。这三件事想明白了,剩下的都是查文档的活。
2. 把镜像这层窗户纸捅破:分层、缓存与写时复制
镜像这块如果只记一句“镜像是只读的”,那基本等于没学。真正需要建立起来的直觉是:镜像是一层一层叠起来的,每层是一组文件系统变更,层与层之间可以共享。理解了这个,你才能解释为什么 Dockerfile 换个顺序构建速度差十倍,为什么删了文件镜像反而没变小。
2.1 一条指令一层,书写顺序直接决定复用率
先看一段很常见的、写得不那么好的 Dockerfile:
FROM node:20 WORKDIR /app COPY . . RUN npm install RUN npm run build CMD ["node", "dist/main.js"]问题出在COPY . .放在了npm install前面。COPY . .这一层的缓存 key,取决于你项目目录里所有文件的内容。也就是说,你改一行 README、改一个.gitignore,这一层的缓存就失效,后面的npm install全部重跑,本来 3 秒能完成的构建变成 3 分钟。
正确写法是把变化频率低的东西放前面:
FROM node:20 WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build CMD ["node", "dist/main.js"]package.json和锁文件只有在依赖真的变动时才会改,所以npm ci这一层绝大多数时候都能命中缓存。这是镜像分层带来的最大红利,也是我见过最多人浪费掉的东西。
关于“每条指令都产生一层”这个说法,需要稍微精确一点:RUN、COPY、ADD这类会改动文件系统的指令,必然产生新的一层;而ENV、LABEL、EXPOSE、WORKDIR、CMD、ENTRYPOINT这类只改元数据或配置的指令,在当前默认构建器下通常不会单独占一层,只记录在镜像配置里。所以别为了“减少层数”把一堆RUN用&&硬串成一条——层数本身没那么贵,缓存命中和可读性才是关键。串命令的主要理由是避免中间产物残留,不是为了省层。
2.2 构建缓存什么时候命中,什么时候失效
缓存的判定逻辑并不复杂,但很多人只知其然。简单讲,每一条指令在计算缓存 key 时,会考虑两样东西:父层的内容,以及这条指令本身的文本(对COPY/ADD来说还包括被复制文件的校验和)。两者任一变化,这一层及其之后所有层全部失效。
这条规则解释了几个常见现象:
- 你在 Dockerfile 前面加了个
ENV,虽然不影响文件系统,但后面某条指令的文本变了,从那条开始缓存重建。 - 你
COPY了一个目录,目录里有个不断变化的日志文件被带进去了,缓存天天失效。这时候.dockerignore就是解药(第 7 章展开)。 - 用
--no-cache构建是排查“本地能过、CI 不过”这类玄学问题的第一手段,因为它排除了缓存污染这个变量。
实际排错时我常用的一个手法是,构建时盯住每一步输出的CACHED标记,一眼就能看出是从哪一层开始重建的。如果发现本该命中的层没命中,直接去看那层之前最近的一次文件变更,基本八九不离十。
2.3 容器写时复制:删了文件,镜像为什么没变瘦
再讲一个几乎所有人第一次都会困惑的现象。你做了个镜像,里面有个 500MB 的临时文件,你在 Dockerfile 后续加了一条RUN rm -f /tmp/bigfile,构建出来发现镜像还是那么大。为什么?
因为分层是只读叠加的。RUN rm这一层做的事,是在自己的层里写一个“白障”(whiteout)标记,声明“这个路径在下面那层里的内容被遮蔽了”,但它并不会去动下面那层已经存在的 500MB 数据。最终镜像体积仍然是 500MB 减去 0。
所以要减小体积,唯一有效的办法是让那个文件从来不曾出现在任何一层里。典型做法是在同一条RUN指令里完成“下载—使用—删除”三步:
RUN curl -fsSL https://example.internal/tool.tar.gz -o /tmp/tool.tar.gz \ && tar -xzf /tmp/tool.tar.gz -C /usr/local/bin \ && rm -f /tmp/tool.tar.gz这也顺带解释了容器运行时的写时复制(Copy-on-Write):容器启动时并不是把镜像整个拷一份,而是在镜像只读层之上叠一个薄薄的可写层,读的时候穿透到下面,写的时候才真正复制上来。所以起一个 1GB 的镜像容器,启动通常是秒级的,占用的额外磁盘也很少——直到你在里面写了大量数据。
我个人的体会是,把“层”这个概念真正吃透之后,看待 Dockerfile 的眼光会完全不同:你不再是在写一串安装脚本,而是在设计一个可复用的缓存结构。这个视角的转变,比记住二十条最佳实践管用得多。
3. 容器的生死:从 docker run 到退出的完整链路
“容器跑起来又退出了”是新手群里出现频率最高的问题,没有之一。这个问题之所以难,是因为它牵扯到前台进程、PID 1、信号处理、退出码好几个概念,而大部分教程只丢一句“容器里要有前台进程”就跑了。这一章把这条链路完整走一遍。
3.1 run、create、start 到底差在哪
很多人以为docker run是一个原子操作,其实它是三步:create(创建容器,准备好可写层和配置)、start(启动容器内的主进程)、然后可选地attach(把终端接上去)。拆开就很好理解:
docker create --name demo nginx:1.25 # 只创建,不启动 docker start demo # 启动 docker exec -it demo bash # 另外开一个进程进去docker run本质上就是create+start的语法糖,加上-it的时候再附加一个终端。
关键在于:容器的生命周期,等同于它内部 1 号进程(PID 1)的生命周期。PID 1 一退出,容器就结束,不管里面还有没有别的进程在后台跑。这就是“容器里必须有个前台进程”这句话的真正含义——不是必须“前台”,是必须有一个占据 PID 1 且不会自己退出的进程。
对比一下就清楚了。docker run -d nginx能一直活着,因为 nginx 镜像的启动命令是nginx -g 'daemon off;',让 nginx 待在前台不脱守。而如果你写一个CMD service nginx start,service脚本启动完 nginx 就返回了,PID 1 退出,容器立刻停止。很多老教程里遗留的这种写法,坑了一代又一代人。
3.2 容器一启动就退出,按这个顺序查
我自己的排查顺序是固定的,从快到慢:
第一步,看日志。docker logs <容器名>,绝大多数情况这里就有答案——配置文件语法错了、端口被占了、连不上数据库、找不到启动脚本。如果日志是空的,说明进程根本没起来,继续往下。
第二步,看退出码。docker inspect -f '{{.State.ExitCode}}' <容器名>。退出码是个信息密度极高的东西,我整理了一张常用对照:
| 退出码 | 含义 | 常见原因 |
|---|---|---|
| 0 | 正常退出 | 主进程执行完就返回了,比如启动脚本没加exec或没有前台进程 |
| 1 | 应用级错误 | 配置语法错误、依赖缺失、权限不足 |
| 126 | 命令不可执行 | 脚本没有可执行权限,或 shebang 指向的解释器不存在 |
| 127 | 命令未找到 | CMD里写的可执行文件路径不对,或基础镜像里根本没装 |
| 137 | 被 SIGKILL 杀掉 | 最常见是内存超限被 OOM Killer 干掉,也可能是docker stop超时后强杀 |
| 139 | 段错误 | 二进制与架构不匹配,比如在 x86 上跑了 arm 镜像 |
| 143 | 收到 SIGTERM 后退出 | 正常的优雅停止流程,一般不用管 |
137 这个码值得单独说一句。很多人看到 137 第一反应是“Docker 有 bug”,实际上八成是容器内存超过了限制被内核杀了。docker inspect里看State.OOMKilled字段,如果是true,那就实锤了,去调--memory或者查应用的内存泄漏。
第三步,临时改命令进容器看。把CMD覆盖掉,直接起个 shell:
docker run --rm -it --entrypoint sh your-image:tag进去之后手动敲一遍启动命令,报错会直接打在脸上,比看日志快。这一招在排查“镜像构建没问题但跑不起来”时特别有效。
第四步,检查挂载和权限。配置文件挂载进去之后权限不对、SELinux 上下文不对、目录不存在被 Docker 自动创建成了一个空目录……这些都会导致启动失败,而且日志往往语焉不详。
3.3 exec 和 logs:进容器该看什么,不该看什么
docker exec -it <容器> bash是日常用得最多的命令,但用得有讲究。它是在已经在运行的容器里另起一个进程,跟你ssh进去是两回事。所以容器没跑起来的时候exec是进不去的,这时候得用docker run --entrypoint sh那招。
另外几个常被忽略的细节:
容器里的ps往往看不到全貌,因为你看到的只是这个容器的 PID 命名空间。想在宿主机上确认容器里跑了什么,用docker top <容器>。
docker logs默认只能看到主进程写到 stdout/stderr 的内容。如果你的应用把日志写进了文件,logs里就是空的。这不是 Docker 的问题,是日志没走标准输出。容器化应用的一条铁律就是日志打 stdout,不要自己写文件,交给外部的日志收集去处理。
还有一个坑是logs会把历史全量吐出来,几万行的日志刷屏。加上--tail和--since会舒服很多:
docker logs -f --tail 200 --since 30m <容器名>最后提一句attach。docker attach是直接接到 PID 1 的 stdin/stdout 上,你在终端里按 Ctrl+C,信号会直接打到主进程上,容器可能就此退出。日常看日志用logs -f,别用attach,这个坑我踩过一次就再也没碰过。
4. 数据落在哪:volume、bind mount 与权限坑
数据持久化这块,教程里通常一句话带过“用 -v 挂载就行”,实际上两种挂载方式的适用场景差别很大,权限问题的排查也有固定套路。这一章按“选型—踩坑—运维”的顺序讲。
4.1 volume 和 bind mount 的本质差别
Docker 提供了两种主要的挂载方式,还有一个tmpfs用于纯内存场景(存临时敏感数据、缓存之类)。核心对比如下:
| 维度 | named volume | bind mount |
|---|---|---|
| 语法 | -v mydata:/data | -v /host/path:/data |
| 数据位置 | Docker 管理的目录(Linux 下通常是/var/lib/docker/volumes/) | 你指定的宿主机任意路径 |
| 宿主机是否可直接读写 | 需要 root,路径不直观 | 直接可见可编辑 |
| 跨平台一致性 | 好 | 在非 Linux 宿主上表现有差异 |
| 适合场景 | 数据库数据、需要备份迁移的持久数据 | 开发时挂源码、挂配置文件、挂证书 |
| 空目录行为 | 首次使用时把镜像里对应路径的内容复制过去 | 直接用宿主机目录覆盖容器内路径 |
表格最后一行是很多人栽跟头的地方。bind mount 一个空目录到容器里的/etc/nginx/conf.d,容器内的默认配置文件会被这个空目录整个盖掉,nginx 起来发现没配置,直接报错。这不是挂载失败,是挂载太成功了。而 named volume 在首次挂载到非空目录时,会把镜像里的内容复制进卷里,行为友好得多。
我的选型习惯很简单:需要长期保存、需要备份迁移的数据用 named volume;需要人肉编辑、需要实时看到变化的文件用 bind mount。开发环境挂源码用 bind mount,因为改完立刻生效;生产环境存数据库用 named volume,因为好管理、好迁移。
顺便说一个老生常谈的问题:-v和--mount两种写法,建议统一用--mount。原因不是它更“高级”,而是它在路径不存在时会直接报错,而-v /typo/path:/data会默默帮你创建一个空目录,然后你的应用在一个空目录上跑,报一个完全不相关的错。这种静默失败最耗时间。
4.2 挂载目录权限不对,怎么一步步查
权限问题是容器挂载里最烦的一类,因为报错通常是Permission denied或者干脆“文件不存在”,看不出根因。
先建立一个基础认知:容器里的用户和宿主机上的用户,本质上是同一套 UID/GID 体系。容器不是安全沙箱(至少默认不是),它没有做用户隔离。所以容器里以 UID 1000 运行的进程,去写宿主机上一个属主是 root、权限 755 的目录,当然写不进去。
排查路径我是这么走的:
第一,确认容器内的运行用户。docker exec <容器> id,看 uid 和 gid 是多少。很多官方镜像会创建一个非 root 用户(比如 uid 1000),而不是用 root 跑。
第二,确认宿主机目录的属主和权限。ls -ld /data/uploads,看 owner 和 mode。
第三,对齐。常见做法有两种,一种是改宿主机目录的属主:chown -R 1000:1000 /data/uploads;另一种是在 Dockerfile 里显式创建用户并指定 UID,让它跟宿主机对齐:
RUN groupadd -g 1000 app && useradd -u 1000 -g app -m app USER app我倾向于第二种,因为 UID 写在镜像里是确定的,不用每次部署都去chown宿主机。
还有一种容易混淆的情况:容器里挂载的路径是个文件,不是目录。比如-v ./nginx.conf:/etc/nginx/nginx.conf,如果宿主机的nginx.conf不存在,Docker 会创建一个同名目录挂进去,nginx 读到的是个目录,直接崩。这个坑非常隐蔽,排查的时候ls -l一下宿主机的源路径,看它是-还是d就明白了。
4.3 备份、迁移,以及别用匿名卷
named volume 存在 Docker 自己的目录下,很多人不知道怎么备份,索性不用,转而全用 bind mount。其实备份卷很简单,起一个临时容器把卷挂进去打包就行:
docker run --rm \ -v mydata:/source:ro \ -v $(pwd):/backup \ alpine tar czf /backup/mydata-$(date +%F).tar.gz -C /source .迁移到另一台机器就是反过来解包。这套操作不优雅,但极其可靠,比去找各种卷管理工具省心。
最后强调一句:不要用匿名卷。也就是-v /data这种只写容器内路径的写法。Docker 会自动生成一串哈希命名的卷,时间一长docker volume ls里全是无主卷,你不知道哪个对应哪个服务,也不敢删。要么显式命名,要么在 compose 文件里声明顶层volumes:段落,让它有名字。
提示:
docker volume ls -f dangling=true可以列出没有被任何容器引用的卷,清理之前务必先用docker volume inspect确认内容,卷删了是真的找不回来。
5. 容器之间怎么互相找到:网络与端口
“我两个容器都起来了,为什么 A 连不上 B”——这个问题背后通常有三种完全不同的原因,而报错信息往往都是同一句Connection refused或者Name or service not known。分清楚这三种,能省下大量瞎猜的时间。
5.1 默认 bridge 网络没有 DNS,这是第一个坑
docker run不加--network参数时,容器会挂到默认的bridge网络上。这个默认网络有个很大的限制:它不提供基于容器名的 DNS 解析。也就是说,你的应用容器里写db:5432是解析不了的,只能写 IP——而 IP 会在容器重启后变化。
正确做法是自己建一个用户自定义网络:
docker network create appnet docker run -d --name db --network appnet postgres:16 docker run -d --name api --network appnet -e DB_HOST=db api:1.0在自定义 bridge 网络里,Docker 内置了 DNS 服务,容器名可以直接当主机名用,还能通过--network-alias加别名。这是容器互联最省事的方式,不需要改/etc/hosts,也不需要猜 IP。
顺便解释一下为什么默认网络不这么做:历史原因。默认 bridge 是 Docker 最早期的实现,后来为了避免破坏兼容性没有改,新加的自定义网络才有完整功能。所以“自定义网络”不是可选项,是应该作为默认做法的。
5.2 端口映射写了却访问不通,三种典型情形
端口这块最经典的写法是-p 8080:80,含义是“宿主机的 8080 转发到容器的 80”。看起来简单,但访问不通的情况特别多,我按出现频率排一下。
情形一:应用在容器里只监听了 127.0.0.1。这是最高频的。端口映射的原理是在宿主机上做 DNAT,把进来的包转发到容器的 eth0 地址上。如果你的应用配置成listen 127.0.0.1:8000,那么只有从容器内部 loopback 发起的连接能成功,从 eth0 进来的包会被直接拒绝。解决办法是把监听地址改成0.0.0.0。很多框架的默认配置是localhost,需要显式改。
情形二:映射到了错误的宿主机接口。-p 8080:80等价于-p 0.0.0.0:8080:80,意味着监听所有网卡,包括公网。这在测试环境图方便,在生产环境就是个安全隐患。正确写法是-p 127.0.0.1:8080:80,只允许本机访问,外网流量统一走反向代理。这个区别很多人不知道,我见过不少把数据库端口直接映射到0.0.0.0的,属实危险。
情形三:宿主机端口被占用。报错信息其实很明确(port is already allocated),但有些人扫一眼就过了。用ss -lntp | grep 8080确认一下谁占着,换个端口就行。
排查顺序我建议是:先在宿主机上curl 127.0.0.1:8080,通不通;再docker exec进容器curl 127.0.0.1:80,通不通。两边对照,能立刻定位是应用没起来、监听地址不对、还是映射规则有问题。
5.3 网络别名与多网络接入的实际用法
服务名冲突的时候,--network-alias很有用。比如你同时跑了两套环境,容器名都叫db,放在不同网络里,各自的api通过别名访问自己那套的db,互不干扰。
另一个实用技巧是让一个容器同时接入多个网络。典型场景是:api需要访问db(内网),同时需要被反向代理访问(另一个网络),而不希望db暴露在代理网络里。做法是给api加两次--network,或者用docker network connect动态接入。这样网络边界就成了一个很轻量的隔离手段,不用起多套机器。
docker network create frontend docker network create backend docker run -d --name db --network backend postgres:16 docker run -d --name api --network backend api:1.0 docker network connect frontend api docker run -d --name proxy --network frontend -p 80:80 nginx:1.25这样proxy只能看到api,看不到db,攻击面小了一圈。这个模式在单机场景下非常好用,成本几乎为零。
6. 把命令行搬进 compose.yaml:从能跑到能维护
回到第 1 章那个十几行参数的docker run。这条命令本身没错,错在它没有被固化下来。Docker Compose 解决的就是这个问题——把运行参数从人的记忆里搬到文件里。但 compose 本身也有几个坑,特别是启动顺序这一块。
6.1 坚持手敲 docker run 会让你放弃
我观察到一个规律:凡是长期靠docker run管理多容器的人,最后基本都会退回到手动部署。原因不是技术难度,是认知负担。每次重启机器、每次换环境、每次出问题要重建,你都得重新回忆一遍那些参数,稍有不慎就漏一个环境变量,然后花两小时排查一个本来不该存在的问题。
compose 的价值不在于“少敲几个字”,而在于三件事:配置进版本库、可以被 review、可以一键重建。这三点决定了它能不能长期维护下去。
6.2 一份能直接用的 compose 文件,逐字段拆
下面这份是一个比较完整的前后端分离示例,字段我都写了注释说明用途:
services: api: image: api:1.2.0 build: context: . dockerfile: Dockerfile environment: DB_HOST: db DB_PORT: "5432" LOG_LEVEL: info env_file: - .env volumes: - uploads:/app/uploads depends_on: db: condition: service_healthy healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"] interval: 30s timeout: 5s retries: 3 start_period: 20s restart: unless-stopped networks: - backend db: image: postgres:16 environment: POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 3s retries: 5 networks: - backend volumes: uploads: pgdata: networks: backend:几个字段值得展开说:
restart: unless-stopped和always的区别在于,前者在你手动docker stop之后不会自动拉起,后者会。日常用unless-stopped更符合直觉,不然你停一个容器,它自己又活了,人会疯。
start_period是新版本才有的字段,作用是给应用一段“预热期”,这段时间内健康检查失败不计入retries。对于启动慢的 Java 应用、需要跑数据库迁移的服务,这个字段能避免容器被反复重启。我踩过一次坑:一个 Spring Boot 服务启动要 40 秒,健康检查interval只有 10 秒、retries3 次,结果容器刚起来 30 秒就被判定不健康重启了,无限循环,日志里全是启动日志,看不出问题。
env_file和environment的关系是:environment优先级更高,会覆盖env_file里的同名变量。密码这类东西建议放.env文件并加进.gitignore,compose 里用${VAR}引用,不要明晃晃写在 yaml 里提交上去。
6.3 depends_on 不会等你就绪,这是最常见的误解
depends_on这个名字起得太有迷惑性了,很多人以为它等于“等 db 启动好了再起 api”。实际上它只保证启动顺序,不保证就绪状态。db 容器进入了 running 状态,depends_on的任务就算完成了,而此时 postgres 可能还在初始化数据目录,端口根本没监听。api 一上来就连,直接 connection refused,然后退出。
这就是为什么上面那份文件里同时写了healthcheck和condition: service_healthy。加上这个条件之后,compose 会等到健康检查通过才启动 api。注意,带condition的写法需要 Compose V2 及以上,老版本的写法不一样,用之前docker compose version确认一下。
如果没有健康检查可用,退而求其次的方案是在应用侧加重试。这是更稳妥的做法,因为健康检查通过也不代表服务真的能处理请求,只是说明它在一个时间点上响应了探针。生产级的服务启动逻辑,永远应该假设依赖可能暂时不可用,带上指数退避的重连。这一条经验我是在一次线上事故之后才真正记住的——数据库主从切换的那几十秒里,depends_on帮不上任何忙。
7. 镜像瘦身与非 root 运行:上线前的最后检查
本地能跑起来,跟能不能上生产是两码事。这一章讲三个在上线前必须过一遍的检查项,每一条都对应过我见过的真实问题。
7.1 多阶段构建:把编译工具链留在构建阶段
一个 Go 或者 Java 服务的镜像,如果直接基于完整的基础镜像构建,动辄 800MB 到 1.5GB。里面的编译器、包管理器、构建缓存,运行时一个都用不上,全是攻击面和传输成本。
多阶段构建的思路是:用一个大镜像负责编译,只把产物拷到一个小镜像里运行。
# 构建阶段 FROM golang:1.22 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/app ./cmd/app # 运行阶段 FROM alpine:3.19 RUN apk add --no-cache ca-certificates tzdata WORKDIR /app COPY --from=builder /out/app /app/app USER 1000:1000 EXPOSE 8000 ENTRYPOINT ["/app/app"]这个写法能直接把镜像从 900MB 压到 20MB 以内。几个细节:CGO_ENABLED=0是为了生成静态链接的二进制,不依赖 glibc,才能跑在 alpine 这种精简系统上;-ldflags="-s -w"去掉符号表和调试信息,能再省几 MB;ca-certificates和tzdata一定要装,不然 HTTPS 请求失败、时间显示成 UTC,这两个问题排查起来都挺费劲的。
如果追求更极致的体积,可以用 distroless 或者 scratch 作为基础镜像。代价是没有 shell,docker exec进去啥也干不了,出问题只能靠日志。我的判断标准是:团队有完善的日志和指标体系时,才值得上 scratch,否则排障成本远大于省下的那点体积。
7.2 .dockerignore 是缓存策略的一部分
.dockerignore很多人当成可选项,实际上它是构建效率和镜像安全的关键。COPY . .会把当前目录下所有东西都带进去,包括.git(可能几百 MB)、node_modules、日志文件、本地配置、甚至.env里的密钥。
一个最基本的.dockerignore:
.git .gitignore node_modules dist *.log .env .env.* .DS_Store coverage除了体积,还有一个更隐蔽的影响:破坏缓存。如果.git目录被带进去了,你每 commit 一次,COPY . .这层的缓存就失效,整条构建链路重跑。有人抱怨“构建怎么这么慢”,查到最后就是.git没排除。
7.3 非 root 运行与资源限制
容器默认以 root 运行。这意味着如果容器里跑的应用被攻破,攻击者在容器内就是 root 权限,再配合一些内核漏洞或者不当的挂载配置,就可能影响到宿主机。这不是危言耸听,是容器安全里最基础的一条。
正确做法是在 Dockerfile 里显式创建用户并切换:
RUN groupadd -r -g 10001 appuser \ && useradd -r -u 10001 -g appuser -s /sbin/nologin appuser USER 10001:10001切了非 root 之后,有一批原来能跑的操作会失败,主要是写文件。比如应用想在/app下写缓存、想在/var/log写日志。这时候要么改目录属主,要么把可写路径挂成 volume。这个适配过程通常会花点时间,但值得。
最后是资源限制。这里说个反直觉的现象:容器不设置内存限制时,是完全可以用满宿主机内存的,这时候 OOM Killer 可能杀掉任何进程,包括你的 SSH 会话或者宿主机上的关键服务。加上限制之后,内核会把 OOM 范围收缩到容器内:
docker run -d --memory 512m --memory-swap 512m --cpus 1.5 api:1.2.0--memory-swap设置成和--memory相等,表示禁用 swap,行为更可预测。如果只想限制硬上限,不想限制 swap,把--memory-swap设为-1。这个参数组合我调过好几次才搞明白,官方文档的表述绕得厉害,实测下来就是上面这个规律。
8. 我踩过的坑清单与排错速查
最后这一章不讲原理,纯堆经验。这些都是我在实际项目里撞过、并且看到别人反复撞的问题,按类型整理成可以直接对照的清单。
8.1 磁盘莫名其妙被吃满
最常见的原因是日志。容器的 stdout 默认会被写到宿主机的一个 json 文件里,如果不做限制,一个疯狂打日志的服务几天就能把磁盘写满,然后整个 Docker daemon 卡死,连docker ps都不响应。
配置日志轮转有两种方式。全局方式是在/etc/docker/daemon.json里加:
{ "log-driver": "json-file", "log-opts": { "max-size": "50m", "max-file": "3" } }改完要重启 Docker 服务才生效,而且只对新创建的容器有效,已有的容器需要重建。
单个容器也可以在 compose 里配:
services: api: logging: driver: json-file options: max-size: "50m" max-file: "3"除此之外,还有三类东西会占空间:停止的容器、悬空镜像(<none>标签)、未使用的构建缓存。定期清理用这几条:
docker system df # 先看清楚谁占的 docker container prune # 清理停止的容器 docker image prune -a # 清理未被引用的镜像,谨慎 docker builder prune # 清理构建缓存docker system df一定要先跑,它会把镜像、容器、卷、构建缓存各自的占用列得清清楚楚。我见过有人上来就docker system prune -a --volumes,结果把还在用的卷删了,数据没了。--volumes这个参数,除非你百分百确定,不要加。
8.2 时间和编码,两个不起眼但很磨人的问题
容器里的时区默认是 UTC,跟本地时间差 8 小时。日志时间对不上,排查问题的时候非常容易误判——你会以为是某个操作之后的日志,实际上是之前的。
解决办法有两种:挂载宿主机的时区文件-v /etc/localtime:/etc/localtime:ro,或者在 Dockerfile 里设环境变量ENV TZ=Asia/Shanghai并装上tzdata。前者更省事,后者更可移植,我一般用后者,因为不依赖宿主机。
编码问题主要体现在中文输出乱码。基础镜像如果不带 locale,程序输出中文可能变成问号或者方块。在 Dockerfile 里加一句ENV LANG=C.UTF-8基本能解决大部分情况。这个问题的隐蔽之处在于,本地开发环境通常是有 locale 的,只有容器里没有,所以经常是“本地好好的,上线就乱码”。
8.3 一张速查表,贴在显示器旁边那种
| 现象 | 优先怀疑 | 快速验证 |
|---|---|---|
| 容器启动即退出 | 主进程不是前台进程 / 启动命令写错 | docker logs,再看退出码 |
| 退出码 137 | 内存超限被 OOM | docker inspect看OOMKilled |
| 端口映射了但访问不通 | 应用监听 127.0.0.1 | 进容器curl 127.0.0.1:端口 |
| 容器间域名解析失败 | 用的是默认 bridge 网络 | docker network inspect看网络 |
| 挂载后权限拒绝 | 容器内 UID 与宿主机目录属主不一致 | docker exec id对比ls -ld |
| 挂载配置文件报错 | 宿主机路径不存在,被创建成了目录 | ls -l看类型是-还是d |
| 构建特别慢 | 缓存失效 / 没写.dockerignore | 看构建输出哪层开始重建 |
| 镜像改不小 | 文件在中间层被写入了 | 检查RUN里是否有“先下载后删除” |
| 磁盘写满 | 容器日志无限制 | docker system df |
| compose 启动顺序乱 | depends_on不等待就绪 | 加healthcheck+condition |
这张表我用了很久,覆盖了日常八九成的问题。真遇到表里没有的,第一件事永远是去看docker inspect的完整输出,那里面的信息密度比任何报错信息都高。
注意:
docker inspect的输出很长,用-f加 Go 模板过滤会高效得多,比如docker inspect -f '{{.NetworkSettings.Networks}}' <容器>直接看网络配置,省得在一大坨 JSON 里翻。
最后分享一个我自己养成的习惯:任何一次调试结束、问题解决之后,立刻把那条最终生效的完整命令或者 compose 片段记下来。不是为了写文档交差,是因为同类问题三个月后一定还会再来一次,而那时候你早就忘了当时是怎么绕过去的。我从“入门”走到今天没“放弃”,很大程度上靠的就是这个看起来有点笨的积累方式。