Kubernetes 调度、重启、扩缩的最小"货物"是容器。如果把容器当成一个黑盒的"轻量虚拟机",后面遇到 OOMKilled、镜像拉取失败、CrashLoopBackOff 时就只能靠猜。这一课把容器拆开来看:它就是一个被隔离、被限制的 Linux 进程,外加一个打包好的文件系统。
学完这一课,你能用 unshare 手工造一个"简陋容器",说清楚镜像分层、OCI、containerd 和 CRI 之间的关系,并写出一个多阶段构建的 Dockerfile,理解镜像 tag 和 digest 的区别。
为什么需要容器
传统部署方式里,一台服务器上跑着多个应用,它们共享同一套系统库、同一个文件系统、同一个网络栈,常见问题包括:
- 依赖冲突:应用 A 需要 Python 3.9,应用 B 需要 Python 3.12。
- 环境漂移:开发机上能跑,测试环境缺个库就跑不起来。
- 互相干扰:一个应用内存泄漏,把整台机器拖垮。
虚拟机能解决隔离问题,但每个虚拟机都要带一整个操作系统内核,启动以分钟计、开销以 GB 计。容器走了另一条路:共享宿主机内核,只隔离进程能看到和能用到的东西。启动以毫秒计,开销几乎等于进程本身。
容器 = 进程 + 隔离 + 限制
Linux 内核提供了两类机制,容器运行时只是把它们组合起来:
| 机制 | 解决的问题 | 例子 |
|---|---|---|
| Namespace(命名空间) | 进程能看到什么 | 独立的进程号、主机名、网络、挂载点 |
| cgroup(控制组,Control Group) | 进程能用多少 | 最多 256 MB 内存、0.5 个 CPU |
再加上一个独立的根文件系统(来自镜像),就构成了一个容器。
Namespace:隔离视图
Linux 目前有 8 种 Namespace,最常用的是:PID(进程号,容器里第一个进程 PID 为 1)、Mount(挂载点)、UTS(主机名)、Network(网卡、IP、路由、端口)、IPC(进程间通信)、User(用户 ID 映射),另外还有 Cgroup 和 Time。
先看看当前 shell 所在的 Namespace,每个链接后面的数字就是 Namespace 的 inode 编号:
$ ls -l /proc/$$/ns | head -4
lrwxrwxrwx 1 user user 0 ... cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 user user 0 ... mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 user user 0 ... net -> 'net:[4026531840]'用 unshare 启动一个新 shell,同时创建新的 UTS、PID、Mount、Network Namespace:
$ sudo unshare --uts --pid --mount --net --fork --mount-proc bash
# hostname my-container
# hostname
my-container
# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 8964 5376 pts/0 S 10:00 0:00 bash
root 8 0.0 0.0 10884 4352 pts/0 R+ 10:00 0:00 ps aux
# ip addr
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00几个现象值得注意:
- 改了主机名,但宿主机的主机名不受影响(UTS 隔离)。
ps只能看到两个进程,bash的 PID 是 1(PID 隔离,--mount-proc重新挂载了/proc)。- 网络里只有一个未启动的
lo,没有任何外网网卡(Network 隔离)。
另开一个终端执行 ps -ef | grep unshare,在宿主机上仍然能看到它,这只是一个普通进程。在第一个终端输入 exit 退出,所有 Namespace 随之销毁。
cgroup:限制资源
Namespace 只管"看得见什么",不管"用多少"。一个进程在独立 Namespace 里照样可以吃光宿主机内存。cgroup 负责资源限制和统计。现代发行版普遍使用 cgroup v2,所有控制器挂在统一的层级 /sys/fs/cgroup 下:
$ stat -fc %T /sys/fs/cgroup
cgroup2fs输出 cgroup2fs 表示 cgroup v2,tmpfs 表示 v1。手工创建一个 cgroup,限制内存为 50 MB:
$ sudo mkdir /sys/fs/cgroup/demo
$ echo 50M | sudo tee /sys/fs/cgroup/demo/memory.max
$ echo 0 | sudo tee /sys/fs/cgroup/demo/memory.swap.max
$ sudo bash -c 'echo $$ > /sys/fs/cgroup/demo/cgroup.procs && python3 -c "a = bytearray(100 * 1024 * 1024)"'
Killed
$ grep oom_kill /sys/fs/cgroup/demo/memory.events
oom_kill 1
$ sudo rmdir /sys/fs/cgroup/demoPython 试图分配 100 MB,超过 50 MB 上限,被内核的 OOM Killer 杀掉。这正是 K8s 里 Pod 状态显示 OOMKilled 的底层原因:你在 YAML 里写的 resources.limits.memory,最终会被 kubelet 和容器运行时写成 cgroup 的 memory.max。CPU 限制同理,对应 cpu.max。详见 健康检查与资源管理。
镜像:分层的文件系统
光有隔离和限制还不够,容器里的进程需要一个根文件系统:/bin、/lib、应用程序本身。镜像就是这个文件系统的打包格式。
分层与写时复制
镜像由若干只读的**层(Layer)**叠加而成,每一层是一个 tar 包,记录相对上一层的文件变化。运行容器时,运行时在最上面再加一个可写层,用 OverlayFS 这类联合文件系统把它们合并成一个目录视图:
容器视图 / (合并后)
┌────────────────────────────────────┐
│ 可写层(容器运行时新增/修改的文件) │ ← 容器删除即消失
├────────────────────────────────────┤
│ Layer 3: COPY app /app │ ┐
│ Layer 2: RUN apt-get install ... │ ├ 只读,多个容器共享
│ Layer 1: 基础镜像 debian:bookworm │ ┘
└────────────────────────────────────┘好处有两个:
- 共享:100 个基于同一基础镜像的容器,磁盘上只存一份基础层。
- 缓存:构建和拉取时只处理变化的层。
修改只读层里的文件时,会先把文件复制到可写层再改,这叫写时复制(Copy-on-Write)。因此容器里写入的数据在容器删除后就丢了,需要持久化的数据要用卷,见 存储。
用 docker image history 可以看到每一层来自哪条指令:
$ docker pull nginx:1.29
$ docker image history nginx:1.29 --format '{{.Size}}\t{{.CreatedBy}}' | head -5OCI 标准
早期"镜像"和"容器"几乎就等于 Docker。2015 年成立的 OCI(Open Container Initiative,开放容器倡议) 把它们标准化成三份规范:Image Spec(镜像格式:manifest、config、layers)、Runtime Spec(如何根据文件系统包 + 配置运行容器)、Distribution Spec(镜像仓库的 push/pull HTTP API)。所以用 Docker 构建的镜像,可以被 Podman、containerd、CRI-O 运行,也可以推送到任何兼容的仓库(Docker Hub、Harbor、GHCR 等)。
容器运行时:containerd 与 CRI
"容器运行时"这个词在不同语境下指不同层级的东西:
kubelet ──CRI(gRPC)──▶ containerd / CRI-O 高层运行时:拉镜像、管理快照、管理容器生命周期
│
▼
runc / crun 低层运行时:按 OCI Runtime Spec 调用
│ clone/unshare、写 cgroup
▼
Linux 内核(Namespace、cgroup、OverlayFS)
docker CLI ──▶ dockerd ──▶ containerd ──▶ runc (Docker 的调用链)- runc:OCI 运行时的参考实现,干的就是上面
unshare+ cgroup 那些事。 - containerd:从 Docker 中拆分出来的高层运行时,现为 CNCF 毕业项目,负责镜像管理和容器生命周期。
- CRI(Container Runtime Interface,容器运行时接口):K8s 定义的 gRPC 接口。kubelet 只通过 CRI 和运行时对话,任何实现了 CRI 的运行时(containerd、CRI-O)都能接入。
kind 的节点镜像里运行的就是 containerd。在下一课搭好集群后,可以进入节点用 crictl 查看:
$ docker exec -it k8s-journey-worker crictl ps写一个 Dockerfile
下面用一个 Go 写的小 HTTP 服务演示构建流程。新建目录 hello-app,放两个文件:
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
host, _ := os.Hostname()
fmt.Fprintf(w, "Hello from %s, version=%s\n", host, os.Getenv("APP_VERSION"))
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}FROM golang:1.25
WORKDIR /src
COPY main.go .
RUN go mod init hello && go build -o /hello .
ENV APP_VERSION=v1
EXPOSE 8080
CMD ["/hello"]构建并运行:
$ docker build -f Dockerfile.single -t hello-app:single .
$ docker run -d --name hello -p 8080:8080 hello-app:single
$ curl localhost:8080
Hello from 3f2a9c1b7d4e, version=v1
$ docker rm -f hello返回的主机名是容器 ID,这就是 UTS Namespace 的效果。
几个指令要点:RUN、COPY 会产生新层;EXPOSE 只是声明,不会真的开放端口;ENTRYPOINT / CMD 分别对应 K8s 容器定义中的 command / args。
多阶段构建
看一下刚才镜像的大小:
$ docker images hello-app
REPOSITORY TAG IMAGE ID SIZE
hello-app single 8c1d2e3f4a5b 900MB900 MB 左右,因为整个 Go 编译工具链都在里面,而运行时只需要一个几 MB 的二进制文件。多阶段构建(Multi-stage Build) 允许在一个 Dockerfile 里用多个 FROM,只把最终产物复制到干净的运行镜像中:
# 阶段一:编译
FROM golang:1.25 AS build
WORKDIR /src
COPY main.go .
RUN go mod init hello && CGO_ENABLED=0 go build -ldflags="-s -w" -o /hello .
# 阶段二:运行
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /hello /hello
ENV APP_VERSION=v2
EXPOSE 8080
USER nonroot
ENTRYPOINT ["/hello"]$ docker build -t hello-app:v2 .
$ docker images hello-app
REPOSITORY TAG IMAGE ID SIZE
hello-app v2 1a2b3c4d5e6f 9MB
hello-app single 8c1d2e3f4a5b 900MBCGO_ENABLED=0生成静态链接的二进制,不依赖 libc。- distroless 镜像只包含运行所需的最少文件,没有 shell 和包管理器,攻击面小。
USER nonroot以非 root 用户运行,和 工作负载安全加固 中的runAsNonRoot呼应。
镜像名、tag 与 digest
一个完整的镜像引用长这样:
registry.example.com/team/hello-app:v2@sha256:4e1f...9a
└──────┬───────────┘ └──────┬─────┘ └┬┘ └─────┬──────┘
仓库地址 仓库路径 tag digest- 省略仓库地址时默认是 Docker Hub
docker.io,nginx等价于docker.io/library/nginx。 - tag 是一个可变的标签,同一个
v2今天和明天可以指向不同内容。省略时默认为latest。 - digest 是镜像 manifest 内容的 SHA-256 哈希,内容不变它就不变,内容一变它必然变。
查看镜像的 digest:
$ docker pull nginx:1.29
$ docker image inspect nginx:1.29 --format '{{index .RepoDigests 0}}'
nginx@sha256:...用 digest 拉取可以保证拿到的是一模一样的内容:
$ docker pull nginx@sha256:<上一步输出的哈希>动手练习
- 在 Linux 环境里用
sudo unshare --uts --pid --net --mount --fork --mount-proc bash进入新的 Namespace,修改主机名并执行ps aux、ip addr,对比宿主机的输出。 - 用 cgroup v2 创建一个
memory.max=30M的组,在里面运行一个分配 50 MB 内存的程序,观察memory.events中的oom_kill计数。 - 按本课步骤分别构建
hello-app:single和hello-app:v2,对比镜像大小和docker image history的层数。 - 用
docker run -e APP_VERSION=v3 -p 8080:8080 hello-app:v2运行容器,验证环境变量覆盖了镜像里的默认值。 - 查出
nginx:1.29的 digest,用nginx@sha256:...的形式再拉一次。
自测
Namespace 和 cgroup 分别解决什么问题?
Namespace 控制进程能看到什么(进程号、网络、挂载点、主机名等),实现隔离;cgroup 控制进程能用多少资源(CPU、内存、IO 等),实现限制与统计。
容器里写入的文件,为什么删除容器后就没了?
镜像层是只读的,容器运行时在最上面加了一个可写层,所有修改都写在这层(写时复制)。可写层的生命周期和容器一样,容器删除它就被删除。需要持久化的数据应使用卷。
K8s 移除了 dockershim,用 Docker 构建的镜像还能在 K8s 上跑吗?
能。镜像遵循 OCI Image Spec,containerd、CRI-O 等 CRI 运行时都能运行。移除的只是 kubelet 调用 Docker 引擎的适配层,与镜像格式无关。
多阶段构建为什么能显著减小镜像?
最终镜像只包含最后一个 FROM 阶段的层。编译工具链、源码、中间产物都留在前面的构建阶段,只用 COPY --from= 把需要的产物复制过去。
tag 和 digest 有什么区别?生产环境该用哪个?
tag 是可变的名字,可以被重新指向新内容;digest 是 manifest 内容的哈希,不可变。生产环境至少使用明确的版本 tag,禁止 latest;要求严格可复现时固定 digest。