1. 先把 Docker 到底是什么讲透:多数人卡在第三天,不是笨
我见过太多人学 Docker 的轨迹几乎一模一样:第一天兴致勃勃装上环境,跑通一个 nginx 欢迎页,截图发群里觉得自己掌握了;第二天想把手上那个跑得好好的项目塞进容器,构建镜像时冒出一屏红字,改了两个小时没改对;第三天就把客户端卸载了,回头继续用虚拟机和手动部署,嘴里念叨一句"这玩意儿学习成本太高"。所以"从入门到放弃"这个说法,某种程度上是真实写照——但放弃的原因往往不是 Docker 难,而是绝大多数教程只教你怎么敲命令,不告诉你每条命令在解决什么问题。
我把 Docker 基础教程这件事重新捋一遍,目标很明确:让你在敲第一条命令之前就知道自己在干什么,构建镜像的时候知道每一层是从哪来的,出问题的时候知道往哪儿看。内容覆盖镜像、容器、数据持久化、网络、编排、排错六个环节,全部是我自己在实际项目里踩过之后沉淀下来的写法。零基础能跟着做,用过一阵但总觉得"会用不会调"的人收获会更大。命令我会给全,参数我会解释为什么这么填,坑我会提前标出来。
1.1 一个真实的痛点:为什么"我这跑得好好的"是最没用的三句话
你肯定遇到过这种对话:测试环境报错,开发说"我这跑得好好的";运维说"服务器上就是起不来";最后发现是 Node 版本差了三个小版本,或者某个系统库少装了一个包,或者时区设置不一样导致时间戳全歪了。这类问题的根本原因很朴素——代码依赖的运行环境没有被一起打包,它散落在每台机器的操作系统里,靠人的记忆和文档来维持一致,而人一定会记错。
虚拟机曾经是解法,把整个操作系统连同代码一起封装,一致性确实有了,代价是每个虚拟机要吃掉几个 G 磁盘和几百兆到几个 G 的内存,启动要几十秒,一台 16G 的机器开不了几个。Docker 换了个思路:不虚拟硬件,只虚拟操作系统的用户空间,多个容器共享宿主机的内核,把应用和它依赖的运行时、库、配置一起打包成镜像。结果是镜像通常几十到几百兆,启动时间从几十秒压到一秒以内,一台机器上跑十几个服务毫无压力。
这个差别的实际意义在于:CI 流水线里可以几秒钟拉起一套完整依赖做测试,本地开发可以随时切换不同版本的数据库,新同事入职第一天直接拉镜像就能跑,不用花半天配环境。理解了这一层,你就知道 Docker 不是"更轻的虚拟机",它解决的核心问题是交付物的一致性,顺带解决了资源密度和启动速度。
1.2 镜像、容器、仓库:用"模具"和"月饼"的关系来记
这三个词是新手第一个混乱点,我用一个不太严谨但很好记的类比。镜像(Image)是模具,是只读的、静态的、可以复制的;容器(Container)是用模具压出来的月饼,是运行中的、可读写的、可以被随时丢弃的;仓库(Registry)是放模具的仓库,你可以从里面拉别人的模具,也可以把自己的模具推上去。关键在于:容器里的任何修改,只要容器被删除就没了,因为那些修改只存在于容器最上面那层可写层,删容器等于把这一层扔掉,底下的镜像层一个字节都没动。
这个特性决定了后面所有的设计思路。为什么数据库不能把数据放在容器内部?因为容器一旦重建(升级镜像、改配置、迁移环境都会重建),数据就跟着消失。为什么要在 Dockerfile 里把依赖安装放在复制源码之前?因为镜像分层,前一层没变就能命中缓存,跳过重建。为什么很多人觉得"容器里的配置改了不生效"?因为他改了运行中的容器,而真正决定下次启动状态的是镜像和启动参数。
1.3 四个最容易放弃的时间点,提前知道能省很多力气
我把身边人放弃的节点做了个统计,基本集中在下面这几个位置。提前知道这些坎长什么样,遇上了就不慌。
| 放弃节点 | 典型现象 | 真实原因 | 应对思路 |
|---|---|---|---|
| 装环境阶段 | 命令找不到、权限被拒、服务起不来 | 没装守护进程;当前用户不在 docker 组 | 先确认服务状态,再处理用户组 |
| 第一次构建镜像 | 构建卡住、报错刷屏、镜像几个 G | Dockerfile 指令顺序不合理、没写忽略文件 | 调指令顺序、加忽略清单、换小基础镜像 |
| 数据丢失 | 重启容器后数据库空了 | 数据写在容器可写层 | 挂载数据卷或绑定目录 |
| 多容器协作 | 容器之间连不上、端口冲突 | 没建自定义网络、误以为容器内部走宿主机端口 | 建网络、用容器名通信 |
我个人的判断是,能顺利跨过第二和第三个坎的人,基本就能把 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是后台运行,不加这个参数容器会占住当前终端,Ctrl+C 就把容器停了。--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 | sort'3. 镜像与数据:这一章决定你是"会跑"还是"敢用"
跑通一个现成镜像是入门,能把自己的项目打包成合格的镜像才叫上手。这一章讲三件事:镜像为什么分层、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 --omit=dev && 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_ENABLED=0 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 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_PASSWORD=devpass \ -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_PASSWORD=devpass \ 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 \ --memory=1g --memory-swap=1g \ --cpus=1.5 \ -e JAVA_OPTS="-Xmx768m -Xms512m" \ myapp:1.4.2--memory-swap显式设成和--memory相等,等于禁用交换,好处是内存超了直接 OOM 退出,坏处是显得"不稳定"。开发机上我会设大一点让它慢一点死,生产环境倾向于禁用交换,行为更可预期。
日志是另一个隐形杀手。默认的 json-file 驱动不做轮转,一个话多的服务几天就能写出几十 G。按max-size=10m、max-file=3算,单容器日志上限是 30MB;20 个容器就是 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.1 | docker port <容器> | 修正映射、改监听 0.0.0.0 |
| 容器间连不上 | 不在同一网络、用了默认 bridge | docker 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 <容器> 2>&1 | 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 小时,查了半天以为是数据库配置问题。后来统一在镜像里装时区数据并设环境变量TZ=Asia/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 指令顺序这两件事做对,能解决八成的"用不下去"的挫败感,剩下的网络和编排,做过两三个真实项目自然就熟了。