
1. 先搞懂容器为什么会退出你才敢动手修1.1 容器不是虚拟机PID 1 就是容器的命脉先说一个可能颠覆很多新手认知的结论Docker 容器不是一台虚拟机它只是一个被隔离了文件系统、网络、进程空间的进程。虚拟机里装了一整个操作系统init 或 systemd 进程一直在后台跑着所以虚拟机不会自己关机但容器不一样你把镜像拉下来docker run之后Docker 只做了一件事——启动镜像里配置好的那个进程并且让这个进程成为容器内的 PID 1。这个 PID 1 有多重要它活着容器就是Up状态它一旦退出无论退出码是 0 还是 1容器立刻结束。这跟你在终端里敲一条命令是一样的命令执行完终端就回到提示符不会一直挂着。所以容器启动就退出这件事从机制上说不是故障而是容器生命周期设计的必然结果。真正的问题永远是同一个你让它跑的进程有没有一直活着把这个模型装进脑子里后面所有排查都会变得非常简单。你不需要去猜测什么镜像坏了Docker 抽风了只需要回答一个问题——容器里的 PID 1 为什么结束了1.2 退出码docker ps -a 里最被忽略的信息新手最常犯的一个错是看到容器退出就急着删了重建根本不看退出码。其实docker ps -a的STATUS列已经给出了最重要的线索。Exited (0)和Exited (1)背后的故事完全不同排查方向也完全不同。退出码含义常见场景0进程主动正常退出完成使命后自己结束跑基础镜像未指定前台任务、hello-world 打印完退出、启动脚本里执行一次性命令1应用启动失败或运行时报错配置文件错误、参数不对、权限问题、依赖连不上126命令本身无法执行镜像里缺少可执行文件、权限不可执行127命令找不到Dockerfile 或启动命令里写了不存在的命令137被 SIGKILL 强制杀死最常见是内存不足触发 OOM Killer139段错误进程崩溃应用自身 bug、底层依赖异常143被 SIGTERM 优雅终止docker stop停止容器时的正常表现我见过太多人分不清 0、1、137 的区别。看到Exited (0)就以为没毛病不用管结果把问题拖到生产环境看到Exited (137)又以为是被谁杀了是不是有病毒。实际上退出码就是程序自己留在门口的字条先读它再决定下一步别一上来就删容器重跑。另外提醒一句如果你的问题根本不是容器退出而是docker ps自己报permission denied while trying to connect to the docker api at unix:///var/run/docker.sock那属于当前用户没有 Docker 守护进程访问权限跟容器生命周期是两码事先别混在一起排查。1.3 容器退出不等于 Docker 没用对很多人第一次遇到容器秒退第一反应是重装 Docker、换镜像源、甚至怀疑电脑配置。其实 90% 的情况是用法问题不是环境问题。hello-world能跑是因为它天生就是打印一段话然后退出的镜像你甚至应该把它当成一个烟雾测试工具而ubuntu、alpine这类基础镜像默认命令是bash没有交互终端的话bash 读不到输入直接返回容器就完了。所以别急着折腾环境。拿起退出码配合下一节要讲的日志命令几分钟就能定位到真实原因。2. 场景一基础镜像空跑导致的瞬退2.1 典型现象复现这是 90% Docker 新手踩的第一个坑现象非常统一docker run -d --name sandbox ubuntu然后docker ps一看什么都没有docker ps -a一看状态是Exited (0)。如果你够细心还会发现这个容器从创建到退出只用了不到一秒。有的人会不死心再试一次docker run -it ubuntu发现进去了shell 可以正常用觉得诶怎么加个参数就好了。这说明什么说明镜像本身没有坏问题出在启动方式上。2.2 三种修法以及每种适合什么场景修法一交互式运行docker run -it --name sandbox ubuntu bash-i表示保持标准输入打开-t表示分配一个虚拟终端。两者缺一不可只加-t没有输入通道bash 会立即跑到 EOF只加-i没有终端虽然能输入但 shell 没有提示符回显乱七八糟。这种方式适合调试和学习适合你希望进去敲命令的场景不适合让容器在后台长期提供服务。修法二挂一个长驻进程占住 PID 1docker run -d --name sandbox ubuntu sleep infinitysleep infinity会一直睡下去容器就能保持Up。你随时可以docker exec -it sandbox bash进容器操作。这是临时代替方案适合先让容器别退我要进去看看情况的排障场景不适合生产——因为你的业务进程并没有真正跑起来。修法三换有实际服务的镜像想跑 Web 服务就让nginx镜像来想跑数据库就让mysql镜像来想跑缓存就让redis镜像来。基础镜像本身只是带包管理的空系统你在里面装完软件后最终还要自己写启动命令来挂住它。新手最容易犯的错就是拿ubuntu当一辈子的沙箱需要什么服务再手动装结果每次做完实验容器一关一切归零。2.3 为什么 nginx 能一直跑ubuntu 不能你可能会想既然基础镜像会退那 nginx 镜像凭什么能一直在后台跑答案在它的启动命令里。nginx 官方镜像的 Dockerfile 末尾通常会写类似这样的东西CMD [nginx, -g, daemon off;]daemon off是关键。nginx 默认会把自己 fork 到后台变成守护进程然后父进程退出。在宿主机上这没问题但在容器里这就是灾难——父进程一退PID 1 没了容器立刻结束。所以镜像作者故意加了daemon off让 nginx 以前台模式运行这样 PID 1 就一直被 nginx master 进程占住容器才不会秒退。同理官方 redis 镜像的默认命令是redis-server直接前台运行。MySQL 镜像则是通过docker-entrypoint.sh启动mysqld并保持前台。几乎所有能长期运行的 Docker 镜像都遵循同一个原则服务进程必须在前台占住 PID 1。谁要是把服务配置成后台守护式运行谁就会得到一个启动即退出的容器。2.4 这个场景的排查路线图拿到一个秒退的容器别急着删按这三步走# 第一步看退出码和重启次数 docker ps -a # 第二步看容器的控制台输出这一步几乎能解决一半问题 docker logs --tail 100 容器名 # 第三步确认镜像默认启动命令 docker inspect 镜像名 --format {{.Config.Cmd}}如果日志是空的或者只有几行 shell banner退出码是 0那基本就是没给 PID 1 交代任务。此时要么加-it进去用要么给它挂一个sleep infinity要么换成有实际业务的镜像。如果日志里明显有一堆报错那就进入下一类场景。3. 场景二应用启动即崩——初始化与配置错误3.1 退出码 1 是有报错的先看日志第二类常见场景是镜像本身有自己的业务进程但进程启动失败。典型表现是你运行 MySQL、Redis、PostgreSQL 这类正经服务镜像容器起来一两秒后退出退出码是 1。退出码为 1 意味着应用层报错了这时候千万别乱猜第一个动作一定是docker logs --tail 50 容器名为什么因为容器退出前写进 stdout/stderr 的内容全都会被 Docker 保存下来。这些日志往往就是应用自己打印的错误信息比如 MySQL 初始化失败的原因、配置文件哪一行解析出错、连不上某个端口等等。我见过太多新手卡在这一步反复重建容器、反复换镜像标签就是不肯看一眼docker logs。实际上你离答案就差这一条命令。3.2 根因 A挂载目录权限不对如果你用-v把宿主机目录挂进容器比如docker run -d --name mysql-test \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDsecret \ -v /data/mysql:/var/lib/mysql \ mysql:8.0结果容器秒退docker logs里出现类似这样的内容[Entrypoint] MySQL Docker Image 8.0.28 [Entrypoint] Initializing database chown: changing ownership of /var/lib/mysql: Operation not permitted问题基本就定位到了宿主机目录/data/mysql的所有者和权限与容器内 MySQL 用户一般 UID 是 999名叫mysql不匹配。容器启动时要 chown 这个目录但权限不够初始化直接失败。解决思路有三种按推荐程度排序方案 A用命名卷让 Docker 自己管权限docker volume create mysql-data docker run -d --name mysql-test \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDsecret \ -v mysql-data:/var/lib/mysql \ mysql:8.0命名卷在第一次挂载时会按镜像内目录的所有者和权限自动初始化这是兼容性最高、最省心的做法。数据仍然在宿主机上只是存放在 Docker 管理的目录下可以通过docker volume inspect mysql-data查找到真实路径。方案 B手动修正宿主机目录权限如果你一定要用绑定挂载就得先确认镜像内进程的 UID。MySQL 8 镜像一般是 999可以执行mkdir -p /data/mysql chown -R 999:999 /data/mysql如果你在用 SELinux 强制模式的环境还可能要加:Z后缀-v /data/mysql:/var/lib/mysql:Z:Z会重新标记宿主机目录的 SELinux 标签让容器能访问。注意这些操作需要 root 权限而且目录一旦被初始化过不要随意来回改属主否则可能引发新问题。方案 C查看镜像里的用户 UID 再决定 chown 谁执行docker run --rm mysql:8.0 id mysql就能看到镜像内 mysql 用户的 UID/GID。依葫芦画瓢调整宿主机目录属主即可。这类问题在 Windows/macOS 的 Docker Desktop 上相对少见因为 Docker Desktop 会在虚拟机和宿主机之间做权限映射但在 Linux 服务器上挂载目录时权限问题几乎是必练兵。3.3 根因 B改动配置破坏了镜像的前台机制第二个高频根因是你自己改了启动命令或配置文件破坏了前台运行的约定。这里分享一个非常经典的 Redis 案例。假设你在redis.conf里设置了daemonize yes然后执行docker run -d --name redis-test -p 6379:6379 \ redis:7 redis-server /etc/redis/redis.conf结果容器启动后立刻退出而且docker logs什么报错都看不到——这不是权限问题也不是密码问题而是 Redis 在配置指引下把自己 fork 到了后台原来的前台父进程直接退出。父进程一退PID 1 消失Docker 判定容器任务完成容器结束。哪怕 Redis 进程此刻还在宿主机上跑着你也已经看不见它了。解决方法很简单在容器里永远别让进程后台化。要么把配置改成daemonize no要么启动命令直接不加载这份配置比如docker run -d --name redis-test -p 6379:6379 redis:7同理如果你写自定义启动脚本也要注意脚本最后执行的那个进程必须前台运行。别写nohup xxx 然后脚本结束——那容器必退无疑。3.4 MySQL 跑通的最稳路径把前面两节串起来给一个零失误的 MySQL 启动流程# 第一步拉镜像并确认默认行为 docker pull mysql:8.0 # 第二步创建命名卷 docker volume create mysql-data # 第三步启动 docker run -d --name mysql-test \ --restart unless-stopped \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDsecret \ -v mysql-data:/var/lib/mysql \ mysql:8.0 # 第四步等待初始化然后看日志 sleep 20 docker logs --tail 50 mysql-test如果日志里出现ready for connections容器状态是Up那就跑通了。如果还是退出用docker logs认真读最后几十行再对照我上面说的权限、配置、内存三种根因去排查。不要一次次删容器重新 run日志才是唯一的正道。4. 场景三资源不足与依赖未就绪4.1 OOM 把容器悄悄杀掉退出码 137第三类场景往往更隐蔽容器可能已经成功运行了几秒甚至几分钟表现正常然后突然消失。你去docker ps -a看退出码是 137。这个退出码说明进程是被 SIGKILL 杀掉的最常见的凶手就是操作系统的 OOM Killer。典型场景是一台只有 2G 内存的服务器直接裸跑 Elasticsearch 或 MySQL 8。ES 默认 JVM 堆就占了 1G再加上其他内存开销很容易让内核判定内存耗尽然后把容器里占用内存最多的进程干掉。你以为 Docker 报错了其实 Docker 是无辜的。排查方法其实很直接# 检查是否被 OOM docker inspect 容器名 --format OOMKilled: {{.State.OOMKilled}} # 实时看容器内存占用 docker stats # 看宿主机内核日志里的 OOM 记录 dmesg | tail -50如果OOMKilled是true基本实锤。解决办法不是盲目加大--memory限制而是先让应用自己少吃内存镜像内存敏感参数建议ElasticsearchES_JAVA_OPTS-Xms512m -Xmx512m低配机上主动压低 JVM 堆MySQL--innodb_buffer_pool_size256M默认值按大内存机器设计小内存要主动调小PostgreSQLshared_buffers一般建议设为物理内存的 1/4小内存机器要克制Redismaxmemory/maxmemory-policy避免数据量增长吃掉全部内存另外如果你发现容器被 OOM 杀掉后又被自动拉起来了可以看docker inspect里的RestartCount那说明你配了 restart 策略但这只能兜底不能根治。该调参还是要调参。4.2 依赖没就绪业务容器连锁退出还有一种让人抓狂的退出不是某个服务单独退出而是应用容器因为连不上依赖服务而崩溃。最常见的是你用docker-compose编排了一个 Web 应用和一个 MySQL 数据库应用容器启动后立刻尝试连接数据库但数据库初始化需要几十秒应用连不上重试几次后放弃带着错误退出。很多人会以为是 MySQL 有问题其实 MySQL 还在慢慢初始化是应用太心急了。解决它需要两层配合第一层告诉编排系统先启动数据库但启动成功不代表可用很多人以为depends_on就够用其实depends_on只保证启动顺序不保证服务就绪。正确做法是给数据库加healthcheckservices: db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: secret healthcheck: test: [CMD, mysqladmin, ping, -h, localhost, -psecret] interval: 5s timeout: 3s retries: 20 app: image: my-app:latest depends_on: db: condition: service_healthy restart: unless-stoppedservice_healthy会等待 MySQL 的 healthcheck 通过后再启动应用从根上避免了连接被拒。第二层应用自己要有重试逻辑健康检查能解决大部分编排顺序问题但不是所有场景都适用比如跨机器、跨网络的依赖。真正的健壮做法是让应用在启动时对依赖做多次重试而不是一次性连接失败就退出。这属于应用层改造但从我实战经验看这个改造价值极高能让整个系统的容错能力上一个台阶。4.3 restart 策略能续命但不是万能药Docker 提供几种重启策略合理配置能避免手动干预的尴尬策略行为适用场景no不自动重启默认值只适合一次性任务on-failure非零退出码时重启应用偶发崩溃可以接受重启兜底always无论退出原因都重启包括 Docker 守护进程重启后常驻服务常用但要防止无限循环unless-stopped同 always但手动停止过的容器不会在守护进程重启后再拉起我个人的首选兼顾稳定和可控配置方式单容器用docker run --restart unless-stoppedcompose 里写restart: unless-stopped。这里有个特别容易踩的坑如果应用本身启动就崩溃你又配了alwaysdocker ps 里会出现Restarting (1) 3 seconds ago这种状态容器一遍遍重启一遍遍失败日志刷得飞快。这不是 Docker 坏了而是退出原因没有解决的信号。此时应该先关掉重启策略或先docker stop静下来看日志把根因修掉而不是眼睁睁看它循环重启。5. 从源头避免让你写的容器天生不会秒退5.1 镜像启动命令的两种正确姿势如果你自己写 Dockerfile最需要小心的是CMD和ENTRYPOINT的写法。它们决定了 PID 1 跑什么也决定了容器能不能活。先说结论服务类镜像的CMD/ENTRYPOINT一定要用exec form也就是 JSON 数组写法CMD [nginx, -g, daemon off;]不要写成CMD nginx -g daemon off;。Shell 写法会让 Docker 用/bin/sh -c包一层这一层 shell 会成为 PID 1 的父进程信号转发、僵尸进程回收都可能出问题服务进程变成了 PID 1 的子进程一旦 shell 退出服务再健康也没用。想给默认命令加参数时用ENTRYPOINT固定不可变部分用CMD提供默认参数ENTRYPOINT [redis-server] CMD [--port, 6379]运行时用docker run redis --port 6380就能覆盖默认端口。这种拆分是 Docker 镜像设计的标准姿势既灵活又不容易把启动进程搞丢。如果你确实需要一个初始化脚本脚本的最后一步务必要用exec把业务进程替换为 PID 1#!/bin/bash # 做一些初始化环境的工作 exec mysqld_safeexec会用新进程替换当前 shell 进程这样 PID 1 就变成了 mysqld_safe而不是一个等命令结束的 shell。进阶话题如果容器里要跑多个子进程或者你想妥善处理孤儿进程可以引入tini或dumb-init作为 PID 1把业务进程挂到它下面。不是每个镜像都需要但当你的应用会 fork 子进程、回收信号又不太正常时这个方案很管用。5.2 用 docker compose 编排别裸跑多容器到了这一步我强烈建议所有配置性的参数都迁移到docker compose里。裸跑一条超长的docker run命令看起来很酷但维护性极差。compose 文件能让你把端口、环境变量、挂载、健康检查、重启策略一次性固化下来别人拉下来就能复现排查问题也少很多手滑重新敲命令的意外。一个比较完整的 Web 数据库 compose 示例services: web: image: nginx:1.26 ports: - 8080:80 volumes: - ./html:/usr/share/nginx/html:ro depends_on: api: condition: service_healthy restart: unless-stopped api: image: my-api:latest environment: DB_HOST: db DB_PORT: 3306 healthcheck: test: [CMD, curl, -f, http://localhost:8080/health] interval: 10s timeout: 3s retries: 5 restart: unless-stopped db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: secret volumes: - mysql-data:/var/lib/mysql healthcheck: test: [CMD, mysqladmin, ping, -h, localhost, -psecret] interval: 5s timeout: 3s retries: 20 restart: unless-stopped volumes: mysql-data:这套结构里web虽然不直接依赖db但通过depends_on让编排顺序更清晰真正的服务依赖通过 healthcheck 串起来。把这些写进文件容器退出后的恢复策略、依赖等待、数据持久化就都齐了。5.3 我的排查顺序与自查清单最后分享一套我自己一直在用的排查习惯适合任何容器启动就退出的情况docker ps -a看退出码和重启次数先分类。docker logs --tail 50 容器名看应用自己的遗言90% 的问题到这一步已经现形。docker inspect 容器名看State.OOMKilled、RestartCount、Mounts、Config.Cmd排查 OOM 和配置问题。docker stats看资源是否吃紧。检查你的启动命令是前台进程吗有-it吗重启策略会不会引发循环检查依赖被依赖的服务真的 ready 了吗有没有健康检查用这套顺序我基本没有遇到过超过十分钟还定位不了的秒退问题。关键是不要在第一步就开始盲修——删容器、换镜像、重启 Docker 都是最后手段不是排查手段。我个人在实际操作中的体会是容器退出这件事本质就是进程没了。把它当成普通的进程排查问题用日志当显微镜用退出码当线索你会发现 Docker 并不神秘。真正让人卡住的不是技术而是不看日志就急着动手的坏习惯。养成先看退出码、再看日志的习惯之后这套避坑流程对你来说就是肌肉记忆了。