容器到底是什么:namespace + cgroup + overlayfs + capabilities
这是 Linux 系列的第 24 篇——容器与部署章节的第一篇。前面所有铺垫——进程、文件系统、网络、cgroup——都是为了这一篇能讲清楚。
0. 容器不是"轻量 VM"
最大的误解就是把容器想象成"小型虚拟机"。
┌──────────────────────────┐ ┌──────────────────────────┐
│ 虚拟机 (VM) │ │ 容器 (Container) │
│ ┌────────────────────────┐│ │ ┌────────────────────────┐│
│ │ 应用 ││ │ │ 应用 ││
│ │ libs ││ │ │ libs ││
│ │ 完整 Guest OS(含内核) ││ │ │ (没有自己的内核) ││
│ └────────────────────────┘│ │ └────────────────────────┘│
│ ↑ │ │ ↑ │
│ Hypervisor (KVM/Xen) │ │ Container Runtime │
│ ↑ │ │ ↑ │
│ 宿主机内核 │ │ 宿主机内核 ← 共享! │
└──────────────────────────┘ └──────────────────────────┘
容器没有自己的内核——所有容器共用宿主机的 Linux 内核。容器只是一个被关到笼子里的普通进程。
这就是为什么:
- 容器秒级启动(只是 fork+exec);VM 几十秒(要起完整内核)
- 容器密度高(一台机器跑几百个);VM 几十个就到顶
- 容器逃逸危险(一旦从笼子里跑出来直接是宿主机 root);VM 隔离强(要先黑掉 hypervisor)
那这个"笼子"由什么组成?4 块拼图:
namespace(看什么)+ cgroup(用多少)+ overlayfs(站哪里)+ capabilities(能做什么)
↓
容器
下面一块一块拆。
1. namespace:让进程"看到"什么
Linux namespace 是给进程"换个世界观"的内核机制——同一台机器上,不同 namespace 的进程看到的资源是隔离的。
有 8 种 namespace:
| Namespace | 隔离什么 |
|---|---|
| PID | 进程号——容器里的 PID 1 实际是宿主机 PID 12345 |
| NET | 网卡 / 路由 / iptables / 端口 —— 容器有自己的 lo / eth0 |
| MNT | 挂载点 / 文件系统视图 |
| UTS | hostname / domain name |
| IPC | System V IPC / POSIX 消息队列 / 共享内存 |
| USER | uid / gid 映射(容器里的 root 可以不是宿主机 root) |
| CGROUP | cgroup 视图 |
| TIME | (5.6+)时钟偏移 |
实测看一眼
$ docker run -it --rm alpine sh
/ # echo $$
1 # 我是 PID 1
/ # hostname
9f2d4e8c1234 # 容器自己的 hostname
/ # ip addr
1: lo: ...
2: eth0@if89: ... # 容器自己的网卡
/ # ps aux
PID USER TIME COMMAND
1 root 0:00 sh
5 root 0:00 ps aux
# 只看得到自己 + ps
在另一个终端从宿主机看:
$ ps -ef | grep alpine
root 12345 12340 0 ... /bin/sh # 容器里的 PID 1 在宿主机是 PID 12345
$ ls /proc/12345/ns/
cgroup ipc mnt net pid pid_for_children user uts
↑
每个文件对应一个 namespace
容器的进程在内核里跟普通进程没区别——只是它的 /proc/<pid>/ns/ 里指向的 namespace 跟宿主机其他进程不一样。
nsenter 进入别的容器的 namespace(救场神器)
$ docker inspect <container> | grep Pid
"Pid": 12345
$ sudo nsenter -t 12345 -n -p ip addr # 用宿主机的命令进容器的 net+pid namespace
这就是为什么 docker exec 能秒进容器——它本质就是 nsenter + 启一个 shell。
2. cgroup:限制"用多少"
23 kernel-tuning 已经讲过 cgroup。容器层就是把它封装成 API:
# 看 docker 容器的 cgroup 限制
$ docker run -d --name test --memory=256M --cpus=0.5 alpine sleep 999
$ docker inspect test | grep -i 'memory\|cpu' | head
$ cat /sys/fs/cgroup/system.slice/docker-*.scope/memory.max
268435456 # 256MB
$ cat /sys/fs/cgroup/system.slice/docker-*.scope/cpu.max
50000 100000 # 每 100ms 给 50ms
容器进程还是普通进程——只是 cgroup 告诉内核"这个进程的内存别超 256MB / CPU 别超 50%"。
OOM kill 内部也是按 cgroup 的限制触发:
进程内存超 cgroup.memory.max → cgroup 内 OOM kill(不影响 cgroup 外的进程)
这就是为什么"杀容器进程不会拖垮宿主机"——隔离在内核 cgroup 层做了。
3. overlayfs:站在"虚拟文件系统"上
容器有自己的根文件系统(/、/etc、/usr 等),这一份从哪来?答案:镜像分层 + 联合挂载。
镜像层(read-only):
base layer: ubuntu rootfs(200MB)
layer 1: apt install nginx
layer 2: COPY nginx.conf /etc/nginx/
容器运行时层:
rw-layer: 容器写的东西在这里(只在容器活的时候)
实际看到的根 /:
= overlayfs.merge(base, layer1, layer2, rw-layer)
overlayfs 把多层 read-only 镜像 + 一层 read-write 联合起来,让进程看到的就是一个"完整可读写"的文件系统。
# 看 docker 的 overlayfs
$ mount | grep overlay
overlay on /var/lib/docker/overlay2/abc123/merged type overlay (rw,...,lowerdir=...,upperdir=...,workdir=...)
字段:
lowerdir:镜像各层(read-only,多个用 : 分隔)upperdir:容器写的层(read-write)workdir:overlayfs 内部用merged:进程看到的统一视图
写文件的语义(Copy-on-Write):
容器进程写 /etc/nginx/conf
↓ overlayfs 拦截
镜像层里有这个文件吗?
有 → 从镜像层 copy 一份到 upper layer,再写 upper(镜像层不变)
没 → 直接写 upper
读文件:
upper 有 → 用 upper 的
upper 没 → 找下层(镜像层),用第一个找到的
好处:
- 镜像层不变 → 100 个容器都跑同一个 nginx 镜像,磁盘只占 1 份
- 容器写的东西在自己 upper layer → 容器死掉自动清
这就是 Docker 镜像层级 + Dockerfile 每条指令一层的根本原理。
4. capabilities:root 权限的"细分"
传统 Unix 只有"普通用户 vs root"——root 能做一切。这太粗。Linux 把 root 权限切成 38 块叫 capabilities:
| Capability | 干什么 |
|---|---|
CAP_NET_BIND_SERVICE |
绑定 1024 以下端口(如 80) |
CAP_NET_ADMIN |
改网络配置(iptables, ip route) |
CAP_SYS_ADMIN |
几十种系统操作(最危险,叫"new root") |
CAP_SYS_PTRACE |
ptrace 别的进程(gdb / strace 需要) |
CAP_DAC_OVERRIDE |
跳过文件权限检查 |
CAP_CHOWN |
chown |
CAP_KILL |
给任意进程发信号 |
| ... | 还有几十个 |
容器默认只给少数 capability,大幅缩小攻击面:
$ docker run --rm alpine grep CapBnd /proc/self/status
CapBnd: 00000000a80425fb # 默认有这些(少于完整 root)
$ docker run --rm --privileged alpine grep CapBnd /proc/self/status
CapBnd: 000001ffffffffff # --privileged 给所有 capability("伪 root")
最佳实践:
# 给最小权限(推荐)
$ docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
# Kubernetes:
securityContext:
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"]
--privileged 等于把容器跟宿主机的隔离彻底关掉——只在你确实需要操作硬件 / 内核(如调试容器)时用。
5. 把 4 块拼起来:从零造一个"手工容器"
光懂概念不够,亲手做一个:
# 1. 准备一个最小 rootfs
$ mkdir mycontainer && cd mycontainer
$ docker export $(docker create busybox) | tar -xf -
# 2. 用 unshare 起一个新进程到新的 PID/NET/MNT namespace
$ sudo unshare --mount --uts --ipc --net --pid --fork --user --map-root-user \
chroot . /bin/sh
# 进去后看:
/ # ps # 只看到自己(PID namespace 生效)
PID USER COMMAND
1 root /bin/sh
2 root ps
/ # hostname # 默认是 nodename,但你能改不影响宿主
/ # ip addr # 空(没网卡,因为新建的 net ns 还没挂网卡)
这就是一个"裸容器"。Docker 在此基础上又:
- 配 overlayfs 让根目录是镜像层
- 配 cgroup 限制资源
- 配 veth 让容器有网卡
- 起一堆 hook(runtime spec)
核心理解:Docker 没什么魔法——它是 Linux 这堆1990s-2010s 陆续加进来的内核功能的优秀产品化。
6. Docker 软件栈一句话
你的命令 docker run
↓
docker (CLI)
↓ HTTP
dockerd (daemon)
↓ gRPC
containerd
↓
runc ← 真正的"启容器"工具,调 unshare + execve + cgroup
↓
你的容器进程
现代 Kubernetes 直接调 containerd,跳过 docker:
kubelet → CRI(接口)→ containerd → runc → 容器
docker 这个 CLI 还在用是因为开发者用得习惯——生产环境的 K8s 已经不依赖 docker 多年。
7. 几个常见"为什么"被解释
A. 为什么容器逃逸是大事件
容器跟宿主机共享内核。一旦容器进程:
- 拿到
CAP_SYS_ADMIN或--privileged - 内核有漏洞(如脏牛 / Spectre)
- 挂了
/var/run/docker.sock(能控制 docker daemon)
→ 它在宿主机上是 root。这跟 VM 逃逸完全两个量级的难度——VM 要先黑 hypervisor。
这就是为什么 K8s 安全里反复强调 "Pod Security Standards"、"capabilities drop"、"runAsNonRoot"——容器的隔离不像 VM 那么坚固。
B. 为什么 Docker on Mac 那么慢
macOS 没有 Linux 内核——所以 Docker for Mac 里面跑了一个轻量 Linux VM(HyperKit / Apple Virtualization Framework)。
Mac App "Docker Desktop"
└─ Linux VM
└─ dockerd / containerd
└─ 容器
文件挂载从 Mac 到 VM 走 9P 协议(极慢)。macOS 用户做开发要不就 OrbStack 替代 Docker Desktop,要不就直接在云上跑容器。
C. 容器为什么瞬间启动
docker run ubuntu echo hi
↓
1. 拉镜像(已经有就跳过)
2. fork + 配置 namespace + cgroup
3. overlayfs 准备根目录
4. execve("echo", ...)
5. 输出 "hi"
→ 全部 < 100ms
对比 VM:
VM 启动
↓
1. 分配虚拟硬件
2. BIOS / UEFI
3. bootloader 加载 kernel
4. kernel 初始化(设备探测、文件系统挂载……)
5. systemd 起所有服务
6. 终于跑你的程序
→ 几十秒
容器跳过整个内核启动——直接从 fork 开始。这就是为什么"容器实现 serverless / 函数计算"成立。
8. 安全实践速记(生产部署容器必看)
# Dockerfile 起手式
FROM alpine:3.20 # 用小镜像
RUN addgroup -S app && adduser -S -G app app # 建非 root 用户
USER app # 切到非 root
WORKDIR /app
COPY --chown=app:app . .
CMD ["./my-binary"]
# docker run 起手式
docker run \
--rm \
--read-only \
--tmpfs /tmp \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--no-new-privileges \
--security-opt=no-new-privileges \
--memory=512M \
--cpus=0.5 \
--pids-limit=100 \
-u 1000:1000 \
nginx
K8s 里把这些翻译成 securityContext。
9. docker 命令速查(很多人不知道的)
# 看容器进程实际在宿主机的 PID
$ docker top <container>
$ docker inspect <container> -f '{{.State.Pid}}'
# 看容器 namespace
$ docker inspect <container> -f '{{.NetworkSettings.SandboxKey}}'
# 用 nsenter -t <pid> -n 进去
# 看 cgroup 路径
$ docker inspect <container> -f '{{.HostConfig.CgroupParent}}'
# 看 layered overlayfs 路径
$ docker inspect <container> -f '{{json .GraphDriver}}' | jq
# 实时 stats
$ docker stats --no-stream
# 看镜像每层多大
$ docker history <image>
# 进容器(exec 干净退出)
$ docker exec -it <container> sh
# 强制重建(不用缓存)
$ docker build --no-cache -t my .
# 找谁占了磁盘
$ docker system df
$ docker system prune -a # 清理未用的(小心)
10. 现在做一件事
# 1. 跑一个最简单的容器,看它的 namespace
$ docker run -d --name test alpine sleep 999
$ PID=$(docker inspect test -f '{{.State.Pid}}')
$ sudo ls -l /proc/$PID/ns/
# 2. 看容器的 cgroup
$ sudo cat /sys/fs/cgroup/system.slice/docker-*.scope/memory.max 2>/dev/null | head
# 3. 用 nsenter 进去(不用 docker exec)
$ sudo nsenter -t $PID -n -p ip addr # 看容器视角的网络
$ sudo nsenter -t $PID -m ls / # 看容器视角的根目录
# 4. 看容器有哪些 capability
$ docker exec test grep CapBnd /proc/self/status
# 5. 看 docker overlayfs 长什么样
$ docker inspect test -f '{{.GraphDriver.Data.MergedDir}}'
$ sudo ls $(docker inspect test -f '{{.GraphDriver.Data.MergedDir}}')
# 清理
$ docker rm -f test
理解了容器不是黑魔法,你看 K8s / Docker / containerd / Podman 任何一个都顺手——它们都是同一组内核积木的不同摆法。
下一篇(Linux 系列终篇):vps-bootstrap——5 分钟把一台空 VPS 调到"能放心跑业务"的清单。本系列所有内容的实战收官。
$ ls related/
-
日志去哪了:journalctl / syslog / logrotate 一次说清 2026-07-05
Linux 系列第 22 篇。"应用挂了——日志在哪?"——这个问题答案随系统版本和应用类型变化巨大。本篇拆开三条主线:systemd journal、传统 syslog、应用自己的日志文件,并给出 logrotate 配置防止磁盘被日志撑爆(这是雪崩第二大杀手)。
-
机器在干什么:top / vmstat / iostat / strace 的实战分工 2026-07-04
Linux 系列第 21 篇。"服务器卡了"——你想 5 分钟内定位是 CPU / 内存 / 磁盘 / 网络 / 还是某个进程的锅。本篇按"4 个资源维度"拆开常用 10 个观测工具,给一份"USE 方法"清单,让你从慌乱排查变成系统化判断。
-
SSH 不只是登录:tunnel / agent / 跳板机 / config 模板 2026-07-03
Linux 系列第 20 篇。SSH 不仅是"远程登录"——它能转发端口(本地 / 远程 / 动态 SOCKS)、托管密钥、跳板穿透多层内网、批量 push 文件。这一篇把这些高级用法拆开,最后给一份 `~/.ssh/config` 让你 5 倍效率地管理几十台机器。