
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_PASSWORDxxx 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_URLpostgres://app:passdb:5432/app \ -e LOG_LEVELinfo \ -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 一条指令一层书写顺序直接决定复用率先看一段很常见的、写得不那么好的 DockerfileFROM 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本质上就是createstart的语法糖加上-it的时候再附加一个终端。关键在于容器的生命周期等同于它内部 1 号进程PID 1的生命周期。PID 1 一退出容器就结束不管里面还有没有别的进程在后台跑。这就是“容器里必须有个前台进程”这句话的真正含义——不是必须“前台”是必须有一个占据 PID 1 且不会自己退出的进程。对比一下就清楚了。docker run -d nginx能一直活着因为 nginx 镜像的启动命令是nginx -g daemon off;让 nginx 待在前台不脱守。而如果你写一个CMD service nginx startservice脚本启动完 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覆盖掉直接起个 shelldocker 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 上你在终端里按 CtrlC信号会直接打到主进程上容器可能就此退出。日常看日志用logs -f别用attach这个坑我踩过一次就再也没碰过。4. 数据落在哪volume、bind mount 与权限坑数据持久化这块教程里通常一句话带过“用 -v 挂载就行”实际上两种挂载方式的适用场景差别很大权限问题的排查也有固定套路。这一章按“选型—踩坑—运维”的顺序讲。4.1 volume 和 bind mount 的本质差别Docker 提供了两种主要的挂载方式还有一个tmpfs用于纯内存场景存临时敏感数据、缓存之类。核心对比如下维度named volumebind 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 danglingtrue可以列出没有被任何容器引用的卷清理之前务必先用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_HOSTdb 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文件并加进.gitignorecompose 里用${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_ENABLED0 GOOSlinux 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 --frombuilder /out/app /app/app USER 1000:1000 EXPOSE 8000 ENTRYPOINT [/app/app]这个写法能直接把镜像从 900MB 压到 20MB 以内。几个细节CGO_ENABLED0是为了生成静态链接的二进制不依赖 glibc才能跑在 alpine 这种精简系统上-ldflags-s -w去掉符号表和调试信息能再省几 MBca-certificates和tzdata一定要装不然 HTTPS 请求失败、时间显示成 UTC这两个问题排查起来都挺费劲的。如果追求更极致的体积可以用 distroless 或者 scratch 作为基础镜像。代价是没有 shelldocker 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 TZAsia/Shanghai并装上tzdata。前者更省事后者更可移植我一般用后者因为不依赖宿主机。编码问题主要体现在中文输出乱码。基础镜像如果不带 locale程序输出中文可能变成问号或者方块。在 Dockerfile 里加一句ENV LANGC.UTF-8基本能解决大部分情况。这个问题的隐蔽之处在于本地开发环境通常是有 locale 的只有容器里没有所以经常是“本地好好的上线就乱码”。8.3 一张速查表贴在显示器旁边那种现象优先怀疑快速验证容器启动即退出主进程不是前台进程 / 启动命令写错docker logs再看退出码退出码 137内存超限被 OOMdocker 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 dfcompose 启动顺序乱depends_on不等待就绪加healthcheckcondition这张表我用了很久覆盖了日常八九成的问题。真遇到表里没有的第一件事永远是去看docker inspect的完整输出那里面的信息密度比任何报错信息都高。注意docker inspect的输出很长用-f加 Go 模板过滤会高效得多比如docker inspect -f {{.NetworkSettings.Networks}} 容器直接看网络配置省得在一大坨 JSON 里翻。最后分享一个我自己养成的习惯任何一次调试结束、问题解决之后立刻把那条最终生效的完整命令或者 compose 片段记下来。不是为了写文档交差是因为同类问题三个月后一定还会再来一次而那时候你早就忘了当时是怎么绕过去的。我从“入门”走到今天没“放弃”很大程度上靠的就是这个看起来有点笨的积累方式。