1. 容器这件事,先想清楚它替你省掉了哪类麻烦
"Docker Container"这个标题看起来像是入门第一课的名词解释,但真正把容器用起来之后你会发现,它解决的从来不是"怎么装软件"这种表层问题,而是"同一份代码在三个人三台机器上跑出三种结果"这类让人抓狂的事。我印象很深的一次,某项目的服务在开发机上跑得好好的,部署到测试环境就直接启动失败,排查了大半天,最后发现是两边系统里的某个基础库版本差了一个小版本号,而这个库并不是项目显式依赖的,是被另一个依赖间接带进来的。这类问题在没有容器的年代,解决方式基本靠"文档 + 祈祷 + 口口相传",而容器把"运行环境"本身变成了一个可以被版本管理、被分发、被复现的产物,这才是它真正值钱的地方。
这篇文章我打算按照一个实际使用者会踩到的顺序来讲:先建立对容器本质的正确理解,再往下拆镜像分层和写时复制这套底层机制,然后是 Dockerfile 怎么写才不至于每次构建都等十分钟,接着是网络、存储、资源限制这些跑起来之后必然会碰到的部分,最后是上线前后那几件必须补的课,以及容器起不来时我自己的排查顺序。内容偏实操,假设你至少敲过docker run,但很多细节即便用了一两年的人也未必认真想过。
1.1 环境漂移才是那个真正的问题
绝大多数人第一次接触容器,都是为了解决部署时"环境不一致"。但"环境不一致"这个描述太笼统了,它其实包含好几层不同的东西,混在一起谈就容易得出错误结论。
第一层是操作系统层面的差异。发行版不同、内核版本不同、系统库不同,这些差异会导致编译出来的二进制文件在另一台机器上直接跑不了,或者跑起来行为诡异。容器在这里的做法是:把应用依赖的那部分用户态文件系统整体打包,运行时复用宿主机内核。注意这句话里的重点——复用宿主机内核,这是容器和虚拟机最本质的分野,后面还会展开。
第二层是运行时依赖的差异。比如某个语言运行时的小版本差异导致行为不同、某个系统级配置项的默认值不同、时区或字符集设置不同。这些东西散落在系统的各个角落,手工对齐极其容易遗漏,而容器把整个文件系统快照下来,天然就把这些一起带走了。
第三层是人为操作带来的差异。有人手动装了个包、改了个配置、留了个临时文件,这些东西不会写进任何文档,但会实实在在影响运行结果。容器镜像的构建过程是一条可重复执行的指令序列,只要指令不变,构建出来的东西就应该一样——注意是"应该",这里面有坑,后面讲缓存的时候会说。
把这三层分清楚之后你会发现,容器真正擅长的是第二层和第三层,第一层它只是"绕过"而不是"解决"。如果你的应用强依赖某个特定内核特性,容器是帮不了你的,这种情况还是得上虚拟机或者物理机。
1.2 "轻量"两个字的代价是什么
容器启动快、占资源少,这几乎是所有人对它的第一印象。但这个"轻"是有代价的,而且这个代价在项目早期完全感知不到,等到出问题的时候才会突然冒出来。
虚拟机是完整的系统模拟,有自己的内核、自己的驱动、自己的资源调度,隔离性强,代价是重。容器是宿主机上的一组进程,靠内核的命名空间做视图隔离、靠控制组做资源限制,没有独立内核。带来的直接后果是:内核是共享的。如果容器里的进程触发了内核 panic,整个宿主机一起完蛋;如果某个内核参数被改了,所有容器都受影响;容器里看到的内核版本永远是宿主机的版本,跟你镜像里装的是什么系统没关系。
还有一个常被忽略的点:隔离不等于安全边界。容器默认以 root 身份运行容器内进程,而这个 root 在早期实现里和宿主机 root 的权限关系相当微妙。现在的实现默认会做用户命名空间映射、能力裁剪、seccomp 过滤,安全性比早年好很多,但"容器里就是安全的"这个假设依然不成立。我在实际项目中见过有人在容器里跑数据库然后把数据目录 bind mount 到宿主机,结果容器内进程以 root 写入,宿主机上的文件属主变成了一个奇怪的高位 UID,后续运维操作全乱套。
所以对"轻量"的正确理解应该是:它把资源开销降下来了,但没有把隔离等级降下来的那部分责任也一并承担。你该做的安全配置、该给的资源限制、该考虑的故障隔离,一样都不能少。
1.3 镜像、容器、仓库:这三个词混着用会吃亏
这三个概念初学者经常混着说,但它们对应的生命周期和操作方式完全不同,混着用会在排查问题时产生误判。
镜像是一个只读的、分层的文件系统快照加上一份配置元数据,它本身不运行,就是一堆躺在磁盘上的层。容器是镜像加上一个可写层,再配上一组命名空间和控制组,是一个活着的进程组。仓库是存放镜像的地方,带版本标签。
用一个类比:镜像是安装光盘,容器是装好并开机的那台机器,仓库是放光盘的架子。光盘可以刻很多张一样的,机器可以同时开好几台,关机之后机器上的临时改动就没了,但光盘还是那张光盘。
日常最容易出错的地方是搞混"删了什么"。docker rm删的是容器,镜像还在;docker rmi删的是镜像,但如果还有容器引用它,会删不掉或者需要强制。更麻烦的是磁盘空间:以为删了容器就释放了空间,结果发现/var/lib/docker还在涨,多半是悬空镜像和构建缓存堆着。我一般会定期跑一下docker system df看看到底是谁占的,再决定清理策略,而不是上来就prune -a把所有东西都干掉——那会把你还想保留的停止状态的容器一起清掉。
| 对象 | 是否可运行 | 删除命令 | 常见误判 |
|---|---|---|---|
| 镜像 | 否 | docker rmi | 以为删容器就释放了镜像空间 |
| 容器 | 是 | docker rm | 以为删容器数据不会丢 |
| 卷 | 否 | docker volume rm | 以为删容器会连带删卷(默认不会) |
| 构建缓存 | 否 | docker builder prune | 完全忘了它的存在 |
2. 镜像分层与写时复制:容器为什么能做到秒级启动
如果只记一个关于容器的技术点,我建议记这个:镜像是由若干只读层叠加而成的,容器启动时并不会复制这些层,只是在最上面加了一个可写层。理解了这一条,很多看起来奇怪的现象就都能解释了——为什么容器启动只要几百毫秒、为什么多个容器能共用同一份镜像却不互相影响、为什么容器里删了文件镜像体积没变小。
2.1 一层一层垒起来的只读文件系统
镜像的每一层其实就是一个文件系统的差量。你在 Dockerfile 里写一条RUN、一条COPY、一条ADD,基本都会产生一个新层,层里只记录相对上一层的增删改。多条指令产生的层通过联合挂载(现在主流是 overlay2 这类实现)在运行时叠加成一个完整的目录树给容器看。
联合挂载这个动作本身很轻,它不搬运数据,只是把多个目录"叠"在一起,上层的同名文件会遮住下层的。这就是为什么一个几百兆的基础镜像,起十个容器也不会占十份空间——它们共享同一批只读层。
这里有个实操上的推论:镜像层数不是越多越好,也不是越少越好。层数太多会让镜像元数据变复杂,启动时叠加的开销也会增加;但把一堆命令硬塞进一个RUN里用&&连起来,虽然能减少层数,可读性和缓存粒度都会变差。我的习惯是:功能相关的几条命令合并成一个RUN(比如装依赖那一串),逻辑上独立的部分保持分开。
还有一点值得提醒:RUN里如果在同一层删除文件,那个文件在这一层就真的不存在了,体积会减。但如果文件是上一层带进来的,你在这一层rm掉,只是加了一个"删除标记",底层的数据依然在镜像里躺着。这是新手最常踩的体积陷阱,下一节细说。
2.2 写时复制:改一个字节也不会动到原层
容器运行起来之后,任何写操作都不会落到只读层上。内核的处理方式是:你要改某个文件,先把这个文件从只读层复制到可写层,然后在可写层里改。这个机制叫写时复制(Copy-on-Write)。
它带来的好处很直接:启动快,因为不需要复制整个文件系统;多个容器共享底层,内存和磁盘的页缓存也能共享一部分。但它也带来一个必须知道的性能特征:第一次写某个大文件会有一个复制开销。如果你在容器里跑一个需要频繁随机写大文件的程序,这个开销会实实在在体现在 IO 上。
我遇到过一次比较典型的情况:某数据处理任务在容器里跑比在物理机上慢了将近一倍,查了半天 CPU 和内存都正常,最后定位到是它要反复改写一个几百兆的中间文件。每次改写触发一次全量复制到可写层,可写层又在磁盘上,等于凭空多了一轮 IO。解决办法很简单,把这个中间文件目录挂成卷或者在启动时用--tmpfs挂到内存里,性能立刻回来了。
所以判断标准很清晰:需要频繁写、数据量大、对 IO 敏感的目录,不要放在容器的可写层里,要么挂卷(数据要保留),要么挂 tmpfs(数据不需要保留但要快)。
2.3 可写层随容器消失,这是设计不是缺陷
docker rm之后容器里的改动全没了,很多人第一次遇到时觉得这是个坑。但从设计角度看,这正是容器可复现性的来源——容器的生命周期被刻意设计成"无状态",任何需要留下来的东西都应该显式地放到容器外面。
这个约定在实践中有几个直接推论,我一条条说。
第一,日志不要指望可写层。默认日志驱动会把标准输出写到宿主机的文件里,这在容器删除后还在,但如果你把日志写到容器内的文件里,删容器就没了。而且容器内写日志很容易把可写层撑大,进而拖慢写时复制。
第二,配置文件怎么给。常见做法有三种:打进镜像(适合不常变的默认配置)、挂载进来(适合按环境变化的)、启动时通过环境变量注入(适合少量参数)。我一般按"变化频率"来选,变化越频繁越往外挂。
第三,临时文件放哪。容器里往/tmp写东西,默认是写在可写层,量大了一样会出问题。可以用--tmpfs /tmp:size=64m限一下,既快又能防止撑爆。
提示:容器里所有"以后还要用"的东西,都必须在启动时就被显式挂载出去。靠"进去改一下再 commit"这种方式维护的容器,最后一定会变成没人敢动的手工艺术品。
3. Dockerfile 写得好不好,取决于你对层缓存的理解
写了上百个 Dockerfile 之后,我的判断标准变得很简单:看构建日志里有多少行是CACHED。缓存命中率高的 Dockerfile,不只是构建快,更重要的含义是构建过程被正确地分解成了变化频率不同的部分。这背后其实是一个很朴素的工程思维:把稳定的东西放前面,把易变的东西放后面。
3.1 指令顺序决定了缓存命中率
构建时,每条指令执行前都会检查能不能复用之前的结果。检查的依据是这条指令本身加上它依赖的上下文(比如COPY的源文件内容)有没有变。一旦某条指令失效,它后面所有指令的缓存全部作废。
所以最典型的错误写法是这样的:先把整个项目目录COPY进去,再装依赖。结果就是你改了一行代码,依赖缓存全部失效,每次构建都要重新下载安装一遍所有包,构建时间从十几秒变成好几分钟。正确做法是把依赖清单单独先COPY进去、装依赖、再COPY源码。这样改代码不会影响依赖层的缓存。
FROM demo-runtime:1.0 WORKDIR /app # 先只拷贝依赖清单,依赖没变时这一层永远命中缓存 COPY package.json package-lock.json ./ RUN npm ci --omit=dev # 源码放最后,改代码只会让这一层及之后失效 COPY . . EXPOSE 8080 CMD ["node", "server.js"]这里有个细节:npm ci比npm install更适合容器构建,因为前者严格按锁文件安装,结果确定,而后者可能悄悄更新锁文件导致两次构建结果不一致。其他语言生态也有类似的分层策略,思路完全一致——锁文件和源码分开拷贝。
另一个常被忽略的点是.dockerignore。默认情况下构建上下文是整个目录,如果你把node_modules、.git、构建产物都塞进去,构建上下文可能几百兆,每次构建光是传输上下文就要等很久。而且把本地已经装好的依赖COPY进镜像,会把本机平台的二进制文件带进去,在别的架构上直接跑不起来。
3.2 基础镜像选型:小巧不等于合适
几乎每篇入门文章都会推荐 alpine 作为基础镜像,理由是体积小。但我踩过不止一次这个坑,所以现在选基础镜像会先问几个问题。
第一个问题是 libc。alpine 用的是 musl 而不是 glibc,绝大部分纯静态编译的程序没问题,但一旦涉及需要 glibc 的预编译二进制、某些语言的 C 扩展、或者依赖 glibc 特定行为的东西,就会出各种莫名其妙的错误。表现形式往往是"找不到某个 so"或者"段错误",排查起来相当费劲。如果项目里用到了预编译的第三方二进制,我会优先选 slim 版本而不是 alpine。
第二个问题是调试工具。alpine 精简到什么程度呢,连bash都没有,你进容器排查问题的时候面对一个sh,很多脚本跑不了,netstat、curl也不一定有。这在线上救火的时候会非常难受。如果容器是那种"平时不出事、出事就要马上查"的角色,我会偏向稍大一点的镜像,或者至少装一个curl和bash。
第三个问题是标签策略。永远不要用latest。latest的语义是"最近推送的那个",它会在你不知道的时候变,某天重新构建就突然跑不起来了。用具体的版本号,最好是精确到小版本加发行版标识的那种,比如带上-alpine、-slim后缀,还要定期主动升级而不是被动等它坏。我一般会在项目里建一个统一记录基础镜像版本的地方,升级的时候集中改,避免各服务版本散乱。
| 基础镜像类型 | 体积量级 | 适用场景 | 主要风险 |
|---|---|---|---|
| 完整发行版 | 大 | 需要完整工具链、老项目迁移 | 体积大、攻击面大 |
| slim 精简版 | 中 | 大多数通用服务 | 部分工具缺省 |
| alpine | 小 | 静态编译的简单服务 | libc 差异、调试困难 |
| distroless | 极小 | 安全要求高的生产服务 | 完全无法进入排查 |
| scratch | 最小 | 单一静态二进制 | 无 shell、无证书 |
3.3 多阶段构建:把编译器和源码留在上一站
多阶段构建是我认为近几年最值得掌握的一个技巧,因为它同时解决了体积和安全两个问题,而且改动成本极低。
思路很简单:一个 Dockerfile 里写多个FROM,每个FROM开始一个新阶段,后面的阶段可以从前面的阶段拷贝产物。典型结构是:第一个阶段装全套编译环境、编译、产出二进制;第二个阶段只放运行时的最小依赖,把二进制拷进去。
# 构建阶段:有编译器、有源码、有开发依赖 FROM demo-builder:1.0 AS builder WORKDIR /src COPY . . RUN make build # 运行阶段:只有运行时和产物 FROM demo-runtime:1.0 WORKDIR /app COPY --from=builder /src/out/server /app/server USER 10001 CMD ["/app/server"]这么写的好处,体积上可能是从一个带完整工具链的几百兆变成几十兆;安全上,源码、编译工具、包管理器的缓存全都不在最终镜像里,攻击面大幅缩小。而且因为最终镜像里连 shell 都可能是没有的,进入容器执行命令这条路被自然堵死,客观上减少了误操作。
有个小细节值得注意:COPY --from的目标文件权限会被保留,如果构建阶段编译出来的二进制可执行位不对,运行阶段会直接报权限错误。另外如果运行阶段的基础镜像用户 ID 和构建阶段不一致,拷进来的文件属主可能是个不存在的高位 UID,这个在非 root 运行的场景下要留意。
4. 端口映射、bridge 网络与容器间寻址
容器跑起来之后,第二个必然遇到的问题就是"怎么访问"。这块的困惑大多来自一个误解:很多人以为-p 8080:80是把容器的 80 端口"改"成 8080。其实不是,容器里面该监听 80 还是监听 80,变的只是宿主机这一侧。
4.1 一条请求从宿主机到容器走过的路
当你执行docker run -p 8080:80 demo-web:1.0,实际发生的事情大致是这样的:容器被接入一个虚拟网桥,分到一个内部地址;宿主机上配置了一条转发规则,把到达宿主机 8080 端口的流量目标地址改写成容器的内部地址和 80 端口。所以对外看来是宿主机在监听 8080,实际上请求被转进了容器。
理解这条链路之后,几个常见现象就顺理成章了。
容器内应用必须监听0.0.0.0而不是127.0.0.1。如果应用只监听回环地址,从外面转进来的请求目标地址是容器的网卡地址,不是回环,所以连不上。这个坑极其常见,表现是"端口映射配置没错、容器也起来了、就是访问不了",很多人会去查防火墙,其实是应用绑定地址的问题。
EXPOSE只是个声明。它写在 Dockerfile 里,作用是告诉使用者和工具"这个镜像打算用这个端口",但它并不会真的把端口开放出去。真正生效的是运行时-p或者--network host。我见过有人在 Dockerfile 里写了EXPOSE就以为能访问了,白折腾半天。
同一宿主机上端口会冲突。两个容器都想映射宿主机的 8080,第二个起不来。测试环境里这种冲突特别常见,我一般会给开发环境分配固定的端口段,避免大家随手写-p 8080:8080撞车。如果只是自己临时跑一下,用-p 127.0.0.1::80这种随机宿主端口的方式更省心,跑完docker port查一下分到了哪个。
4.2 自定义网络里的名字解析
默认的 bridge 网络上,容器之间只能用 IP 互相访问,而且 IP 在容器重启后可能变,非常不可靠。正确做法是自建一个自定义网络,把需要互相访问的容器接进去。
docker network create demo-net docker run -d --name api --network demo-net demo-api:1.0 docker run -d --name web --network demo-net -p 8080:80 demo-web:1.0接进同一个自定义网络的容器,可以直接用容器名当主机名访问,比如web里的应用配置后端地址写成http://api:8080就行。这个解析是由网络内的解析服务提供的,容器重启换 IP 也不影响。
这个能力在多容器项目里非常关键,因为它让配置彻底摆脱了 IP。配合 compose 使用的时候,服务名自动就是主机名,几乎不用额外配置。我现在的习惯是:只要有两个以上的容器需要互相通信,第一时间建自定义网络,绝不用默认 bridge 加手工 IP。
还有一个细节:默认 bridge 上的容器互相之间是通的(受老版本影响),而自定义网络默认只允许同网络内容器互访,隔离性更好。这也是我倾向自定义网络的原因之一。
4.3 "连不上"的排查顺序
端口和网络问题是最容易浪费时间的一类,因为现象都一样——连不上。我摸索出一套固定顺序,基本能在几分钟内定位。
第一步,确认容器在跑且没有反复重启。docker ps看状态,如果STATUS一直在Restarting,那根本不是网络问题,先看日志。第二步,从宿主机内部访问转发端口,curl 127.0.0.1:8080。这一步能区分是"转发没配好"还是"应用本身没起来"。第三步,进容器内部访问自己,docker exec -it web sh然后curl 127.0.0.1:80,这一步验证应用是否真的在监听。第四步,确认监听地址,看是不是绑在回环上。第五步,跨容器访问,从另一个容器里用服务名去连,验证网络和解析。
这套顺序的核心逻辑是"从内到外逐层剥离",每一步排除掉一类可能性,而不是凭感觉乱试。我见过太多人一上来就查防火墙规则,其实问题出在应用压根没起来。
5. 数据落在哪:卷、绑定挂载与那些悄悄消失的文件
关于容器数据,我只有一个核心观点:凡是需要活过容器生命周期的数据,都不要放在容器里。这句话说起来简单,但"哪些数据算需要活过"这件事,很多团队是在丢过一次数据之后才想明白的。
5.1 volume 与 bind mount 的取舍
两种挂载方式,语义差别其实很大。
volume由容器引擎管理,存放在一个专门的位置,你在宿主机上不需要关心具体路径。它的好处是跨平台一致、权限模型清晰、备份和迁移有一套标准做法,而且性能在多数平台上更好。缺点是你不能方便地用宿主机的编辑器直接改里面的文件。
bind mount是把宿主机上一个具体路径挂进去。好处是直观、方便本地开发和调试、可以直接看到和修改文件。缺点是强依赖宿主机的目录结构和权限,换一台机器路径可能就不对了,而且容器内进程的写入会直接落到宿主机上,属主问题很容易出现。
我的划分原则是:生产环境用 volume,本地开发用 bind mount。生产环境追求的是可迁移和可管理,volume 明显更合适;本地开发追求的是改完立刻生效,bind mount 更顺手。
| 维度 | volume | bind mount |
|---|---|---|
| 存放位置 | 由引擎管理 | 宿主机任意路径 |
| 跨平台一致性 | 好 | 差 |
| 本地编辑便利性 | 差 | 好 |
| 权限问题 | 较少 | 常见 |
| 适合场景 | 生产数据、数据库 | 本地开发、配置注入 |
有一个例外值得提:配置文件。生产环境的配置文件我一般还是用 bind mount 或者配置管理工具下发,因为改配置不需要重建容器,重启一下就行,比走镜像构建流程快得多。但要注意配置文件如果是只读挂载(加:ro),能防止容器内进程误改,这个后缀我基本都会加上。
5.2 属主与权限:最常见的一类写入失败
"容器起来了,但写文件报 Permission denied",这是挂载相关问题里出现频率最高的一种。
根因通常是 UID 不匹配。宿主机上某个目录属主是 UID 1000,容器里跑应用的用户是 UID 10001,这个用户对这个目录没有写权限,于是失败。反过来也一样,容器以 root 写入,宿主机上看到的文件属主变成一个没有对应用户名的高位数字,后续运维操作各种别扭。
处理方式有几种,各有取舍。
第一种是在镜像里显式创建用户并固定 UID,然后在宿主机上把挂载目录的属主改成同一个 UID。这是最干净的做法,但要求你在部署时多做一步。
第二种是启动时通过环境变量指定运行用户,很多官方镜像支持这种方式,容器启动脚本里会根据环境变量调整权限再降权运行。这种方式灵活,但依赖镜像实现,不是所有镜像都支持。
第三种是用命名卷而不是 bind mount,让引擎自己处理权限。命名卷初始化时会继承镜像里目录的属主,通常能避开这个问题。
注意:不要用"给目录 777 权限"来绕过这个问题。这么做确实能跑起来,但等于放弃了权限隔离,后面会带来更难处理的安全问题。宁可多花十分钟把 UID 对齐。
5.3 备份、迁移与卷清理
卷的生命周期独立于容器,这是好事也是隐患——容器删了卷还在,反过来也意味着你很可能忘了它还在占着磁盘。
查看当前有哪些卷,用docker volume ls,看占用用docker system df -v。清理的时候要非常小心,docker volume prune会删掉所有没有被容器引用的卷,如果你的做法是"停掉容器再备份数据",那这个命令会直接把数据删掉。我一般会先ls出来看清楚名字再一个个删,绝不用批量清理。
备份卷的标准做法是起一个临时容器把卷挂进去,用 tar 打包输出到宿主机:
docker run --rm \ -v demo-data:/data:ro \ -v "$PWD:/backup" \ demo-runtime:1.0 \ tar czf /backup/demo-data.tar.gz -C /data .恢复就是反过来解开。注意这里用了:ro只读挂载源卷,防止打包过程中意外写入。另外打包前最好确认应用没有在写,或者至少保证写入是原子的,不然备份出来的数据可能处于不一致状态。数据库类的卷,更稳妥的方式是先做逻辑备份(导出 SQL),而不是直接打包数据目录的文件。
迁移到另一台机器的时候,通常的流程是:在目标机器上创建同名卷、把备份解进去、用同样的挂载配置启动容器。整个过程不需要关心宿主机路径,这是 volume 相比 bind mount 的一大优势。
6. 资源边界:CPU、内存、健康检查与 OOM
容器默认没有资源上限。这句话的意思不是"它能用多少用多少",而是"它饿的时候会把宿主机上其他东西的资源一起抢走"。单机跑几个容器的时候感觉不到,一旦一台机器上跑十几个服务,一个内存泄漏的服务就能把整台机器拖垮。
6.1 不给上限会发生什么
最直接的后果是故障扩散。某个服务因为流量突增或者内存泄漏吃满了内存,宿主机开始用交换分区,所有容器的响应时间一起恶化,最后整个节点不可用。原本只是一个服务的问题,变成了整台机器的问题。
所以资源限制不是为了"公平分配",而是为了把故障限制在爆炸半径内。一个服务最多吃掉 2G 内存,超了就它自己死,其他服务不受影响。这个思路和电路里的保险丝是一样的。
具体怎么定这个上限,我的做法是先观察再收紧:先不设上限跑一段时间,看监控里的峰值用量,然后在峰值基础上加 30% 到 50% 作为限制值。定得太紧会导致正常波动时被杀,定得太松等于没定。另外要注意内存和 CPU 的处理方式不同:CPU 是可压缩资源,超了只是变慢;内存是不可压缩的,超了只能杀。
6.2 内存超限被杀与退出码 137
容器内存超限时的表现有一定的迷惑性:进程突然消失,日志里没有任何错误,docker ps -a看到状态是Exited (137)。
137 这个数字的来源是 128 加上信号编号,9 号信号是强制终止。所以你看到 137,基本可以断定是被外部强制杀掉的。但"被谁杀"还需要再看一层,是内存超限被系统杀,还是有人手动docker kill。判断方式是用docker inspect看容器的OOMKilled字段,如果是 true,那就确定是内存问题。
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' demo-api找到是内存问题之后,处理方向有两个:要么提高限制值,要么去查为什么内存涨上去了。前者是止血,后者是治本。我一般先临时提上去恢复服务,再安排时间查泄漏,因为内存问题往往不紧急但很隐蔽,容易被一直拖着。
还有一个容易被忽略的细节:某些语言的运行时在容器里读到的可用内存是宿主机的总内存,而不是限制值,于是它会按照宿主机的内存去设置自己的堆大小,结果一启动就超限。较新的运行时大多已经能正确识别控制组限制,但老版本需要显式指定,比如在启动参数里把堆上限设成限制值的百分之七十左右。
6.3 健康检查比"进程还在"更有意义
进程还在跑,不代表服务可用。连接池耗尽、死锁、依赖的下游挂掉,这些情况下进程活得好好的,但所有请求都超时。如果只看进程状态做判断,流量会一直被送到这个已经死掉的服务上。
健康检查解决的正是这个问题。配置方式有几种,如果镜像里没写,可以在运行时用参数指定,也可以编排文件里配置。
healthcheck: test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"] interval: 10s timeout: 3s retries: 3 start_period: 30s几个参数的含义值得说清楚。interval是检查间隔,timeout是单次检查超时,retries是连续失败几次才判定不健康,start_period是启动宽限期,在宽限期内失败不计入次数。start_period这个参数很多人不用,结果应用启动慢一点就被判死,然后被反复重启,进入死循环。
健康检查的接口内容也有讲究。我见过有人把健康检查接口做成"只要能返回 200 就算健康",结果下游挂了它还是返回 200,检查完全没意义。合理的做法是检查关键的依赖是否可用,但同时要注意不要让健康检查本身成为负担——如果每次检查都去查一次数据库,那在高频检查下就是个额外压力。折中方案是检查一个轻量的、有缓存的内部状态。
7. 上生产前必须补的几课:日志、PID 1、时区、非 root
容器能跑起来和能在生产环境跑住,中间隔着好几件事。这几件事在本地开发时基本不会暴露,上线之后会一个接一个冒出来,而且往往是在最不方便处理的时候。
7.1 日志驱动与磁盘被写满
默认的日志驱动会把容器的标准输出和标准错误写到宿主机上的文件里,而且默认没有大小限制。这意味着一个打日志很凶的服务,跑上几天就能把磁盘写满,然后整台机器上的所有容器一起出问题。
这个问题的隐蔽性在于,它出问题的速度和业务量正相关,业务越好越早爆。我现在的习惯是,任何长期运行的服务,日志配置都默认带上轮转策略:
docker run -d \ --log-driver json-file \ --log-opt max-size=50m \ --log-opt max-file=5 \ demo-api:1.0这样单个容器的日志最多占 250M 左右,超了自动滚动删旧的。更大的部署规模下,通常会把日志直接送到集中式收集系统,宿主机上只留很短的缓冲,避免本地磁盘成为瓶颈。
还有一个实践上的建议:应用的日志不要写到容器内的文件里。写文件会让可写层膨胀,而且容器一删就没了。标准做法是写到标准输出,交给外部的日志系统处理。这不只是为了容器,本身也是十二要素应用的基本要求——把日志当成事件流而不是文件。
7.2 PID 1 收不到信号,容器停不干净
这个问题稍微有点绕,但很值得搞清楚。
容器里的第一个进程 PID 是 1。在常规的 Linux 系统里,PID 1 有特殊地位,它会忽略那些没有注册处理函数的信号。如果你的应用是直接用CMD ["node", "server.js"]这种形式启动的,那 node 就是 PID 1,多数运行时对 SIGTERM 有默认处理,问题不大。但如果你写的是CMD node server.js(shell 形式),实际执行的是/bin/sh -c "node server.js",sh 是 PID 1,node 是它的子进程。这时候docker stop发的信号会发给 sh,sh 不会转发给 node,于是等十秒超时之后被强制杀掉。
后果就是优雅关闭失效。正在处理的请求被硬切,数据库事务没提交完,缓存没刷出去,各种脏数据问题都从这里来。
解决办法有两个。一是用 exec 形式写启动命令,让应用直接成为 PID 1,并且应用自己要正确处理 SIGTERM。二是在启动时加--init参数,它会注入一个很小的初始化进程作为 PID 1,负责转发信号和回收僵尸进程。
docker run -d --init --stop-timeout 30 demo-api:1.0--stop-timeout也值得一提,默认是 10 秒,如果你的应用需要更长时间来处理完存量请求,要显式调大,且要和应用内部的关闭超时对齐。我见过应用自己设了 60 秒优雅关闭,但容器 10 秒就被强杀,前面的配置完全没生效。
7.3 时区、编码与非 root 运行
时区这个问题特别典型。容器镜像默认通常是 UTC,日志时间戳、业务逻辑里的日期计算全按 UTC 走,和本地时间差几个小时,排查问题时对着日志算时间能算崩溃。解决方式是在构建时设置TZ环境变量并确认镜像里有时区数据,或者在运行时通过环境变量注入。注意仅仅设置TZ环境变量是不够的,如果镜像里没有对应的时区数据文件,设置也不会生效,精简镜像里这种情况很常见。
编码问题出现频率低一些,但一旦出现就很难查。默认字符集不是 UTF-8 的时候,中文会变乱码,某些字符串处理函数的长度计算也会出错。稳妥的做法是在镜像里显式设置语言环境,确保是 UTF-8。
非 root 运行是我认为最应该尽早做的一件事。默认容器内进程以 root 运行,一旦应用有文件上传、命令执行之类的漏洞,攻击者拿到的就是容器内 root,配合某些配置不当还可能影响到宿主机。改造方式是在 Dockerfile 里创建用户、切换用户,并在文件权限上配合。
RUN addgroup -g 10001 app && adduser -u 10001 -G app -D app COPY --chown=app:app ./dist /app USER 10001改成非 root 之后会遇到两类问题:一是某些端口(小于 1024)不能直接监听,需要改端口或者加权限;二是挂载目录的写入权限需要提前对齐。这两个问题在开发阶段改比上线后改成本低得多,所以我的建议是新项目一开始就非 root,别等到后来再补。
8. 容器起不来时,我按这个顺序查
排查这件事,方法比经验重要。经验只能覆盖你见过的场景,方法能覆盖你没见过的。我这些年下来形成了一套固定顺序,基本遵循"从最可能到最不可能、从信息量最大到最小"的原则。
8.1 第一步永远是状态和日志
docker ps -a看状态,这一步几乎能排除掉一半的可能性。状态是Exited (0)说明进程正常退出了,大概率是启动命令写错、或者进程本身就是一次性任务;Exited (1)一般是应用自身报错;Exited (137)是被强杀,往内存或手动停止方向想;Exited (139)是段错误,往二进制兼容性方向想;Restarting说明在反复重启,通常是启动就崩。
然后看日志,docker logs --tail 100 容器名。如果日志为空,说明进程可能压根没启动,这时候要看的是容器创建阶段的错误,用docker inspect看配置。如果日志有内容但看不出问题,可以试试--since限定时间范围,避免被历史日志淹没。
这里有个小技巧:如果容器启动后立刻退出,日志又很短,可以把启动命令临时改成sleep infinity或者sh,让容器保持运行,然后exec进去手动跑一遍启动命令。这样你能看到完整的交互式输出,比看日志直观得多。
8.2 第二步进容器里手动复现
能进容器的话,很多问题当场就能定位。进去之后我一般按这个顺序看:应用的工作目录在不在、配置文件在不在、文件权限对不对、环境变量有没有正确注入。
docker run --rm -it --entrypoint sh demo-api:1.0用--entrypoint覆盖启动命令,能绕过原本的启动逻辑直接进 shell。进去之后手动执行一遍CMD里的那条命令,看报什么错。这一步的威力在于它能区分"环境问题"和"应用问题"——同样的命令在容器里能跑,说明环境没问题;跑不了,错误信息会直接指向缺什么。
如果镜像里连 shell 都没有(distroless 或者 scratch),那就得用别的方法,比如临时基于同一基础镜像起一个带 shell 的容器,把卷挂载进去观察。这也是我前面说"生产镜像可以极简,但要留一条调试通路"的原因——完全无法进入的镜像,出问题时的排查成本会高很多。
8.3 最后才是网络、资源和运行时环境
应用能在容器里手动跑起来,但通过正常方式起不来,这时候再往外部条件上找。
网络方面,检查端口是否被占用、应用绑定的地址是不是0.0.0.0、跨容器访问时是否在同一网络里。资源方面,检查内存限制是否过紧、CPU 配额是否过低导致启动超时、磁盘是否满了。运行时环境方面,检查挂载的卷是否有写权限、依赖的服务是否可达、DNS 解析是否正常。
磁盘满这件事值得单独说一句,因为它伪装得很好。容器创建时会报各种奇怪的错误,比如"no space left on device",但业务日志里看不出来。docker system df一眼就能看出来,养成定期看的习惯能省不少事。
提示:排查时尽量保留现场。不要在没搞清原因之前就
docker rm然后重跑,很多时候重启之后问题消失了,但根因还在,过几天换个形式再来一次。先docker inspect把配置存下来,再看日志,实在要重建也留一份备份。
这套顺序的核心是用最小成本获取最大信息量。状态和日志几秒钟就能看,能排除大半问题;进容器复现需要一分钟,能定位到具体缺什么;网络和资源排查最慢,所以放最后。按这个顺序走,绝大多数问题都能在五分钟内找到方向,而不是漫无目的地翻文档。
9. 我个人在长期使用中沉淀下来的几条习惯
写到这里补充几条不成体系但很实用的习惯,都是踩过坑之后固化下来的。
镜像标签永远带具体版本,部署永远用摘要。标签是可以被覆盖的,同名标签在仓库里重新推一次,内容就变了,而你本地可能还有缓存,导致两边跑的不是一个东西。对稳定性要求高的场景,部署时用镜像摘要而不是标签,能确保拿到的是完全确定的那一份。
构建产物不要靠人工同步。构建镜像这件事要么在 CI 里做,要么本地做完立刻推送,绝不要出现"本地构建了一个版本,部署时用的是另一个版本"这种情况。我见过最离谱的一次是排查了半天线上问题,最后发现线上跑的镜像和仓库里的代码差了两个版本。
给每个容器加上描述性标签。用--label记录负责人、所属项目、代码仓库位置之类的信息。容器数量一多,光看名字根本不知道是干什么的,尤其是有历史遗留的时候。这些标签在后续做清理和审计的时候能救命。
定期清理,但不要自动清理。悬空镜像、停止的容器、没被引用的卷、构建缓存,这四类东西会持续占用磁盘。定期看一眼docker system df,手工清理确认过的东西。自动清理脚本很容易在某个深夜把你还需要的东西删掉,恢复成本远高于省下来的那点磁盘。
先把本地开发跑顺,再谈生产。我见过不少团队一上来就搞编排系统,结果连单个容器的构建和运行都没摸透,出问题的时候完全无从下手。容器这套东西是分层的,下面那层不扎实,上面那层只会放大问题而不是掩盖问题。单个容器能稳定构建、能正确挂载、能优雅停止,这些基础打好了,往上加编排才是顺水推舟的事。