1. 容器不是轻量虚拟机:先给心智模型纠偏
1.1 一切从进程说起:容器本质上是个普通进程
我在带团队的时候经常做一个测试:让刚接触容器的同学用 ps -ef 看一眼宿主机,再对比 docker ps 列出的容器列表。大多数人会在这一刻愣住——容器里跑的进程,居然在宿主机的进程表里直接可见。
这个现象就是理解容器原理的第一把钥匙。容器不是一个个小型的、独立的操作系统实例,它就是宿主机上的普通进程。所谓“容器”,只是进程外面的那层包装,让这组进程看起来像生活在一个独立的世界里。你写的 Java 服务、Nginx、Redis,进了容器之后依然是进程,照样遵循 fork、exec、exit 这一套 Linux 进程生命周期。
既然容器是进程,那么容器之间、容器与宿主机之间的边界从哪里来?答案是两种内核机制:Namespace 负责让进程“看不见不该看的”,Cgroups 负责让进程“用不了不该用的”。前者解决隔离问题,后者解决资源限制问题。这两个机制组合起来,就构成了绝大多数人理解的“容器”。
很多人习惯把容器类比成“轻量虚拟机”,这个类比能帮你快速上手,但一旦进入排障、优化、安全评估这类进阶场景,这个类比就会变成阻碍。因为虚拟机里排查问题的思路是“登录到另一台机器”,而容器里排查问题的思路是“找到宿主机上的某个进程,再想办法进入它的视角”。前者是跨机器,后者是跨视图。
1.2 隔离不是虚拟化:内核特性的组合拳
虚拟化的本质是模拟硬件。虚拟机里跑着一个完整的 Guest OS,这个 Guest OS 有自己的内核,通过 Hypervisor 与宿主机物理硬件打交道。Hypervisor 是位于内核之下的一层软件,它拦截和模拟 CPU 指令、内存地址、中断、I/O 设备访问。这种全栈模拟换来的是强隔离,但付出的代价是性能损耗和资源开销——每个虚拟机都要带上整份内核和全套系统组件。
容器完全没有模拟硬件这一层。容器里的进程直接运行在宿主机内核上,没有所谓的 Guest OS,也没有 Hypervisor 这个中间层。Docker 镜像里带的那些 /bin、/lib、/etc 文件,只是给进程提供一套“看起来完整的根文件系统”,并不参与内核运行。
用一个生活化的例子来说:虚拟机是搭了一栋带独立水电的小房子,你住进去就和外界完全隔开,但盖房子成本很高;容器是集体宿舍里的一个个隔间,隔板保证你看不见隔壁,但走的是同一套水电管道。隔板就是 Namespace,管道的水电配额就是 Cgroups。隔板只是视觉和感知上的隔离,管道却确定性地决定你这间房能开几盏灯。
所以容器技术本身并不是什么全新的“虚拟化技术”,它是 Linux 内核里一系列隔离和资源管理特性的重新组合和产品化。这个认知会直接影响你对容器安全性的判断:虚拟机之间的边界是硬件虚拟化,非常硬;容器之间的边界是内核特性,一旦内核有漏洞,边界可能被穿透。
1.3 为什么这个区别这么重要
理解“容器是进程”这件事,最直接的好处是排障思路彻底变了。我见过太多同事在一个容器里用 top 看到 CPU 占用居高不下,然后一头雾水不知道该怎么办。实际上,top 在容器里显示的很多指标来自 /proc,而 /proc 里看到的数据和宿主机视角会存在差异。正确做法是回到宿主机上用 pidstat、perf、strace 这类工具直接观察那个进程。
再比如网络问题。既然容器共享宿主机内核,那么容器的 Network Namespace 本质上就是内核里一套独立的网络栈视图——独立的网卡、IP、路由表、防火墙规则。localhost 在容器里指向的是它自己那套网络栈,不是宿主机。很多“容器里访问不到宿主机服务”的案例,追根溯源都是对这套网络视图理解不到位。
整个进阶系列我会带着你沿着这条“进程视角”的主线往前走:先弄清楚进程是怎么被关进容器的,再看资源是怎么被限制的,最后拿这套认知去解释找上门的各种容器故障。这一篇先讲清楚最基础的两块地基——Namespace 和 Cgroups,以及和它们强相关的镜像存储。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Namespace 机制:容器“看起来独立”的第一层保障
2.1 八类 Namespace 各自管什么
Namespace 是 Linux 内核提供的一种资源隔离视图。每一种 Namespace 负责隔离一类全局资源,让处于不同 Namespace 的进程看到彼此独立的资源副本。从容器技术的角度,常用的 Namespace 一共有八类,我整理了一张表:
| Namespace 类型 | 隔离的资源 | 容器中对应的效果 |
|---|---|---|
| Mount | 文件系统挂载点 | 容器里 mount、umount 不影响宿主机 |
| PID | 进程列表 | 容器内 PID 从 1 开始,看不到宿主机其他进程 |
| Network | 网络栈(网卡、路由、iptables) | 每个容器有独立 IP 和端口空间 |
| UTS | 主机名和 NIS 域名 | 容器里的 hostname 和宿主机不同 |
| IPC | System V IPC 和 POSIX 消息队列 | 容器间默认不共享 IPC 资源 |
| User | 用户和用户组 ID | 容器里的 root 可以映射为宿主机上的普通用户 |
| Cgroup | Cgroups 根目录视图 | 容器内看到的 cgroup 路径是经过重映射的 |
| Time | 系统启动时间和单调时钟 | 可让容器内看到独立的时钟偏差(Linux 5.6+) |
在 Docker 默认启动一个容器时,它会为容器创建前述大多数 Namespace(Time 命名空间默认不启用,User 命名空间通常需要显式开启daemon配置)。正是这几层视图叠加,才让容器里的进程误以为自己独占了一台机器。
从内核实现来看,每种 Namespace 都对应内核里的一份数据结构,进程通过 struct nsproxy 指针引用这些结构体。同一容器内的所有进程共享同一组 Namespace 对象,这就是 docker exec 能进入同一个容器空间的原因——它调用了 setns() 系统调用,把自己加入目标进程对应的那组 Namespace。
2.2 clone、unshare、setns:跟 Namespace 打交道的三个入口
内核给用户态提供了三个操作 Namespace 的系统调用,理解它们就能理解容器的生命周期。
第一个是 clone。创建新进程时,clone 可以按位传入各种 CLONE_NEW* 标志。比如传入 CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS,新进程就会拥有一套全新的 PID、网络和挂载视图。这种“创建时指定隔离”的方式对应的是 docker run 时创建容器的动作。runc 在启动容器时就是调用 clone 并传入了一系列 CLONE_NEW* 标志。
第二个是 unshare。它允许一个已经存在的进程脱离当前的某些 Namespace,创建新的隔离视图。这个过程不会创建新进程,只改变当前进程的 Namespace 归属。unshare 在容器里常用于让某个进程或脚本在独立的 Mount Namespace 中运行,避免挂载操作影响容器其他进程。
第三个是 setns。它让调用进程加入某个已经存在的 Namespace,这也是 docker exec、nsenter 这类工具能够进入容器执行命令的底层依据。nsenter 是排查容器问题时的神兵利器,举一个实操场景:某个容器网络异常,docker exec 都进不去时,你可以找到容器主进程的 PID,然后执行:
bash复制nsenter -t <PID> -n ip addr
nsenter -t <PID> -n route -n
nsenter -t <PID> -m mount | grep overlay
-t 指定目标进程 PID,-n 进入网络视图,-m 进入挂载视图。这条命令直接把你带进容器的视角,比 docker exec 更底层,很多容器假死状态下也能用。我遇到容器卡死的时候,第一反应不是重启容器,而是先 nsenter 进去看系统状态——因为重启会丢掉现场。
2.3 从 UTC 时间问题反推 UTS/Mount Namespace 的实现细节
热搜词里有一个非常经典的容器问题:为什么我的 Docker 容器时间是 UTC?这个问题其实和 UTS Namespace 关系不大,反而能串联起 Mount Namespace 和进程环境的知识点。
UTS Namespace 隔离的是 hostname,不包含时区。时区信息在 Linux 上由两个层面决定:一是 /etc/localtime 文件(通常是指向 /usr/share/zoneinfo/ 下某个时区文件的软链),二是 TZ 环境变量。
Docker 镜像默认从官方基础镜像构建,官方镜像里 /etc/localtime 指向的是 UTC 时区文件。宿主机时区设置不会自动传到容器里,因为镜像拥有独立的根文件系统——这正是 Mount Namespace 的效果:容器看到的是镜像自带的 /etc/localtime,而不是宿主机的。
弄明白这个原理之后,解决方案就非常清晰了。如果你需要容器使用宿主机时区,有两种常见做法。第一种是启动时设置环境变量:
bash复制docker run -e TZ=Asia/Shanghai your-image
但这个方法有一个前提:镜像里必须安装了 tzdata 包,否则 glibc 找不到对应的时区数据文件。很多基于 Alpine 的精简镜像不带 tzdata,环境变量设了也不生效。
第二种方案是启动时挂载宿主机时区文件:
bash复制docker run -v /etc/localtime:/etc/localtime:ro your-image
这种方案本质上是用宿主机文件覆盖容器镜像里的对应文件——利用了 Mount Namespace 里挂载点覆盖的特性。注意容器内的 /etc/localtime 仍然保持着镜像原有的软链关系,即使覆盖挂载后其链结指向宿主机的时区文件路径,由于 glibc 解析时会读取目标文件内容,所以能够正常工作。不过更稳妥的做法是挂载时同时确认容器内该路径存在且不被其他进程频繁改写。
这里插一句个人经验:如果容器只是临时排查问题用,直接挂载 /etc/localtime 最省事。如果是生产环境的服务镜像,我倾向于在 Dockerfile 里安装 tzdata 并设置 ENV TZ=Asia/Shanghai,让镜像自包含时区配置,不依赖宿主机环境——毕竟容器可能被调度到任意一台节点上。
3. Cgroups:让容器“规规矩矩”的第二层约束
3.1 没有 Cgroups 时,容器会变成什么样
Namespace 做到了“看不见”,但它管不住“用得过多”。如果只靠 Namespace 跑容器,你会看到这样的景象:容器 A 里有个进程在跑无限循环,CPU 被打满,宿主机上其他服务全部卡顿;容器 B 一次性申请了大量内存,触发 OOM,内核开始随机杀进程——被杀的可能不是容器 B 自己的进程,而是宿主机上其他服务的进程。
之所以会这样,是因为进程没有资源配额的概念。内核只负责调度 CPU 周期、分配内存页,它不知道也不关心进程属于哪个容器。所以容器化平台必须给内核补充一份“资源使用预算表”,告诉内核某组进程最多可以用多少 CPU、多少内存、多少磁盘 I/O。这份预算表就是 Cgroups 提供的。
Cgroups 更准确的名字是控制组(Control Groups),核心能力是把一组进程圈起来,然后对这组进程的资源使用进行限制、统计和优先级的控制。从运维的角度看,没有 Cgroups 的容器是危险的,它在生产环境里会引发各种连锁事故。
3.2 Cgroups v2 的核心设计:从碎片化到统一层级
Cgroups 经历了从 v1 到 v2 的演进,理解这个演进对排查资源相关的问题很有帮助。
在 v1 时代,每种资源控制器各自挂载一个独立的层级(hierarchy)。比如 CPU 控制器挂载在 /sys/fs/cgroup/cpu,内存控制器挂载在 /sys/fs/cgroup/memory,IO 控制器挂载在 /sys/fs/cgroup/blkio。一个进程如果要同时受 CPU 和内存限制,就必须同时加入两个不同层级里的两个不同 cgroup 节点。这种“一个进程有多个 cgroup 归属”的设计,在多级树状嵌套时很容易出现混乱——比如嵌套两层 cgroup 后,某个 cgroup 包含的进程集可能和其他 cgroup 不完全对齐,资源统计变得难以理解。
Cgroups v2 把这些分散的层级统一成一个树状结构,所有控制器都挂在同一个层级下。每个 cgroup 节点都是一个目录,控制器的配置文件直接出现在目录内部。现代 Linux 发行版跑 systemd 时默认启用 cgroups v2,路径通常在 /sys/fs/cgroup/。你在宿主机上执行:
bash复制cat /sys/fs/cgroup/cgroup.controllers
就能看到当前系统支持的控制器列表。Docker 开启容器后,容器内的进程会被塞进 docker 这个子树下的一个子 cgroup 里,文件路径类似 /sys/fs/cgroup/docker/<container-id>。
v2 里有两个概念需要特别留意:一个是 subtree_control,它决定了哪些控制器可以在子目录中生效。父目录必须先把某个控制器写入 subtree_control,子目录才能真正使用这个控制器。另一个是 “no internal process constraint”,意思是如果一个 cgroup 目录下要启用控制器,那么这个目录本身不能直接包含进程,进程必须放在更下层的子 cgroup 里。这个设计保证了树状结构和配置的清晰性。
3.3 CPU 和内存限制背后的计算逻辑
既然容器就是一组进程,容器层面的资源限制必然映射到 Cgroups 的参数上。Docker 的 --cpus、--memory 参数,最终都会落到 cgroup v2 的配置文件中。
先看 CPU。cgroup v2 中 CPU 限制主要靠 cpu.max 文件,格式是 <quota> <period>,例如 50000 100000。period 是调度周期,单位是微秒,100000 微秒等于 100 毫秒;quota 是这个周期内允许使用的 CPU 时间。50000 100000 表示每 100 毫秒最多用 50 毫秒 CPU 时间,相当于占用 0.5 个核。Docker 的 --cpus=0.5 设置的就是这一组参数。还有一个 cpu.weight 文件,它不做硬限制,只控制 CPU 时间分配的比例权重,对应 Docker 的 --cpu-shares。
从机制层面看,CPU 限制的实现依赖内核 CFS(完全公平调度器)。当 cgroup 里的进程组用完 quota 之后,CFS 会对这个组做 throttle,强制它暂停执行,直到下一个调度周期开始。很多容器应用觉得“变慢了”,但不一定 CPU 很高,可能就是因为频繁被 throttle。排查时可以看看 /sys/fs/cgroup/cpu.stat 里的 throttled_usec 数值,如果这个值持续快速增长,说明 CPU 配额给得不够。
再看内存。cgroup v2 中内存限制主要靠 memory.max,格式是字节数,例如 docker run --memory=512m 会设置 memory.max 为 536870912。当进程组内存使用量超过这个上限时,内核会尝试回收此 cgroup 内的匿名页和文件页以腾出空间;如果回收后仍超限,cgroup 内的进程会被 OOM Killer 选择性地杀死(取决于 memory.oom.group 和进程的 oom_score)。
和生产环境强相关的另一个参数是 memory.high,它不像 memory.max 那样是硬限制,而是软限制。超过 memory.high 后内核会积极回收内存,让进程进入直接回收导致的延迟中。在容器场景里,这个参数很容易被忽视,但它对内存申请突发型应用特别重要——比如一个 Java 应用启动时堆大小暴涨,memory.max 定得死死的话直接 OOM,而配合 memory.high 可以让内核更早介入回收,平滑过渡。
Docker 的 --memory-swap 参数也值得解释。它表示允许容器使用的内存加交换分区的总量。如果只设 --memory=512m 而不设 --memory-swap,默认 swap 上限和内存上限相同,也就是说容器最多可用 1G 内存(512M RAM + 512M swap)。这个默认行为恰恰是很多线上诡异的性能问题的来源——容器明明没超过内存限制,却一直在用 swap,慢得离谱。
4. 镜像分层与容器可写层:文件从哪来,写到哪里去
4.1 镜像分层的实现原理:不是简单的文件复制
讲完进程的资源边界,再看文件系统。镜像为什么要分层设计?最直接的原因是复用。多个镜像基于同一个基础镜像构建时,如果每个镜像都完整复制一份全部文件,那几百上千个容器跑在同一台宿主机上,磁盘空间早就爆炸了。
现代容器运行时几乎都采用 OverlayFS 或类似实现,这本身就是一种叠加文件系统。Docker 镜像由若干只读层堆叠而成,每一层代表构建过程中的一次文件系统变更,对应 Dockerfile 里的一条指令。最终运行时会在镜像层之上再加一个可写层,然后合并所有层形成容器内看到的完整根文件系统。
在宿主机上执行 mount,你会看到类似这样的输出:
bash复制overlay on /var/lib/docker/overlay2/xxx/merged type overlay (rw,lowerdir=...,upperdir=...,workdir=...)
lowerdir 对应镜像的各个只读层,upperdir 对应容器可写层,merged 是容器内进程实际看到的最终视图。这里有个细节值得注意:同一宿主机上多个容器共享基础镜像层,无论你启动了多少个基于相同镜像的容器,底层文件都只存一份。这也是为什么容器启动往往只要几百毫秒——创建容器并不复制文件,只是做了几次目录挂载和资源分配。
4.2 写时复制与容器可写层的生命周期
OverlayFS 的文件读写有一个核心机制:写时复制(Copy-on-Write)。容器内读取文件时,OverlayFS 按 upperdir、lowerdir 的顺序查找。如果要读取的文件在镜像层已经存在,直接读镜像层的文件即可,不产生额外开销。
但如果容器内要修改镜像层里的文件,事情就变了。OverlayFS 不会直接改镜像层的数据,而是先把文件从镜像层完整复制到 upperdir,然后再修改那份副本。这就是“写时复制”名字的由来——只在第一次写入时付出复制成本。
这个机制带来一个非常经典的坑:如果容器内频繁修改镜像层的大文件,比如一个 10GB 的日志文件虽然不是典型的镜像层文件,但假如基础镜像内置了一个大应用包需要 patch,每次启动容器写入时,先把大文件复制到 upperdir 再做修改,瞬时磁盘 I/O 会非常高。如果启动了大量容器同时做这种操作,宿主机的 I/O 压力瞬间就能被拉满。
另一个和可写层相关的问题是容器删除后的数据命运。可写层和容器生命周期绑定在一起,容器一旦被删除,upperdir 里所有新增和修改的文件都会随之消失。这就是“容器重启后数据丢失”现象的根源——不是数据被神秘吃掉了,而是那些文件本来就在容器的可写层里,没有流入数据卷或外部存储。生产环境里的做法应该是:所有需要持久化的数据,通过 volume 或 bind mount 挂载到宿主机、共享存储,而不是依赖容器可写层。
还有一点容易被忽略:镜像层虽然标明“只读”,但层内的文件是可以通过 overlay 机制在容器内看到并且被读取的。如果某个容器以特权模式运行并挂载了宿主机敏感路径,攻击者完全可以读取镜像层内容和宿主机的 /proc、/sys 暴露的信息。所以镜像层“只读”针对的是普通的文件写操作,并不构成安全边界。
4.3 镜像体积、层数与安全:进阶使用者的三个权衡点
关于镜像,很多人的关注点停留在“怎么把镜像变小”,但进阶阶段要权衡的问题远不止体积。
第一个权衡是层数。每一层增加的不仅是硬盘占用,更影响镜像的构建和拉取效率。Dockerfile 里每条 RUN、COPY 都会产生一个新层。最佳实践是把频繁变化的操作放在 Dockerfile 尾部,把较少变化的基础依赖放在前面,这样利用构建缓存时能最大程度复用已有层。但层数也不是越少越好,硬把十个 RUN 合并成一个,虽然镜像变小了,可一旦中间步骤失败,整个层都要重新构建,调试体验极差。工程上建议在合理分层和减少层数之间取平衡。
第二个权衡是基础镜像的更新节奏。很多团队基础镜像几个月不更新,每次构建都基于旧镜像拉新代码,表面上运行正常,实际镜像里已经积累了大量的已知漏洞。镜像扫描工具能扫出这些漏洞,但如果团队没有建立定期更新基础镜像的机制,扫描结果只会沦为一堆无人处理的报告。
第三个权衡是关于容器安全的认知。镜像扫描只能发现镜像文件里的问题,比如被安装的包有已知 CVE、可执行文件权限异常等。但镜像层的生命周期里还有两类风险很难被扫描覆盖:一类是构建层中残留的敏感信息,比如某个 RUN 步骤里下载了私钥又在后续步骤删除,删除操作会产生一个新的只读层,但底层那个含私钥文件的层依然存在,可以被提取出来;另一类是运行时挂载和特权配置带来的风险,镜像本身没问题,容器 run 的时候给了不合理的特权或挂载,才构成实际威胁。
5. 用原理看故障:容器时间、启动失败和编排问题的本质
5.1 容器时间是 UTC?说明你还没理解 init 进程的职责
回到前面那个 UTC 时间问题。我们知道了时区由 /etc/localtime 和 TZ 决定,而这两者都来自镜像而不是宿主机。顺着这个思路再往下问一层:容器里 PID 1 的进程是谁?谁负责在容器启动后执行初始化?
这就引出了容器 init 进程的概念。镜像里如果有 tini 或类似的 init 工具,它会作为 PID 1 运行,负责处理信号转发和回收孤儿进程。如果镜像没有 init 工具,那 PID 1 直接是你的业务进程。很多情况下,容器时间异常和 PID 1 进程的行为没有直接因果,但它会影响整个容器的运行模式,比如进程收到 SIGTERM 之后由谁响应、是不是会被直接丢弃。
时间问题的标准处理步骤通常是这样:先确认宿主机的时间是否正确,再确认容器内 /etc/localtime 的指向,最后检查 TZ 环境变量。如果镜像里根本没有 tzdata,光设 TZ 不生效。在我自己的生产实践中,最省心的做法是直接在 Dockerfile 里固定时区:
dockerfile复制RUN apk add --no-cache tzdata \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone
ENV TZ=Asia/Shanghai
这套做法写进镜像规范之后,团队里再没出现过时区问题。
5.2 一个 Pod 中某个容器启动失败,影响范围取决于什么
在容器编排场景里,热词里那个问题很典型:一个 Pod 中如果包含了多个容器,如果其中一个容器没有成功启动,可能会影响其它容器吗?要回答这个问题,首先得理解 Pod 的本质。
一个 Pod 在实现上并不是“几个容器放在一起”,而是由一个承载性的 pause 容器先启动,创建好 Pod 级别的共享 Namespace(主要是 Network Namespace、IPC Namespace 和 UTS Namespace),然后再启动其他业务容器并让它们加入这些 Namespace。也就是说,Pod 里的容器先共享网络视图和主机名,再各自拥有独立的 Mount Namespace 和 PID Namespace(PID Namespace 是否共享取决于运行时配置,Kubernetes 默认业务容器共享同一个 PID Namespace,注意实际上 Kubernetes 从 1.27 开始启用 Pod 级 PID Namespace 的 Alpha 特性;在更早的版本中默认不共享。让我精确一点:Kubernetes 的容器运行时(如 containerd)在创建沙箱时,Pause 容器持有一组 Namespace,业务容器加入这些 Namespace。但对于 PID Namespace,Kubernetes 默认并不共享,除非显式开启 ShareProcessNamespace 特性;Mount Namespace 始终不跨容器共享。)
有这个背景再看“影响范围”就清晰了。如果某个业务容器启动失败,通常只影响它自己。只要 pause 容器还活着,Pod 的网络栈就在,其他容器可以继续对外服务。Kubernetes 的 restartPolicy 会决定失败容器是否重启,默认的 Always 会让 kubelet 不断重建这个容器。假如失败的是 init 容器(Init Container),情况就完全不同了。init 容器按顺序执行,任何一个失败都会阻塞后续所有业务容器的启动,Pod 会一直处于 Init 状态,其他容器根本没有机会运行。
这里的关键结论是:Pod 的可用性绑定在共享的 Namespace 上,而不是绑定在每一个容器上。如果你想让多个容器互相独立启动失败互不拖累,就保持它们共享 Pod 级 Namespace 即可;如果你需要某个前置逻辑先完成再启动业务容器,那就用 init 容器。这两类容器在失败时的影响范围有本质差别。
5.3 常见容器故障背后的共同根源
总结一些常见的容器故障,你会发现它们都能用前面这套原理解释清楚。
容器重启后数据丢了,根源是数据写在了容器可写层,没有持久化到 volume。你在宿主机上找不到容器里的数据文件,因为它们嵌套在 overlay 的 upperdir 里,容器删除后那一整块 upperdir 被回收。
容器启动报端口占用,根源是 Network Namespace 只在容器创建时分配。同一宿主机上两个容器如果配置了相同的端口映射,宿主机端口确实会被占用,报错很正常。如果改用 host 网络模式,容器直接共享宿主机的网络栈,连 IP 都是宿主机的,这时端口冲突规则就和普通进程完全一致。
容器内 CPU 飙升但 docker stats 显示正常,这个场景比较隐蔽。docker stats 的数据来自 cgroup 里的统计文件 /sys/fs/cgroup/cpu.stat 和 cpuacct,如果你的容器使用了 host 网络或某些共享资源,看起来像是“容器内”的 CPU 使用,实际上可能是宿主机上其他进程在同一个 cgroup 内(比如 Kubernetes 的 pod 级 cgroup 和容器级 cgroup 层级关系),导致统计口径和直观感受不一致。
再比如容器进程“杀不掉”,本质上是 PID Namespace 隔离了信号。PID 1 进程是一种特殊存在:它是当前 Namespace 内所有孤儿进程的收养者,并且默认不处理其他进程发送的信号。如果在容器里 kill -9 1 没有反应,不是因为进程真的无法被杀,而是内核有逻辑保护 PID 1 的特殊性,信号被 PD 伪装忽略:实际上内核通常会忽略来自同 namespace 内非特权进程对 PID 1 的 SIGKILL(在某些场景),或 PID 1 进程自身注册了信号处理但 SIGKILL 是真实不会被忽略的(注意实际上 SIGKILL 在 Linux 中对 PID 1 也是默认无效的,内核专门对 PID 1 有信号屏蔽和处理规则)。这种问题的标准解法是:在宿主机上使用 nsenter 进入容器,或者直接 kill 宿主视角下的容器进程 PID,绕开 Namespace 的信号限制。
| 故障现象 | 根因层面 | 推荐解法 |
|---|---|---|
| 容器重启丢数据 | 可写层生命周期 | 数据挂 volume |
| 容器时间是 UTC | 镜像内时区配置 | 设置 TZ 或挂载 localtime |
| 容器启动端口冲突 | Network Namespace 视图 | 检查宿主端口占用与映射 |
| 容器内 kill 不掉 PID 1 | PID Namespace 信号规则 | 从宿主机 kill 或 nsenter |
| 磁盘占用越来越大 | 可写层和日志文件增长 | 日志外置、定期清理容器 |
我见过不少团队在容器踩坑之后的第一反应是“再加一个工具”或“换一个编排平台”,但其实很多问题回到原理上,一两句话就能说清楚。这并不意味着知道原理就能避免所有坑,而是面对坑的时候能快速判断出它属于哪一类、从哪个方向排查,而不是靠猜。
这一篇先讲到这里,核心是把“容器是进程”这个模型深植到你的思维里,再把 Namespace、Cgroups、镜像分层这三个关键机制串起来。下一篇我会继续沿着这套模型往下走,聊一聊容器网络和数据卷这两个让很多人头疼的话题——网络模型绕不开 CNI 和 iptables,数据卷绕不开挂载传播和权限问题,到时候都是从这一篇的地基上长出来的东西。
