DevOps
Docker 生产排障决策树:服务器新手从现象到修复的实战路线
排障时最怕的不是问题大,而是先把系统改坏了。Docker 生产里的第一条纪律很简单:先保留证据,再做最小修复,验证通过后才允许继续扩展。容器可以重建,镜像可以替换,数据和回退路径不能靠运气。
这份内容按“现象 → 证据 → 最小修复 → 验证 → 回滚”组织。它不是命令清单堆砌,也不是故障故事合集,而是一条适合单机服务器、Compose 项目和数据库服务的实战路线。遇到问题时,不要先猜原因,要先确认它落在哪一层:镜像、容器、卷、网络、进程,还是外部依赖。
官方资料里最值得记住的不是某条命令,而是 Docker 对对象边界的定义。安装、运行、Compose、镜像构建和卷管理各管各的,Docker Engine 官方安装文档、Compose 文档和镜像构建说明只是在不同层面描述同一件事:把职责分开,问题才容易定位。
先固定排障顺序
| 步骤 | 该看什么 | 不要做什么 |
|---|---|---|
| 现象 | 退出、超时、端口不通、连接失败、数据异常、更新后回滚不了 | 不要一上来就重建整套环境 |
| 证据 | docker logs、docker inspect、compose config、健康检查、卷、网络、端口 | 不要只看一行报错就改配置 |
| 最小修复 | 只改最可能的那一处:命令、地址、卷、权限、版本、检查器或启动顺序 | 不要顺手重写全部 Compose |
| 验证 | 再次运行、再次访问、再次连库、再次看日志 | 不要凭“看起来好了”就结束 |
| 回滚 | 恢复到上一个已知可用版本,必要时连数据回退也要有脚本 | 不要把临时修复堆成新问题 |
在 Docker 里排障,很多时候不是在修 Docker 本身,而是在修部署契约:镜像是否可复现、容器是否只承担单一职责、数据是否放对地方、网络是否连对名字、更新是否有回退点。把这些底层规则固定下来,很多现象就不会变成猜谜游戏。
先别把容器当虚拟机
容器只解决进程隔离和交付一致性,不负责给你完整操作系统那种“什么都像本机”的幻觉。排障时若默认容器是虚拟机,通常会在网络、权限、文件系统和信号处理上犯错。
| 对象 | 真正含义 | 排障时怎么用 |
|---|---|---|
| 镜像 | 只读模板 | 检查标签、digest、构建来源是否可靠 |
| 容器 | 镜像运行实例 | 看主进程、日志、退出码、健康状态 |
| Volume | 独立于容器的持久数据 | 数据库不见了先查卷和挂载,不要先怀疑镜像 |
| 网络 | 容器之间的连接边界 | 连不上服务时先看服务名和网络是否一致 |
| 进程 | 容器里真正执行的程序 | 不要只看容器状态,要看主进程有没有正确接信号 |
容器里的 localhost 只代表容器自己。应用和数据库都在 Docker 里时,最常见的错误之一就是把连接串写成 localhost,然后把故障误判成数据库没起来。实际上是服务发现写错了。
安装后先验收,不要直接上生产
Linux 安装按 Docker Engine 官方安装文档 走。安装完成后,先确认 daemon、Compose 插件和 hello-world 都没问题,再考虑接入真实服务。共享服务器上,把用户加入 docker 组意味着非常高的宿主机权限,这种授权要按敏感权限看待。
docker version
docker compose version
sudo docker run --rm hello-world
如果其中任何一步失败,先回到安装层面,不要继续在业务配置上打补丁。很多看似复杂的生产故障,最初只是 daemon 没启动、套接字权限不对,或者 CLI 和引擎版本不匹配。
现象一:容器一启动就退出
最先看 docker ps -a 和 docker logs。退出码 0 往往意味着主进程自己结束了,退出码 1 多半是启动参数、环境变量、文件路径或权限问题,退出码 137 常见于被杀掉、内存压力或外部停止。不要直接重启三次赌运气,先把退出码和日志里的第一条真实错误找出来。
证据:先看退出码和日志
docker ps -a
docker logs --tail 200 app
docker inspect app --format '{{.State.ExitCode}} {{.State.OOMKilled}} {{.State.Error}}'
如果日志里出现找不到配置文件、端口被占、数据库连不上、类路径缺失或权限拒绝,就把它当成主因。不要让后面的连锁报错遮住真正的第一现场。很多日志看起来像“系统报错”,实际上只是第一次异常的回声。
最小修复:只改连接串或健康检查
只改一处:命令行、环境变量、挂载路径、工作目录,或者把启动脚本里对外部依赖的等待逻辑改成明确检查。对于 Java 服务,最常见的修复不是重写镜像,而是把容器内路径和镜像构建产物对齐。对于脚本型服务,则要确认 shebang、执行权限和换行符没出错。
验证:再次访问与再看日志
重新运行后,观察 3 件事:主进程是否持续存在、日志是否停止报错、健康检查是否稳定通过。如果只是容器“没退出”但业务接口还没准备好,那不算真正修好。必要时再看一次 docker inspect,确认退出原因真的消失了,而不是被重新启动掩盖。
回滚
回到上一版镜像标签或上一版 Compose 配置。不要在失败镜像上继续叠修复。排障时最怕“临时改动”没有记录,结果下次再遇到同一个问题时,已经分不清谁改过什么。
现象二:外部端口访问不了
先分清是容器没监听、端口没映射,还是只监听了 127.0.0.1。Java、Nginx、Node 这类服务如果只绑定到本地回环地址,容器外部自然访问不到。Docker 的端口映射只是把主机端口转进去,不会替你修正应用自己的监听地址。
docker port app
ss -lntp | grep 8080
docker inspect app --format '{{json .NetworkSettings.Ports}}'
最小修复通常是把应用监听地址改成 0.0.0.0,或修正 ports 的左右两端。如果反向代理在前面,还要确认代理和后端在同一个网络里,服务名写对了,证书和转发头也没被误改。若 TLS 在代理层终止,别把证书问题误诊为后端程序问题。
证据补充
还可以从宿主机看监听状态、从容器里看回环地址、从代理日志看上游返回码。端口不通的故障很多,但第一步永远是先确定“端口没出去”还是“出去后被挡回来了”。
现象三:数据库连不上
这类问题里,localhost、错误端口、错误服务名和错误网络最常见。数据库容器内部端口和主机暴露端口不是一回事,应用容器该连的是服务名和容器端口,不是主机暴露端口。要先确认到底谁在连谁。
证据:核对解析与端口
docker compose ps
docker compose logs db
docker exec -it app sh -lc 'getent hosts db; nc -vz db 5432'
如果服务名能解析,但连不上端口,多半是数据库没准备好、健康检查不够严格、初始化脚本卡住,或者 PostgreSQL 监听到了错误的地址。连不上就不要先怀疑 Java 代码,先确认数据库容器本身健康。
最小修复:只改停止参数和信号处理
把连接串改成 Compose 服务名,补上健康检查,再让应用在数据库真的 ready 之后启动连接。对 Java 项目来说,最稳妥的做法不是盲等 5 秒,而是让应用连接层自己识别失败并重试,或者让入口脚本检查端口可达。对于 PostgreSQL,必要时再补 pg_hba.conf 和监听地址的检查。
常见误区
数据库“起了”不代表“可写了”。初始化、迁移、恢复和权限检查都可能把服务拖在半可用状态。只看容器 running,太容易误判。
depends_on 不是健康保证
depends_on 只负责启动顺序,不等于依赖服务已经可用。很多人把它当成“数据库先起来,应用就一定能连上”,结果启动链条看似整齐,真正发布时还是会撞到初始化延迟。要把顺序和就绪拆开看。
services:
app:
depends_on:
db:
condition: service_healthy
db:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
如果 Compose 版本或实现不支持你写的条件,还是要回到健康检查本身。最小修复的目标是让应用只有在数据库真正可用时再开始工作,而不是靠时间猜测。服务编排只能表达关系,不能替你推断状态。
验证:检查健康状态和业务可用性
先看数据库健康状态,再看应用是否进入稳定运行。应用日志里如果还在连库重试,说明依赖链没有收敛,不能算通过。最好同时检查 docker compose ps 和真实业务接口,别把健康探针当终点。
现象四:构建很慢,镜像还很大
这通常不是 Docker 天生慢,而是构建上下文太脏。源码、日志、IDE 文件、测试产物、临时配置都被送进上下文时,构建缓存会失真,敏感文件也可能被带进去。此时要先改 .dockerignore,不是先调大机器。
.dockerignore 是构建契约的一部分
.git
.idea
.vscode
target
build
*.log
.env
README.md
compose.override.yaml
node_modules
coverage
.DS_Store
构建上下文越干净,Docker build 越稳定。对于 Java 项目,target 里本来就属于构建产物,不该跟源码一起被传入;对于前后端同仓项目,前端依赖目录和临时缓存也要排除。这样做不是为了“更优雅”,而是为了避免不必要的缓存污染和泄露风险。
多阶段构建要做实,不要只写个名字
多阶段构建的价值不在于形式,而在于把编译环境和运行环境拆开。构建阶段可以有 Maven、Gradle、测试工具和源码,运行阶段只留 JRE、jar 和必要的运行用户。这样一来,镜像更小,攻击面更窄,回退也更清楚。
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B clean package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 appuser
COPY --from=build /workspace/target/*.jar /app/app.jar
USER appuser
ENTRYPOINT ["java","-jar","/app/app.jar"]
如果构建阶段和运行阶段混在一起,镜像会越来越脏。排障时,混乱镜像会把依赖问题、缓存问题和运行问题揉成一团,恢复速度很慢。
很多人把“镜像小一点”当作唯一收益,其实更重要的是构建过程可解释。能解释,才知道该从哪一层回退;不能解释,出了问题就只能猜。
现象五:更新后服务活着,但用户还是报错
这往往说明入口进程已经起来,真实依赖还没准备好,或者健康检查太宽松。更新发布时不要只盯着容器是否 running,要盯着接口是否真的 ready、数据库是否可写、缓存是否可用、反向代理是否已经把流量切过去。
优雅停止要接得住信号
生产里“停掉”不等于“硬杀”。Java 服务应该尽量接收 SIGTERM,先拒绝新请求,再完成手头请求,最后退出。数据库尤其不能靠粗暴 kill -9,否则事务和 WAL 可能留下未收敛的状态。
services:
app:
stop_grace_period: 30s
init: true
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/actuator/health || exit 1"]
interval: 10s
timeout: 3s
retries: 5
最小修复通常是加长 stop_grace_period、补上 init、让健康检查更贴近真实可用状态,或修正应用的关闭钩子。不要把“容器已退出”当成“服务已安全停止”。
验证:确认回滚后的稳定窗口
手动停止容器后,检查日志是否出现正常关闭流程,数据是否落盘,前端代理是否把新流量导走。再重启服务,确认它能在相同状态下重新接住请求。若有事务处理,最好再核验一次数据库一致性。
优雅停止真正重要的地方在于,它决定你能不能平静地下线、扩容、升级和回滚。把停止当作一等公民,发布节奏会稳很多。
现象六:磁盘突然涨满
常见来源有三类:日志无限增长、旧镜像堆积、卷里出现了意料之外的大文件。别先删卷,先看是哪一类占满了空间。容器层本来就不该承载长期写入,大文件落在可写层里,后面一定会出问题。
docker system df
du -sh /var/lib/docker/* 2>/dev/null | sort -h
docker logs --since 24h app | tail -n 50
最小修复一般是把日志交给宿主机轮转,清理废弃镜像,或者把误写到容器层的数据迁回卷。验证时要看空间是否恢复、应用是否还能继续写入、日志是否还在无节制地增长。若是数据库卷增长过快,还要回头查慢查询、批处理和保留策略。
证据细化
空间耗尽时,先看 docker system df -v 再看宿主机目录大小。镜像、容器、卷各自吃掉多少空间,通常一眼就能看出来。与其删一个看不懂的卷,不如先搞清楚数据到底写到了哪里。
备份、恢复和回滚必须分开设计
数据库备份不是镜像备份。镜像可以重拉,卷里的数据一旦丢失就不是重建能解决的。生产里至少要准备三样东西:卷级备份、应用配置备份、版本回退脚本。三者不是一个概念,不能互相替代。
如果你需要一台长期跑容器的单机主机,可以先看一眼 RainYun。部分链接可能包含返利,不影响推荐判断。重点不是平台名字,而是主机、磁盘、备份和权限边界能不能配合你的部署契约。
恢复验证
恢复不是把备份文件拷回去就完事。要在隔离环境里演练:恢复卷、启动数据库、确认表结构、检查关键业务数据,再把应用指向恢复后的实例。只有跑通一次完整闭环,备份才算真的可用。
如果恢复后应用仍然报错,先区分是数据没恢复完整,还是应用版本比备份版本更晚。很多恢复失败其实不是备份坏了,而是依赖版本不一致。
最小回滚流程
回滚时先退应用,再退配置,最后才考虑数据回退。把顺序写清楚,能避免“只退了一半”的混乱现场。数据回退如果不是明确批准的动作,别临场决定。
网络、卷和日志的交叉故障
真实现场里,单一故障往往会串成三层:网络抖动导致应用重试,重试导致日志暴涨,日志暴涨又把磁盘拖满。这个时候只看一处就很容易误判。排查时要把网络、卷和日志一起看。
比如应用访问数据库时频繁超时,首先可能是 DNS 没解析,接着是连接池不断重试,最后是日志写满磁盘,容器因为磁盘压力退出。这样的链条不长,但每一环都要证据。
什么时候该回滚,而不是硬顶
如果故障出现在升级之后,且修复需要跨多个层面才可能奏效,就先回滚到上一版稳定镜像和上一版 Compose 文件。回滚不是失败,而是把系统先拉回已知边界,再重新定位问题。硬顶着继续改,最容易把可回退窗口关掉。
回滚前先确认三件事:旧镜像还在不在、旧配置有没有被覆盖、数据库迁移是不是可逆。若迁移不可逆,就不能把“回滚服务”误当成“回滚数据”。
什么时候不该把问题推到 Kubernetes
单机服务刚起步、问题还集中在镜像构建、健康检查、卷和启动顺序这些基本层面时,先把 Docker Compose 契约做稳。Kubernetes 不是“更高级的修复工具”,它解决的是编排和弹性,不会自动修好错误的 Dockerfile、错误的连接串、错误的卷挂载或错误的关闭逻辑。
当你还在追问为什么容器启动就退出、为什么数据库连不上、为什么更新后无法优雅停止时,通常说明问题还没到需要集群编排的阶段。先把单机上的镜像、网络、健康检查、回滚链条做正确,再谈迁移。否则只是在更复杂的系统里复制同样的错误。
Compose 检查清单
- 镜像是否固定到可追踪的版本,而不是只写 latest。
- 业务容器是否只承担一个主进程,关闭时能接收信号。
- 数据库是否使用卷,卷是否有备份与恢复脚本。
- 服务间连接是否只依赖服务名,不依赖动态 IP。
- 健康检查是否覆盖真正的业务可用性,而不仅仅是进程存活。
- 挂载与 secrets 是否清楚分层,敏感信息是否远离镜像。
故障复盘时最该保存的证据
镜像标签、Compose 文件、健康检查定义、关键日志片段、数据库备份时间点和回滚版本号,这六样东西通常就足够复盘。不要把复盘写成流水账,要把“哪一层出错、哪一层被验证、哪一层恢复正常”写清楚。
快速命令速查
docker ps -a
docker logs --tail 100 <container>
docker inspect <container>
docker compose ps
docker compose logs -f
docker compose config
docker compose up -d --build
docker compose down
docker system df
docker system df -v
docker volume ls
docker volume inspect <volume>
这些命令不负责诊断结论,只负责把证据摆出来。真正的诊断还是要回到“现象 → 证据 → 最小修复 → 验证 → 回滚”这条线。把顺序守住,Docker 排障就不会变成拍脑袋。
把问题还原成边界问题
大多数 Docker 故障,最后都不是“容器技术本身坏了”,而是边界没写清:谁负责启动,谁负责可用,谁负责数据,谁负责停止,谁负责回滚。把这些边界写成可执行的 Compose 配置和运维步骤,比背一堆命令更有用。
把部署现场拆成四层,再决定修哪一层
排障时最有效的做法,不是围着报错循环,而是把现场拆成四层:宿主机、Docker 引擎、容器进程、业务依赖。四层同时存在,但一次只修一层。宿主机负责磁盘、内核和网络,Docker 引擎负责镜像、网络与卷的编排,容器进程负责真正的应用行为,业务依赖负责数据库、缓存、消息队列和外部 API。把层次写清楚,证据就不会乱。
例如容器起不来,不一定是镜像坏了;有时只是宿主机磁盘满了、内核 OOM 了、或者挂载点权限变了。反过来,业务接口偶发 500 也不一定要回退镜像,可能只是数据库瞬时抖动,或者健康检查写得太宽松。把这几层分开看,修复才不会偏题。
宿主机层要看的不是 Docker 命令
先看磁盘、内存、端口、时间同步和系统日志。Docker 只是使用这些资源,不能替你修它们。宿主机一旦出问题,容器里看到的往往只是连锁反应。
df -h
free -m
ss -lntp
journalctl -u docker --since '1 hour ago'
这几条命令的意义不是“收集一切”,而是确认故障是否已经越过容器边界。若宿主机资源异常,继续改 Compose 只会徒劳。
引擎层要确认状态是否一致
有时 Docker daemon 看上去活着,但网络驱动、卷挂载或镜像缓存已经异常。此时最有价值的是对比 docker info、docker system df 和实际容器状态。只要出现明显不一致,就先停手,不要继续大批量重启。
docker info
docker system df
docker network ls
docker volume ls
如果网络、卷、镜像的数量和预期差得离谱,先查最近一次部署留下了什么。很多问题都是上一轮发布的残留,不是这次修改直接造成的。
依赖服务的健康检查要贴着真实业务
健康检查不是“机器活着就算好”。对于数据库,检查连接成功只是最低门槛,还要看是否能正常读写;对于应用,最好检查一个轻量但真实的业务路径,比如只读查询、内部状态页或公开健康接口。这样才能避免“探针通过、用户报错”的尴尬。
如果你的健康检查只是 echo ok,那几乎等于没有。探针必须接近真实依赖链,才能提早暴露问题。
一个更贴近业务的思路
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/actuator/health/readiness || exit 1"]
interval: 10s
timeout: 3s
retries: 5
如果应用没有 readiness 概念,可以拆成两个接口:一个检查进程状态,一个检查依赖状态。这样在上线和故障期看到的信号更准确。
优雅停止不是礼貌,是数据安全
Java 服务被 SIGTERM 触发后,应该先停止接收新请求,再等待在途请求结束,最后才退出。若容器没有正确处理信号,docker stop 退化成硬杀,连接池、事务、文件写入和异步任务都可能留下半截状态。对于数据库、索引任务和批处理程序,这种损伤往往不是一次重启能弥补的。
优雅停止有两个关键参数:应用自己要能接住信号,Compose 侧要给足宽限期。一个没做好,另一个再大也没用。
services:
app:
stop_signal: SIGTERM
stop_grace_period: 45s
init: true
如果应用在关闭阶段还会去访问外部服务,最好把关闭逻辑写得更保守:先下线入口,再刷新状态,最后关闭资源池。不要在退出时还做不必要的网络调用。
单机 Compose 和 Kubernetes 的真实边界
很多团队把 Kubernetes 想成“更高级的发布按钮”,但它真正解决的是多节点调度、弹性伸缩、滚动升级、权限分层和复杂网络,而不是修正错误的部署习惯。若当前问题集中在 Dockerfile、镜像体积、数据库连通、卷挂载、停止信号和备份恢复,先把 Compose 做稳定更合适。
一个简单判断:如果你还在问“为什么容器启动就退出”“为什么数据库连不上”“为什么改了连接串还报错”,说明问题还在基础层。Kubernetes 不会替你修好这些基础层错误,只会把它们放进更复杂的系统里。
适合继续留在 Compose 的情形
- 服务单机就能跑,主机数量少。
- 瓶颈在构建、配置或数据恢复,不在调度和横向扩展。
- 业务发布频率不高,但需要稳定回滚和清晰审计。
- 团队还没有把镜像、卷、健康检查、代理和备份契约写明白。
等这些基础契约都稳定了,再评估是否需要迁移到更重的编排层。迁移不是目的,稳定和可维护才是。
回滚前要先问三个问题
第一,旧镜像还在不在;第二,旧配置有没有被覆盖;第三,数据迁移是否可逆。只要有一个答案不确定,就别把回滚说得太轻松。很多现场真正需要的不是“马上回滚”,而是先暂停、确认影响面,再决定退到哪一层。
如果回滚只退应用,不退配置,不退反向代理缓存,也不退数据库 schema,那其实只是换了一层表面状态。真正的回滚应该把依赖一起拉回可控范围。
回滚验证清单
- 镜像 tag 是否回到已知稳定版本。
- Compose 文件是否回到上一个已验证提交。
- 数据库迁移脚本是否与旧版本兼容。
- 外部流量是否已切回旧入口。
- 监控和日志是否没有新的异常峰值。
现场常见误判
误判一:容器重启就等于修好。实际上只是把症状暂时遮住。误判二:healthcheck 通过就等于业务可用。实际上只是入口探针通过。误判三:数据库能连上就等于迁移成功。实际上可能只是连通而已。误判四:Kubernetes 能解决一切。实际上基础边界没写好,换平台也没用。
排障的经验常常不是“记住更多命令”,而是“少犯几种典型误判”。把这些误判写在心里,后面很多问题就会提前被拦下。
把证据保存成可复盘材料
复盘不是为了写长篇报告,而是为了下次更快定位。最少保留四类证据:镜像版本、Compose 配置、关键日志、回滚步骤。若有数据库问题,再加上备份时间点和恢复脚本。这样无论是线上故障还是演练失败,都能快速复原现场。
如果没有这些证据,排障很容易变成“我记得当时改过什么”,但没人能复现。复盘材料越短越好,但每一项都要能落地。
多阶段构建和缓存不是可选项
很多人以为多阶段构建只是为了把镜像做小一点,其实它更重要的价值是把“构建失败”和“运行失败”切开。构建阶段负责依赖下载、编译、测试和打包,运行阶段负责启动、接流量和退出。两者分开后,排障时才能一眼看出问题发生在哪一段。
对于 Java、Go、Node、Python 这类服务,镜像层里最不该混的就是编译器、包管理器、源码和运行产物。只要你把构建缓存、运行缓存和持久化数据混在一起,下一次故障排查就会变成“到底是代码坏了还是镜像层污染了”的猜谜。
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B clean package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 appuser
COPY --from=build /workspace/target/*.jar /app/app.jar
USER appuser
ENTRYPOINT ["java","-jar","/app/app.jar"]
有些团队会把 docker build 当成一次性动作,构建上下文又大又乱,调试时再用 --no-cache 硬顶。这样做只能暂时绕开缓存问题,不能解决根因。正确做法是把缓存看作可控资产,让依赖下载、源码变更和运行镜像各自有边界。
镜像标签、digest 和回退点
在生产里,标签只是人类可读的名字,真正稳定的是 digest。标签方便回退,但标签本身可能被错误覆盖;digest 不方便读,却更适合锁定已经验证过的产物。最稳妥的做法,是把标签用于发布说明,把 digest 用于生产固定。
如果你的回滚策略只写“拉回上一版镜像”,但没有记录具体 digest,那等于把回退建立在记忆上。排障一旦过了一天,版本关系很容易混。
为什么 .dockerignore 不是节省一点流量这么简单
构建上下文是隐性的风险面。它影响的不只是速度,还包括缓存命中、机密泄露和可复制性。IDE 文件、测试输出、临时日志、环境文件、打包产物都不应该无脑送进上下文。最小原则是:只把构建真的需要的内容送进去。
若项目是前后端同仓,前端产物和依赖目录也要一起管。否则你会发现 Docker build 每次都像第一次,缓存命中很差,故障时还很难区分是谁污染了上下文。
建议的忽略项
.git
.idea
.vscode
target
build
coverage
*.log
.env
README.md
compose.override.yaml
node_modules
.DS_Store
不是每个项目都需要同一份列表,但原则相同:让构建上下文只剩下镜像真的要用的文件。越少越干净,越容易解释。
depends_on 的边界再说一次
depends_on 可以表达“谁先启动谁后启动”,但不能表达“谁已经能服务”。这两个概念经常被混在一起,导致数据库还在初始化时应用已经开始连库,错误信息看起来像连通失败,实际上只是就绪判断错了。
如果你的 Compose 版本支持健康条件,就把它写清楚;如果不支持,就在入口脚本里做显式等待。不要把“等五秒”当成设计。
services:
app:
depends_on:
db:
condition: service_healthy
db:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
健康检查最好贴着真实依赖。数据库要能连、能读、能写;应用要能返回真实健康状态;代理要能连到后端。只要探针写得太宽,部署就会把“假健康”误当成“真可用”。
优雅停止必须和业务关闭流程一起设计
如果一个服务不能接住 SIGTERM,那它就不适合在生产里反复滚动更新。优雅停止不只是“别用 kill -9”,更重要的是让应用自己知道什么时候下线、什么时候停接新请求、什么时候释放资源。
对 Java 服务来说,stop_grace_period 只是最后一层保险;真正的关键是应用的关闭钩子和连接池回收。对数据库和批处理来说,优雅停止更是数据安全的底线。
services:
app:
stop_signal: SIGTERM
stop_grace_period: 45s
init: true
如果关闭阶段还会发请求、跑迁移、写临时文件,最好把这些动作前移到正常请求路径,而不是留给退出阶段。退出阶段应该尽量短、尽量稳、尽量可预测。
回滚不是最后一招,而是正常设计的一部分
生产里最怕的不是回滚,而是没有可回滚的边界。镜像、配置、数据库迁移和入口代理都要能说明自己能不能回退。只要其中任何一个层面不可逆,回滚就不能临时拍板。
回滚之前先确认旧镜像还在、旧配置可追踪、迁移脚本可逆、数据备份能恢复到足够近的时间点。把这些都写清楚,回滚才不是“试试看”,而是“按预案执行”。
回滚后要再验证什么
- 业务接口是否已经回到稳定响应。
- 数据库是否没有新的写入错误。
- 日志里是否只剩已知的旧告警。
- 入口流量是否已经恢复到旧路径。
什么时候需要重新审视容器边界
如果问题总是反复落在同一类地方,比如密钥管理、卷挂载、单机资源、初始化顺序、回滚流程,那不是 Docker 不行,而是你的边界还没写成可执行契约。先把契约补好,再谈扩平台。
很多故障不会因为换工具而消失,只会因为边界更清楚而变少。
把每次故障都变成下一次的检查表
排障最怕“修好了就忘”。如果每次故障都只停在当场,下一次仍会重复同样的动作。更有价值的做法,是把这次故障转化成一条可复用的检查表:先看什么、先排除什么、最后确认什么。这样团队后续接手时,不需要猜前人是怎么判断的。
举例来说,容器启动失败后,应该形成固定的三步检查:先看退出码和日志,再看挂载和环境变量,最后看镜像和入口命令。端口访问失败则形成另一套三步:先看监听地址,再看端口映射,最后看代理和防火墙。把这些动作沉淀下来,比临时修补更值钱。
把判断写成固定顺序
- 先判断是不是宿主机资源问题。
- 再判断是不是 Docker 引擎或网络问题。
- 再判断是不是容器自身配置问题。
- 最后判断是不是业务依赖或回滚策略问题。
这个顺序不是死规定,但它能减少大多数无效动作。很多团队习惯从“改配置”开始,结果把问题越改越乱。先证据,后修复,才是更稳的路径。
日志、监控和手工验证要一起用
只看日志不够,只看监控也不够。日志告诉你发生了什么,监控告诉你什么时候发生,手工验证告诉你是不是真的恢复。三者必须合起来看,判断才不会偏。
例如健康检查已经绿了,但用户仍然报错,这时就不能只看探针,要去查业务路径、数据库状态和代理缓存。很多“看起来恢复了”的现场,实际只是表层信号恢复了。
一个简单的验证组合
curl -fsS http://127.0.0.1:8080/health
curl -fsS http://127.0.0.1:8080/api/ready
journalctl -u docker --since '10 minutes ago'
如果这些结果彼此矛盾,就继续追根因,不要急着宣布结束。验证不是形式,它是确认修复真的落地。
故障后要明确谁负责收尾
很多现场问题不在于“没修”,而在于“修完没人收尾”。改了镜像的人、改了 Compose 的人、看日志的人、负责回滚的人,最好在一次故障里能分清职责。否则到了下次,没人知道哪个动作有效,哪个动作只是碰巧。
收尾的重点包括:把临时绕过删掉,把临时扩容缩回去,把调试日志级别恢复正常,把应急账号撤掉,把回滚记录写回 Runbook。真正完成不是服务跑起来,而是把现场恢复到可长期维护的状态。
常用命令的真正用途
docker compose config 是为了看渲染后的最终配置;docker inspect 是为了看状态细节;docker logs 是为了看容器自身说了什么;docker system df 是为了看资源去哪了。每条命令都对应一个明确问题,不要把命令当成漫无目的的试探。
如果你愿意把命令和问题一一对应,排障速度会快很多。反过来,如果每条命令都只是“试试看”,那你其实还没有形成判断。
从一个故障看全局
最成熟的排障方式,不是对每个错误都临时判断,而是从一次故障里反向补全系统设计。比如这次是数据库连不上,那么下次就应该提前加健康检查、明确服务名、准备恢复脚本、确认停止时间。故障不只是问题,也是设计校验。
当一套系统经过几轮故障之后,还能逐步变得更清楚、更可回退、更容易解释,这套系统就已经开始走向稳定了。
把现场信号区分成“症状”和“根因”
排障经验越多,越会发现一件事:用户看到的症状和系统真正的根因往往不是同一层。端口不通可能只是代理层的错误,容器退出可能是宿主机内存压力,数据库连不上可能只是初始化慢。把症状直接当根因处理,通常会绕远路。
所以排障时最好先把信号分层:用户报错、容器状态、日志异常、资源告警、依赖不可用。每一层都可能是真的,但它们解释的问题不一样。只有把层拆开,才知道最小修复应该落在哪里。
先看谁最靠前发出异常
- 用户先报错,说明业务链路已经影响外部流量。
- 日志先报错,说明容器内部已经开始失稳。
- 监控先报错,说明资源或依赖已经出现趋势问题。
- 回滚才报错,说明版本边界本身就不稳定。
判断顺序一旦排好,很多“看起来像大故障”的问题就会缩小成某一层的小问题。
一份好的排障记录应该写什么
最有价值的排障记录,不是把命令一条条抄下来,而是把决策写明白:当时看到什么、为什么先改那一处、修复后验证了什么、如果不行准备退回到哪一版。这样下一次遇到同类问题,别人才能直接接手。
建议至少保留这几项:发生时间、影响范围、容器名、镜像标签、Compose 版本、退出码、关键日志片段、修复动作、验证动作、回滚动作。信息不需要多,但要足够复盘。
排障记录模板
现象:
证据:
最小修复:
验证结果:
回滚计划:
最终结论:
这个模板不会替你判断,但它会逼你把判断说清楚。
把临时修复和长期修复分开
现场有时需要先用临时修复止血,比如加大超时时间、临时回退镜像、缩短流量切换窗口。但这些动作只是止血,不是设计修复。长期修复仍然要回到 Dockerfile、健康检查、卷、启动顺序和停止逻辑这些基础层面。
如果临时修复留得太久,系统会慢慢把“权宜之计”当成“正式方案”。那种状态最麻烦,因为问题已经被掩盖,系统习惯却被固化了。
边缘案例:容器重建后问题还在
有些故障重启后还是一样,这时不要急着怀疑 Docker。先分清是镜像问题、卷问题还是外部依赖问题。镜像重建后如果问题完全消失,多半是镜像或入口命令有误;如果重建后还在,重点就该转到卷、网络和外部服务。
这类判断很重要,因为它能快速告诉你该从哪一层回头。镜像可重拉,卷要备份,网络要确认,外部依赖要独立验证。不要把所有不确定性都扔给容器本身。
重建后仍有错误时的分叉判断
- 同镜像、同配置、不同容器仍出错:优先查卷和外部依赖。
- 不同镜像、同配置、错误消失:优先查镜像构建和入口脚本。
- 重建后症状变化但没消失:说明有多个问题叠在一起。
这种分叉判断能避免一次故障拖成三轮无效修改。
数据卷(Volume)、Bind Mount 和只读挂载:先保留证据,再谈恢复
排障时要先分清数据放在什么地方。Volume 由 Docker 管理,适合数据库和长期状态;Bind Mount 直接映射宿主机路径,适合配置、证据导出和手工检查;--mount 则把参数写得更清楚,便于复盘。容器重建后数据是否还在,先看挂载方式,再看应用本身。
docker run -d --name pg-demo -v pgdata:/var/lib/postgresql/data postgres:16
docker run --rm -it --mount type=bind,src=/srv/app/conf,dst=/etc/app,readonly alpine cat /etc/app/app.yaml
docker run --rm --mount type=volume,src=pgdata,dst=/var/lib/postgresql/data,readonly alpine ls -la /var/lib/postgresql/data
如果只是想确认现场证据,不要先写回容器。用 readonly 或 ro 挂载把容器限制住,先把目录清点出来,再决定是否恢复。Volume 里能看到文件,不代表它就是备份;它只是数据所在的位置。
镜像固定和最小复现:docker run 先跑起来,再锁 digest
排障时先用可读的 tag 复现,再把最终版本固定到 digest。这样你既能快速验证,又能避免“同名标签已经换了内容”的回退陷阱。
docker run --rm alpine:3.21 echo ok
DIGEST=$(docker image inspect --format '{{index .RepoDigests 0}}' alpine:3.21)
docker run --rm "$DIGEST" echo ok
如果某次升级后才出问题,先回到上一个确认过的 digest,而不是只回到同名标签。tag 代表可变引用,digest 才是可校验的镜像内容。
restart policy 只是重启策略,不是故障修复
restart: unless-stopped 或 restart: on-failure:5 只能告诉 Docker 怎么重启进程,不能告诉它为什么会崩。频繁重启的容器,可能只是把日志刷得更快、把数据库打断得更频繁。
services:
app:
image: myapp:1.2.3
restart: unless-stopped
worker:
image: myworker:1.2.3
restart: on-failure:5
正确做法是先看退出码和日志,再判断是配置错、依赖没起、权限不够,还是程序自己崩了。不要把重启策略当成健康策略。
PostgreSQL 备份恢复:卷不是备份
数据库的卷保存的是当前数据,不是恢复方案。要有真正可演练的备份,就得把逻辑备份和恢复命令写出来。最常见的组合是 pg_dump / pg_restore,而不是只盯着 Volume。
docker exec -t pgdb pg_dump -U app -Fc appdb > appdb.dump
docker exec -i pgdb pg_restore -U app -d appdb --clean < appdb.dump
如果数据已经损坏,先验证备份文件可读,再恢复到隔离环境,确认表、索引、权限和连接都正常,然后再决定是否覆盖生产。恢复证据应该包括备份时间、恢复目标、校验结果和业务验证结果。
安全边界:privileged、docker.sock、非 root、最小端口暴露
--privileged 会把容器权限抬得过高,/var/run/docker.sock 挂进容器基本等于把宿主机 Docker 控制权交出去。生产里如果能不用,就不要用。
docker run --privileged --rm alpine sh
docker run -v /var/run/docker.sock:/var/run/docker.sock --rm alpine sh
更稳妥的方式是让容器以内置非 root 用户运行,尽量只暴露必须的端口,例如把服务绑定到 127.0.0.1 再交给反向代理,或者在防火墙里只放行业务真正需要的入口。宿主机防火墙、云安全组、Compose 端口映射要一起看,别只改其中一层。
docker run --rm -p 127.0.0.1:8080:8080 --user 10001:10001 myapp:1.2.3
对应的官方文档
- Docker Engine 官方安装文档:先把引擎装对,再谈排障。
- Dockerfile 最佳实践:构建上下文、缓存和层次写法的官方说明。
- Volumes 文档:理解卷和持久化边界的基础资料。
- Compose 启动顺序说明:什么时候能用 depends_on,什么时候不能依赖它。
- Engine 安全文档:privileged、daemon 暴露和宿主机风险都该先看它。
- Rootless 模式:了解非 root 运行和权限收缩的官方路径。
- Compose 参考手册:字段、语法和行为以这里为准。