Docker入门到可用:镜像、容器、数据持久化与排错实战 1. 先把 Docker 到底是什么讲透多数人卡在第三天不是笨我见过太多人学 Docker 的轨迹几乎一模一样第一天兴致勃勃装上环境跑通一个 nginx 欢迎页截图发群里觉得自己掌握了第二天想把手上那个跑得好好的项目塞进容器构建镜像时冒出一屏红字改了两个小时没改对第三天就把客户端卸载了回头继续用虚拟机和手动部署嘴里念叨一句这玩意儿学习成本太高。所以从入门到放弃这个说法某种程度上是真实写照——但放弃的原因往往不是 Docker 难而是绝大多数教程只教你怎么敲命令不告诉你每条命令在解决什么问题。我把 Docker 基础教程这件事重新捋一遍目标很明确让你在敲第一条命令之前就知道自己在干什么构建镜像的时候知道每一层是从哪来的出问题的时候知道往哪儿看。内容覆盖镜像、容器、数据持久化、网络、编排、排错六个环节全部是我自己在实际项目里踩过之后沉淀下来的写法。零基础能跟着做用过一阵但总觉得会用不会调的人收获会更大。命令我会给全参数我会解释为什么这么填坑我会提前标出来。1.1 一个真实的痛点为什么我这跑得好好的是最没用的三句话你肯定遇到过这种对话测试环境报错开发说我这跑得好好的运维说服务器上就是起不来最后发现是 Node 版本差了三个小版本或者某个系统库少装了一个包或者时区设置不一样导致时间戳全歪了。这类问题的根本原因很朴素——代码依赖的运行环境没有被一起打包它散落在每台机器的操作系统里靠人的记忆和文档来维持一致而人一定会记错。虚拟机曾经是解法把整个操作系统连同代码一起封装一致性确实有了代价是每个虚拟机要吃掉几个 G 磁盘和几百兆到几个 G 的内存启动要几十秒一台 16G 的机器开不了几个。Docker 换了个思路不虚拟硬件只虚拟操作系统的用户空间多个容器共享宿主机的内核把应用和它依赖的运行时、库、配置一起打包成镜像。结果是镜像通常几十到几百兆启动时间从几十秒压到一秒以内一台机器上跑十几个服务毫无压力。这个差别的实际意义在于CI 流水线里可以几秒钟拉起一套完整依赖做测试本地开发可以随时切换不同版本的数据库新同事入职第一天直接拉镜像就能跑不用花半天配环境。理解了这一层你就知道 Docker 不是更轻的虚拟机它解决的核心问题是交付物的一致性顺带解决了资源密度和启动速度。1.2 镜像、容器、仓库用模具和月饼的关系来记这三个词是新手第一个混乱点我用一个不太严谨但很好记的类比。镜像Image是模具是只读的、静态的、可以复制的容器Container是用模具压出来的月饼是运行中的、可读写的、可以被随时丢弃的仓库Registry是放模具的仓库你可以从里面拉别人的模具也可以把自己的模具推上去。关键在于容器里的任何修改只要容器被删除就没了因为那些修改只存在于容器最上面那层可写层删容器等于把这一层扔掉底下的镜像层一个字节都没动。这个特性决定了后面所有的设计思路。为什么数据库不能把数据放在容器内部因为容器一旦重建升级镜像、改配置、迁移环境都会重建数据就跟着消失。为什么要在 Dockerfile 里把依赖安装放在复制源码之前因为镜像分层前一层没变就能命中缓存跳过重建。为什么很多人觉得容器里的配置改了不生效因为他改了运行中的容器而真正决定下次启动状态的是镜像和启动参数。1.3 四个最容易放弃的时间点提前知道能省很多力气我把身边人放弃的节点做了个统计基本集中在下面这几个位置。提前知道这些坎长什么样遇上了就不慌。放弃节点典型现象真实原因应对思路装环境阶段命令找不到、权限被拒、服务起不来没装守护进程当前用户不在 docker 组先确认服务状态再处理用户组第一次构建镜像构建卡住、报错刷屏、镜像几个 GDockerfile 指令顺序不合理、没写忽略文件调指令顺序、加忽略清单、换小基础镜像数据丢失重启容器后数据库空了数据写在容器可写层挂载数据卷或绑定目录多容器协作容器之间连不上、端口冲突没建自定义网络、误以为容器内部走宿主机端口建网络、用容器名通信我个人的判断是能顺利跨过第二和第三个坎的人基本就能把 Docker 用起来了。剩下的网络和编排属于熟练度问题用得多了自然就顺。2. 环境搭建与第一个容器地基打歪了后面全是坑环境搭建这一块教程最容易糊弄过去一句安装 Docker 然后运行 hello world带过结果新手卡在第一步就放弃了。实际上这一步有几个关键决策会影响你后面几个月的手感装哪个版本、用哪种安装方式、要不要处理权限、守护进程的日志和存储怎么配。我按顺序说清楚。2.1 安装方式怎么选三选一别选错主流的安装路径有三条适用场景差别很大选错了后面会很别扭。第一条是桌面版集成环境适合本地开发和初学者装上就有图形界面自带容器编排支持和一些常用工具缺点是资源占用较高而且它本质上是在本机跑了一个轻量虚拟机来托管容器和线上环境的行为会有细微差异。第二条是包管理器安装适合服务器和 CI 环境通过系统自带的包管理器添加软件源后安装可控性好升级和回滚都方便。第三条是官方提供的安装脚本适合快速验证一行命令搞定但生产环境我不建议这么干因为你不知道它到底改了什么。不管走哪条路装完之后先用两条命令确认状态别急着跑容器。# 看客户端和服务端的版本信息 docker version # 看更详细的信息存储驱动、根目录、内核版本、资源情况 docker infodocker version会分两段输出一段是客户端一段是服务端Server。如果只有客户端没有服务端说明守护进程没起来这时候跑任何容器命令都会报连接失败。这是我见过最高频的第一个错误尤其是 Windows 上装了客户端但没启动后台服务的情况。2.2 权限与守护进程那两个必踩的小坑在 Linux 上装完用普通用户跑docker ps大概率会得到一句permission denied while trying to connect to the Docker daemon socket。原因很简单守护进程监听的是一个 Unix 套接字文件默认只有 root 和 docker 组的成员能读写。标准解法是把自己加进 docker 组# 把当前用户加入 docker 组 sudo usermod -aG docker $USER # 让组变更立即生效或者直接退出重新登录 newgrp docker # 验证 docker ps注意加入 docker 组意味着这个用户实际上获得了接近 root 的权限因为容器可以挂载宿主机目录。生产服务器上要不要给普通用户这个权限取决于团队的安全策略别图省事全员放开。第二个坑是磁盘。容器镜像、容器可写层、日志、数据卷默认都堆在一个目录下跑一阵子很容易把系统盘吃满然后出现各种诡异现象比如镜像拉不下来、容器起不来、日志写入失败。装完之后第一件事就是看docker info输出里的根目录和存储驱动如果系统盘紧张早点把根目录迁到大盘上。迁移需要改守护进程配置文件重启服务操作之前先确认没有重要容器在跑。# 查看当前磁盘占用分布先搞清楚是谁在吃空间 docker system df -v这条命令比docker system df详细得多会分别列出镜像、容器、数据卷、构建缓存各占多少以及每个镜像的大小和是否被引用。养成定期看一眼的习惯能避免很多莫名其妙的磁盘告警。2.3 跑起第一个容器逐字拆解那条最常见的命令教科书式的第一条命令大概是这个docker run -d --name web -p 8080:80 nginx看着简单其实每一段都有讲究我拆开讲。run是创建并启动的合体动作等价于先create再start。-d是后台运行不加这个参数容器会占住当前终端CtrlC 就把容器停了。--name web给容器起了个固定名字好处是后面可以用名字代替容器 ID坏处是名字不能重复重跑之前得先删掉旧的。-p 8080:80是端口映射冒号左边是宿主机端口右边是容器内端口这个顺序新手经常写反写反之后的症状是访问不通但容器状态是运行中。最后nginx是镜像名本地没有的话会自动去仓库拉。跑完之后验证三件事容器在不在跑、端口通不通、日志有没有异常。docker ps # 只看运行中的容器 docker ps -a # 包括已停止的排错时更常用 curl -I http://localhost:8080 docker logs --tail 50 web # 只看最后 50 行避免刷屏提示如果docker ps看不到容器先别急着怀疑命令写错用docker ps -a看它的状态。状态显示Exited (1)之类的退出码说明容器启动后就崩了这时候docker logs才有内容可看。容器运行中但访问不通多半是端口映射问题或者应用只监听了 127.0.0.1。2.4 容器日常操作把这几个动作练成肌肉记忆入门阶段有个坎是状态混乱容器到底是停止了还是删除了删除了镜像还在不在。我的建议是先把下面这张表里的命令用熟遇到不确定的状态就先查再说。目的命令关键说明查看运行中的容器docker ps默认只显示运行中的查看全部容器docker ps -a排错第一站停止容器docker stop web先发信号让进程优雅退出超时再强杀启动已存在的容器docker start web保留原有的配置和数据层重启docker restart web等价于 stop 加 start删除容器docker rm -f web加-f会强制停止再删数据层一起没了进容器里看看docker exec -it web sh排查问题的核心手段实时看资源占用docker stats看内存、CPU、网络 IO这里有个很实用的区别要记住docker stop和docker rm是两回事前者只是停容器还在配置和数据层都保留可以start回来后者是删可写层彻底消失。很多新手以为停止等于删除结果每次都要重新创建容器端口和挂载参数重敲一遍。docker exec -it 容器 sh这一条我要单独强调它是排查问题的主力。容器里缺什么文件、环境变量对不对、时区是不是 UTC、能不能解析另一个容器的名字全在容器里面看。用sh而不是bash是因为很多精简镜像里根本没装 bash敲bash会得到executable file not found白白浪费几分钟。# 进容器看一眼时区和环境变量这两个是最常见的线上不一样来源 docker exec -it web sh -c date; env | sort3. 镜像与数据这一章决定你是会跑还是敢用跑通一个现成镜像是入门能把自己的项目打包成合格的镜像才叫上手。这一章讲三件事镜像为什么分层、Dockerfile 怎么写才不快不胖不乱、数据到底往哪儿放。这三件事做对了你的镜像可能就是一百多兆、构建几十秒做不对就是两个 G、构建十分钟还动不动失败。3.1 镜像分层理解了这个构建速度问题解决一半镜像不是一坨整体它是由一层层只读的层叠起来的每一条会修改文件系统的 Dockerfile 指令都会产生一层。容器运行时在这些只读层上面再加一层可写层所有写操作都落在最上面这层。删除文件在可写层里表现为白障标记底下的层并不真的变小——这也是为什么在 Dockerfile 里装了东西又删掉根本不会减小镜像体积。分层的真正价值是复用和缓存。多个镜像共用同一个基础层磁盘上只存一份构建时某一层及其之前的所有层都没变就能直接复用缓存跳过执行。这解释了一个非常常见的现象你只改了一行代码重新构建却要把依赖重新装一遍耗时十分钟。原因是你把复制源码的指令写在了安装依赖的指令之前源码一变前面那一层的缓存全失效后面所有层都得重来。正确的顺序永远是变化频率低的放前面变化频率高的放后面。系统包和基础配置放最前依赖清单放中间业务源码放最后。3.2 Dockerfile 编写顺序、合并与清理的三条实操心法先看一个我早期写的反面教材功能没问题但构建慢、镜像大FROM node:18 WORKDIR /app COPY . . RUN npm install CMD [node, server.js]三个问题。第一COPY . .放在依赖安装之前改一个字符就导致缓存全失效。第二把node_modules和.git一起拷进去了体积暴涨且可能覆盖容器内的依赖。第三用的是完整版基础镜像几百兆起步。改一版FROM node:18-alpine WORKDIR /app # 先只拷依赖清单依赖没变就能命中缓存 COPY package*.json ./ RUN npm ci --omitdev npm cache clean --force # 再拷源码这一层经常变放最后 COPY . . EXPOSE 3000 CMD [node, server.js]同时加一个忽略文件把不该进镜像的东西挡在门外# .dockerignore node_modules .git *.log .env dist coverage这三个动作下来同样是改一行代码重新构建耗时能从几分钟掉到十几秒。具体数字取决于依赖规模我经手的一个中后台项目依赖装一次大概 90 秒改代码后重建从 100 秒降到 8 秒左右日常开发体验差别巨大。还有两条心法要补。一是合并 RUN 指令每个RUN是一层在层 A 装包、在层 B 删缓存体积一点不少所以要写在一行里用串起来最后清干净包管理器缓存。二是多阶段构建编译依赖留在一个阶段最终镜像只拷产物这个对编译型语言效果最明显。# 阶段一构建带着全套编译工具 FROM golang:1.21-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 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 EXPOSE 8080 USER 10001 CMD [/app/app]体积差异是数量级的。完整版 golang 镜像普遍在 800MB 到 1.2GB 之间多阶段构建之后成品通常落在 15MB 到 30MB。假设你有 10 个服务、每个镜像保留 5 个历史版本这一项就能省下几十 G 的仓库存储和拉取带宽。我给一个粗略估算优化前单镜像按 900MB 算10 个服务 5 个版本是 45GB优化后按 25MB 算一共 1.25GB压缩了 97%。注意用USER指定非 root 用户运行是个容易被忽略但很值得做的小动作。容器里默认是 root一旦应用有文件写入漏洞影响面会大很多。加一行USER 10001成本极低但要记得工作目录和需要写的路径都要提前给好权限否则会报权限错误。3.3 数据持久化卷和绑定目录到底该用哪个数据这块的判断标准只有一条这个数据在容器删除后还需不需要。需要就必须放到容器外面不需要留在容器里也没关系反正下次重建又是干净的。Docker 给了两种方式。数据卷volume由 Docker 管理存放在守护进程的目录下跨平台一致、性能好、可以用命令备份和清理缺点是位置不直观想直接改文件不太方便。绑定挂载bind mount是把宿主机上某个具体路径挂进容器路径你完全掌控改配置文件、看日志都直接在宿主机上操作缺点是依赖宿主机目录结构换个环境路径不一样就得改启动参数。我的选择习惯是这样的数据库、消息队列这类有状态服务用数据卷因为它们的数据你基本不会手动去改交给 Docker 管理更省心本地开发时挂载源码、配置文件、Nginx 配置用绑定挂载改完立刻生效不用重新构建镜像。# 数据卷先创建再挂载命名清晰便于备份 docker volume create pgdata docker run -d --name pg \ -e POSTGRES_PASSWORDdevpass \ -v pgdata:/var/lib/postgresql/data \ postgres:16-alpine # 绑定挂载本地开发挂源码和配置读写权限按需给 docker run -d --name devweb \ -p 8080:80 \ -v $(pwd)/html:/usr/share/nginx/html:ro \ -v $(pwd)/nginx.conf:/etc/nginx/conf.d/default.conf:ro \ nginx:alpine:ro是只读挂载这个细节很值得养成习惯。配置文件、静态资源这类只读的东西挂成只读能挡住一类容器把宿主机配置改坏的事故。我见过一次把宿主机上的配置文件挂进去又在容器里跑了个初始化脚本结果宿主机上的原始配置被覆盖回滚花了半小时。提示绑定挂载有个经典陷阱——宿主机路径不存在时Docker 会自动创建一个同名目录而不是报错。于是你本意是挂一个配置文件实际挂成了一个空目录应用启动后读不到配置直接崩日志里还看不出原因。启动前先用ls确认路径存在能省很多时间。3.4 构建与分发几个让流程顺滑的实操细节构建的时候用标签区分版本别全靠latest。latest是个约定不是最新的保证尤其在多台机器多个版本并存的时候标签混乱会直接导致我明明推了新的怎么还是旧行为。我的做法是每次构建打两个标签一个语义化的版本号一个环境标记。docker build -t myapp:1.4.2 -t myapp:prod . docker images | grep myapp构建缓存出问题的时候比如依赖明明更新了却还是装旧的先怀疑缓存。可以针对性清理构建缓存不用把镜像全删了。清理之前用docker system df看一眼确认清的是该清的。# 只清构建缓存 docker builder prune # 清理所有未被使用的镜像、容器、网络、缓存这一步有破坏性先确认 docker system prune -a --volumesdocker system prune -a --volumes这条命令我要重点标红它会删掉所有没在运行中的容器、所有没被引用的镜像、以及没有被任何容器使用的数据卷。如果你有一个停着的数据库容器卷还挂着那不会删但如果容器已经被删了卷还留着这里就一起没了。执行前把docker volume ls和docker ps -a都过一遍别在还有数据的机器上随手敲。4. 网络与多容器协作从单个玩具到能跑起来的项目单个容器跑通之后下一个现实问题是应用要连数据库前端要连后端缓存要连业务。这时候网络配置就成了新的门槛。新手最常见的误解是容器 A 要访问容器 B那就把 B 的端口映射到宿主机A 通过宿主机 IP 加端口访问。这个做法能通但很难看也很难维护换个环境 IP 就变了。4.1 端口映射与容器间通信分清对外和对内先明确两个概念。端口映射-p解决的是宿主机外部怎么访问容器是给外部用户用的。容器之间的通信走的是 Docker 内部的虚拟网络不需要经过宿主机端口。在同一个自定义网络里容器可以直接用容器名当主机名互相访问Docker 内置了名字解析。# 建一个自定义网络比默认的 bridge 网络好因为默认网络不支持名字解析 docker network create appnet docker run -d --name db --network appnet \ -e MYSQL_ROOT_PASSWORDdevpass \ mysql:8 # 应用容器加入同一个网络连接串里直接写容器名 db docker run -d --name api --network appnet -p 3000:3000 myapp:1.4.2应用里的数据库连接地址就写db:3306不需要知道宿主机 IP也不需要把数据库端口映射出去。这样做的额外好处是安全数据库根本不对外暴露端口只有同一网络里的容器能连攻击面小了非常多。我经手的环境里凡是对外映射了数据库端口的基本都成了被扫的对象日志里全是暴力尝试。顺带说一个排查技巧。容器间连不上先在应用容器里试两件事解析名字、测端口。# 在 api 容器里执行看能不能解析到 db docker exec -it api sh -c getent hosts db || ping -c 1 db # 测端口通不通没装 nc 就用 sh 的 /dev/tcp docker exec -it api sh -c nc -zv db 3306 || (echo /dev/tcp/db/3306 echo open)名字解析失败说明两个容器不在同一个网络或者用了默认 bridge 网络默认网络不支持名字解析这是个高频坑。名字能解析但端口不通说明服务没起来或者监听地址不对——很多服务默认只监听 127.0.0.1容器外面自然连不上得改成监听 0.0.0.0。4.2 编排实战把五条 docker run 收进一个文件当启动参数超过三个容器就该上编排工具了。理由很实在一堆长命令记不住、顺序依赖靠人肉保证、换台机器要重新敲一遍。编排文件把这些固化成配置一条命令拉起整套环境。# compose.yaml services: api: build: context: . dockerfile: Dockerfile image: myapp:1.4.2 ports: - 3000:3000 environment: DB_HOST: db DB_PORT: 3306 CACHE_HOST: cache depends_on: db: condition: service_healthy cache: condition: service_started restart: unless-stopped logging: driver: json-file options: max-size: 10m max-file: 3 db: image: mysql:8 environment: MYSQL_ROOT_PASSWORD: devpass MYSQL_DATABASE: appdb volumes: - dbdata:/var/lib/mysql healthcheck: test: [CMD, mysqladmin, ping, -h, 127.0.0.1, -pdevpass] interval: 10s timeout: 5s retries: 5 start_period: 30s cache: image: redis:7-alpine command: [redis-server, --maxmemory, 256mb, --maxmemory-policy, allkeys-lru] volumes: dbdata:几个关键点值得说清楚。depends_on加condition: service_healthy才是真正等数据库可用再启动应用不加条件只是保证启动顺序不代表服务已经能接受连接——这是新手最常踩的一个坑应用启动时数据库还没初始化完直接连接失败退出然后你以为是网络问题。healthcheck的start_period很重要给数据库留足初始化时间我一般给 30 秒慢机器上给 60 秒。restart: unless-stopped让服务异常退出后自动拉起但手动停掉的不再自动起这个策略在开发环境最合适。常用操作也就三条记熟足够docker compose up -d --build # 构建并后台启动 docker compose ps # 看各服务状态和健康情况 docker compose logs -f api # 追某个服务的日志 docker compose down # 停止并删除容器和网络保留数据卷 docker compose down -v # 连数据卷一起删慎用注意down -v里的-v会删掉编排文件里声明的数据卷。数据库数据跟着一起没。这个参数我在生产环境从来不敲本地调试时也要先想一秒。4.3 资源限制与日志治理不设限的容器会咬人容器默认对资源几乎没有限制一个内存泄漏的服务能把宿主机内存吃光触发系统层面的连锁反应最后所有服务一起挂。所以给它设上限不是优化是保底。内存限制这里有个容易翻车的地方。以 JVM 应用为例容器内存限制是 1G不代表堆能设 1G。堆之外还有元空间、线程栈、直接内存、JVM 自身开销通常要预留 20% 到 30%。所以-Xmx我一般按限制值的 70% 到 75% 给。# 容器总内存 1G堆上限 768m留出非堆空间 docker run -d --name api \ --memory1g --memory-swap1g \ --cpus1.5 \ -e JAVA_OPTS-Xmx768m -Xms512m \ myapp:1.4.2--memory-swap显式设成和--memory相等等于禁用交换好处是内存超了直接 OOM 退出坏处是显得不稳定。开发机上我会设大一点让它慢一点死生产环境倾向于禁用交换行为更可预期。日志是另一个隐形杀手。默认的 json-file 驱动不做轮转一个话多的服务几天就能写出几十 G。按max-size10m、max-file3算单容器日志上限是 30MB20 个容器就是 600MB 封顶。不设的话同样的服务量级可能轻松突破 100GB然后磁盘满了整个宿主机上的服务一起出问题。这个参数在编排文件里写一次全场生效性价比极高。# 虚拟机级别的全局默认值守护进程配置省得每个容器单独设 # /etc/docker/daemon.json { log-driver: json-file, log-opts: { max-size: 10m, max-file: 3 } }改完这个文件要重启守护进程重启会导致所有运行中的容器停止选个合适的时间点做。5. 常见问题与排查技巧实录那些文档里不会写的东西前面四章讲的是怎么用这一章讲出问题怎么办。我把自己这几年被问最多、自己踩最多的问题整理成一张速查表再补几条真正能省时间的排查思路。这一部分我认为价值最高因为命令谁都能查到判断顺序和先看哪里是经验堆出来的。5.1 高频故障速查表先按现象定位再动手现象最可能的原因快速验证处理方式命令报连不上守护进程服务没启动、用户不在 docker 组systemctl status docker、id起服务、加用户组并重新登录容器起来就退出启动命令错误、配置缺失、依赖服务未就绪docker ps -a看退出码docker logs看日志第一屏改成前台运行调试端口访问不通映射写反、监听地址是 127.0.0.1docker port 容器修正映射、改监听 0.0.0.0容器间连不上不在同一网络、用了默认 bridgedocker inspect看网络配置建自定义网络用容器名访问数据重启后消失数据写在可写层、卷没挂对docker inspect看 Mounts 段补挂数据卷或绑定目录构建特别慢指令顺序不合理、缓存全失效看构建输出哪一层在重跑调整 COPY 与 RUN 顺序镜像特别大完整基础镜像、缓存未清理、无用文件进镜像docker history 镜像看每层大小换精简镜像、合并 RUN、加忽略清单磁盘越用越少日志无轮转、悬空镜像堆积docker system df -v配日志上限、定期清理改了代码没生效挂载路径错、构建缓存、镜像标签混用进容器看文件内容核对挂载、加--no-cache重建这张表里我特别想强调两条。第一条是容器起来就退出最有效的排查手段是把-d去掉让它前台跑直接看完整输出。很多情况下日志在docker logs里只看到最后一行前面真正的报错被吞了前台跑一目了然。第二条是镜像特别大docker history能看到每一层的大小和创建它的命令哪个RUN装了 500MB 的东西一清二楚比盲目猜效率高太多。# 看镜像每一层的大小定位肥在哪一层 docker history --no-trunc myapp:1.4.2 | head -20 # 看容器的挂载详情确认卷到底挂上没有 docker inspect -f {{json .Mounts}} api | python3 -m json.tool # 看容器为什么退出重点看退出码和错误信息 docker inspect -f {{.State.ExitCode}} {{.State.Error}} api5.2 排查思路从外到内从现象到根因我把排查顺序固定成四步绝大多数问题在第二步就能定位。第一步看容器的状态。运行中、已退出、重启中、不健康这四种状态对应完全不同的排查方向。运行中但服务不可用说明问题在应用层或者网络层已退出问题在启动过程直接去看日志一直重启说明它在崩溃循环里restart策略越猛反而越难看清临时把策略改成no再排查。第二步看日志而且要看第一屏。很多人习惯docker logs --tail 100但崩在启动阶段的容器真正的报错往往在最早那几行后面全是连带报错。我一般先docker logs 容器 21 | head -50看清楚了再往下。第三步进容器内部看现场。文件在不在、环境变量对不对、能不能连通依赖、磁盘有没有满。这些在容器外面永远看不准。第四步看配置来源。容器实际运行时用的配置未必是你以为的那份。docker inspect里的Config、Env、Mounts三个部分才是真相用它来核对挂载路径、环境变量、启动命令是否正确。# 一次性把关键配置打出来比翻长 JSON 快 docker inspect -f CMD{{.Config.Cmd}} ENV{{.Config.Env}} MOUNTS{{range .Mounts}}{{.Source}}-{{.Destination}} {{end}} api提示排查时改配置的正确姿势是改启动参数然后重建容器而不是进容器里改文件。容器里改的东西重启就没了而且下次重建还是回到错误状态。进容器改文件只用于临时验证假设验证完记得同步到 Dockerfile 或编排文件里。5.3 我踩过的几个坑和对应的处理习惯第一个坑时区。容器默认跑 UTC业务代码里做时间格式化写进数据库的时间和本地看到的差 8 小时查了半天以为是数据库配置问题。后来统一在镜像里装时区数据并设环境变量TZAsia/Shanghai这个问题再没出现过。多阶段构建那版 Dockerfile 里我特意写了apk add --no-cache tzdata就是这个原因——精简镜像为了减体积把时区数据删了不装的话设了环境变量也不生效。第二个坑latest标签。某次更新镜像后几台机器上行为不一致查出来是有台机器上还残留着旧版本的latest镜像docker run时用了本地的旧镜像而没去拉新的。从那以后我所有构建都打语义化版本标签latest只作为附带的便利标签部署时明确指定版本号。第三个坑磁盘无声无息地满。表现很有迷惑性镜像拉不下来、容器起不来、日志写入失败但没有任何一条报错说磁盘满。养成每周看一眼docker system df的习惯比事后救火省心得多。第四个坑docker compose down之后的网络残留。反复 up 和 down 之后偶尔会出现网段冲突报错说地址池耗尽。用docker network ls看一下把没用的网络清掉或者干脆给编排文件指定一个固定的子网。最后一个习惯分享本地开发环境我习惯给每个项目建一个固定的编排项目名用-p参数指定这样多个项目同时跑的时候网络、卷、容器都是分开的互不干扰也不会误删另一个项目的东西。# 用项目名隔离清理时也只影响本项目 docker compose -p projecta up -d docker compose -p projecta down实际用下来从能跑通一个 nginx到敢在自己的项目里用中间隔的其实不是命令量的积累而是对分层的理解、对数据生命周期的判断、对排查顺序的熟练。前面这几章里我个人的经验是先把数据卷和 Dockerfile 指令顺序这两件事做对能解决八成的用不下去的挫败感剩下的网络和编排做过两三个真实项目自然就熟了。