服务写完了、测试过了,最后一个问题永远是:它到底怎么跑到线上那一堆机器上去? 十年前的答案是”打个包、写份部署文档、运维照着装环境”,然后在”我本地明明是好的”里反复扯皮。容器把这套彻底改写了——把应用连同它的整个运行环境打成一个不可变的镜像,一次构建、处处运行。这个系列专门深挖容器化与 Kubernetes 部署,从容器的内核原理,到 K8s 的核心对象、调度弹性,一路落到广告竞价服务真正上云的实战调优。
开篇这一篇先钻最底层:容器到底是什么? 它不是”轻量虚拟机”这么一句话能糊弄过去的——把 Linux namespace、cgroup、联合文件系统这三块地基挖开,你才会明白后面 K8s 的 Pod、资源 limit、OOMKilled 是怎么一回事。
本文是容器化与 K8s 部署系列的第 1 篇(开篇 · Docker 原理与镜像分层)。 全系列 5 篇:
- 容器化(开篇)· Docker 原理与镜像分层(本篇)
- Kubernetes 核心对象 · Pod/Deployment/Service/Ingress
- K8s 调度、资源与弹性伸缩
- CI/CD 流水线 · Jenkins 构建镜像与自动部署到 K8s
- 广告服务上云实战 · 容器化部署与调优
一句话定位:容器不是虚拟机,而是”被 namespace 隔离视图、被 cgroup 限制资源、用联合文件系统分层打包”的一组宿主机进程;Docker 只是把这套内核能力封装成”构建镜像 + 运行容器”的顺手工具。
TL;DR
- 容器 = namespace + cgroup + 联合文件系统:namespace 隔离”看到什么”(进程树/网络/挂载/主机名各自独立),cgroup 限制”能用多少”(CPU/内存/IO 配额),UnionFS 把镜像做成分层只读 + 容器可写层。三者都是 Linux 内核特性,Docker 只是封装。
- 容器不是轻量虚拟机:VM 每个都带一整套 Guest OS + 走 Hypervisor 虚拟化硬件(GB 级、启动分钟级);容器共享宿主机内核、只是被隔离限额的普通进程(MB 级、启动毫秒~秒级)。代价是隔离性弱于 VM。
- 镜像是分层的:每条 Dockerfile 指令(大致)产生一层只读 layer,层层叠加;多个镜像共享相同的底层,节省存储和拉取带宽。
- 写时复制(CoW):容器启动时在只读镜像层之上加一个薄薄的可写层,运行中的写入都落在这层;改一个只读层里的文件会先把它复制到可写层再改——所以容器本身是”近乎无状态”的,数据要靠 volume 落到外面。
- layer 缓存决定构建速度:Docker 按层缓存,只要某层的输入没变就复用缓存。把不常变的(依赖安装)放前面、常变的(源码/jar)放后面,改一行代码不会重装所有依赖。
- Dockerfile 最佳实践:合并
RUN、清理缓存、用.dockerignore、固定基础镜像 tag/digest、非 root 运行、显式COPY而非ADD。 - 多阶段构建:
build阶段用带编译器的重镜像编译,runtime阶段COPY --from=build只拷产物到轻镜像——最终镜像不含 JDK/Maven/源码,体积和攻击面都骤降。 - 镜像瘦身取舍:
alpine(musl libc,小但偶有兼容坑)vsdistroless(无 shell/包管理器、更安全但难进容器调试)vsslim。广告高频拉取镜像的场景,瘦身直接缩短扩容和滚动更新时间。 - 容器运行时分层:Docker CLI/daemon → containerd(管镜像与容器生命周期)→ runc(真正调用内核 clone/namespace/cgroup 创建容器进程),遵循 OCI 标准;K8s 通过 CRI 直接对接 containerd。
- AdTech 实践:竞价服务镜像臃肿会拖慢扩容与发布(拉取几分钟,弹性形同虚设),且 JVM 在容器里不感知 cgroup limit 会误判可用内存——这是本篇和末篇 war story 的伏笔。
Table of contents
Open Table of contents
1. 容器到底是什么:一个被”骗”了的进程
先破除最大的误解:容器里跑的,就是宿主机上一个普通的进程。你在容器里 ps 看到自己是 PID 1、ip addr 看到独立网卡、df 看到独立文件系统——这些全是”障眼法”。从宿主机上 ps -ef 看,那个所谓”容器里的进程”就明晃晃地列在宿主机进程表里,只是被内核用 namespace 蒙住了眼睛、用 cgroup 拴住了手脚。
所以理解容器,本质是理解三个 Linux 内核机制怎么协作,把一个普通进程”包装”成一个看似独占整台机器的沙盒:
- namespace(命名空间):隔离进程”能看见什么”——让它以为进程树、网络、文件系统都是自己的。
- cgroup(control group):限制进程”能用多少”——CPU、内存、IO 都有配额,超了就被限速或杀掉。
- 联合文件系统(UnionFS):把镜像做成分层、只读、可共享的文件系统,再叠一个可写层给容器运行时用。
容器 = namespace(隔离”看到什么”)+ cgroup(限制”能用多少”),两者都是内核特性,容器共享宿主机同一个内核。
1.1 namespace:隔离”看到什么”
Linux 提供了多种 namespace,容器启动时会为进程新建一组,从而隔离它对系统资源的”视图”:
| namespace | 隔离的东西 | 效果 |
|---|---|---|
| PID | 进程 ID 空间 | 容器内进程从 PID 1 开始编号,看不到宿主机其他进程 |
| NET | 网络栈 | 独立网卡、IP、端口、路由表、iptables |
| MNT | 挂载点 | 独立的文件系统挂载视图(配合 UnionFS 就是独立根目录) |
| UTS | 主机名/域名 | 容器可以有自己的 hostname |
| IPC | 进程间通信 | 独立的信号量、消息队列、共享内存 |
| USER | 用户/组 ID | 容器内 root(UID 0)可映射为宿主机上的非特权用户 |
关键是:这些 namespace 是可以独立组合的。容器技术就是”新建一组 namespace,把目标进程塞进去”。宿主机内核只有一个,但每个容器进程被塞进各自的 namespace,就产生了”每台机器都归我独占”的错觉。
1.2 cgroup:限制”能用多少”
namespace 解决了”看不见别人”,但没解决”抢资源”——如果不加限制,一个容器完全可以吃光宿主机的 CPU 和内存,把邻居饿死。cgroup(control group) 就是内核用来给一组进程做资源限额与统计的机制:
- CPU:
cpu.max(绝对配额,如”每 100ms 最多用 50ms CPU 时间”= 0.5 核)、cpu.weight(相对权重,竞争时按比例分)。 - 内存:
memory.max(硬上限)。容器内存用量超过memory.max,内核的 OOM Killer 会直接杀掉容器里的进程——这就是 K8s 里OOMKilled的根源(详见第 3 篇)。 - Block IO:
io.max限制磁盘读写带宽/IOPS。 - PIDs:
pids.max限制进程数,防 fork 炸弹。
记住这条因果链:容器的内存 limit 本质是 cgroup 的
memory.max;进程 RSS 触到这个值就被内核 OOMKilled,而不是”变慢”。很多”容器莫名重启”的事故,根子都在这里。Java 应用尤其容易踩——JVM 若不感知 cgroup limit,会按宿主机物理内存去规划堆,结果堆还没到-Xmx就先被 cgroup 杀了(末篇 JVM in container 详解)。
1.3 一句话总结
容器就是”一个被 namespace 蒙住视图、被 cgroup 拴住资源、以镜像分层文件系统为根的宿主机进程”。 没有魔法,全是内核特性的组合。理解了这点,后面 K8s 里所有”Pod 共享网络""资源 request/limit""被 OOMKilled”的现象,都能一眼看穿本质。
2. 容器 vs 虚拟机:别再叫它”轻量虚拟机”
“容器是更轻的虚拟机”是最流行也最误导的说法。两者的隔离层次根本不同:
虚拟机各带一整套 Guest OS + 走 Hypervisor 虚拟化硬件;容器共享宿主机内核,只是被隔离限额的进程。密度与启动速度天差地别,隔离强度反过来。
| 维度 | 虚拟机(VM) | 容器 |
|---|---|---|
| 隔离层 | Hypervisor 虚拟化硬件,各跑一套 Guest OS 内核 | 共享宿主机内核,靠 namespace/cgroup 隔离 |
| 镜像/体积 | GB 级(含整个 OS) | MB 级(只含 app + 依赖 + 精简 rootfs) |
| 启动速度 | 分钟级(要引导整个 OS) | 毫秒~秒级(起个进程而已) |
| 密度 | 单机几个~几十个 | 单机几十~几百个 |
| 隔离强度 | 强(内核级隔离,安全边界硬) | 弱(共享内核,内核漏洞可逃逸) |
| 适用 | 强隔离多租户、跑不同 OS、传统应用 | 微服务、高密度、快速弹性伸缩 |
这个差别对广告场景很实际:竞价、检索这类服务要快速弹性扩容(流量高峰几分钟内翻几倍),容器的秒级启动 + 高密度是刚需,VM 的分钟级启动根本跟不上。但也正因为容器共享内核、隔离弱,安全多租(跑不可信的第三方代码)时会再用 Kata Containers、gVisor 这类”用轻量 VM/用户态内核给容器套一层”的方案补强隔离——本质是在密度和隔离之间再权衡一次。
3. 镜像分层与写时复制
容器”一次构建、处处运行”的底气,来自镜像。而镜像最精妙的设计就是分层(layered)+ 写时复制(Copy-on-Write)。
左:镜像是只读层堆叠 + 一个容器可写层(CoW);右:多阶段构建把编译环境甩掉、只把产物拷进最终的轻镜像。
3.1 每条指令一层
Dockerfile 里的 FROM/RUN/COPY/ADD 等指令,大致每条产生一个只读镜像层(layer)。最终镜像就是这些层自下而上叠起来的联合视图(UnionFS,如 overlay2):
FROM eclipse-temurin:17-jre # L1: 基础层(JRE)
RUN apt-get update && apt-get install -y curl # L2: 系统依赖层
COPY pom.xml . # L3: 依赖清单
RUN mvn dependency:go-offline # L4: 下载依赖层
COPY target/app.jar app.jar # L5: 应用产物层
上层能”看见”并覆盖下层的同名文件,多层叠出来就是容器看到的完整根文件系统。
3.2 层共享:省存储、省带宽
分层最大的红利是共享:如果你有 20 个微服务镜像都基于同一个 eclipse-temurin:17-jre,宿主机上这个基础层只存一份,20 个镜像共用。拉镜像时也只下载本地没有的层——发布一个只改了业务代码的新版本,往往只需传输最上面那个几十 MB 的应用层,下面几百 MB 的依赖层直接命中本地缓存。这对”一天发布几十次、几百台机器同时拉镜像”的广告系统是巨大的带宽和时间节省。
3.3 写时复制:容器的可写层
镜像层全是只读的。容器启动时,运行时会在镜像层顶上加一个薄薄的可写层(container layer):
- 容器运行中的所有写入(新建文件、改配置、写日志)都落在这个可写层,不动下面的只读镜像层。
- 修改一个来自只读层的文件时,先把它复制一份到可写层再改(Copy-on-Write),原文件不变。
- 容器删除时,这个可写层跟着销毁——所以容器里的数据默认是临时的。要持久化(数据库文件、上传内容),必须挂 volume 把数据落到容器外。
这解释了两件事:① 为什么说容器”应该无状态”——可写层随容器生灭,重启即丢;② 为什么同一镜像能秒起 100 个容器——它们共享同一份只读镜像层,各自只多一个空的可写层,几乎零拷贝成本。
3.4 layer 缓存:构建为什么有时快有时慢
Docker 构建时对每一层做缓存:只要某层的输入(指令 + 依赖的上下文)没变,就直接复用缓存层,不重新执行。一旦某层变了,它和它之上的所有层都要重建(缓存失效向上传播)。
这直接决定了 Dockerfile 的写法:把不常变的放前面、常变的放后面。经典对比:
# 反例:先 COPY 全部源码,改一行代码就让依赖层缓存失效、重新下载所有依赖
COPY . .
RUN mvn package
# 正例:先只拷依赖清单、装依赖(这层几乎不变、长期命中缓存),最后才拷源码
COPY pom.xml .
RUN mvn dependency:go-offline # 依赖没改 → 命中缓存,跳过
COPY src ./src
RUN mvn package -o # 只有这层随代码变
改一行业务代码,正例只重跑最后两层(秒级),反例要重新拉全部依赖(分钟级)。CI 里镜像构建慢,十有八九是缓存层顺序没排好。
4. Dockerfile 最佳实践与镜像瘦身
镜像不是能跑就行——它的体积、层数、安全性直接影响拉取速度、扩容延迟和攻击面。
4.1 通用最佳实践
- 合并
RUN、清理缓存:把apt-get update && install && rm -rf /var/lib/apt/lists/*写在同一条RUN里。分成两条会让缓存文件留在中间层里(即使后一层删了,前一层的体积仍在)。 - 用
.dockerignore:排除.git、node_modules、构建产物、密钥等,避免把无关文件塞进构建上下文(既慢又可能泄密)。 - 固定基础镜像版本:用
eclipse-temurin:17.0.9_9-jre甚至 digest(@sha256:...),别用latest——否则构建不可复现,某天基础镜像一变行为就飘。 - 非 root 运行:
USER appuser,别用 root 跑业务进程,降低容器逃逸后的破坏面。 COPY优于ADD:ADD会自动解压 tar、能拉 URL,语义隐晦;除非确实要这些特性,一律用COPY。- 只
COPY需要的:别COPY . .把整个仓库塞进去。 - 一个容器一个主进程:容器的哲学是”一进程一职责”,别在一个容器里塞一堆 daemon(那是 VM 思维)。
4.2 多阶段构建
编译型语言(Java/Go/Rust/C++)最大的浪费是:编译要一整套工具链(JDK、Maven、gcc),但运行只需要产物。如果把编译和运行放同一个镜像,最终镜像就白白背着几百 MB 的编译器。
多阶段构建(multi-stage build) 解决这个问题——一个 Dockerfile 写多个 FROM,前面的阶段负责编译,最后的阶段只 COPY --from 拷走产物:
# ---- 阶段 1:build(重镜像,带完整编译工具) ----
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests -o
# ---- 阶段 2:runtime(轻镜像,只有运行所需) ----
FROM gcr.io/distroless/java17-debian12
WORKDIR /app
COPY --from=build /src/target/app.jar app.jar # 只拷产物,不带编译器/源码
USER nonroot
ENTRYPOINT ["java", "-jar", "app.jar"]
最终镜像里没有 JDK、没有 Maven、没有源码、没有中间产物,只有一个 JRE + 你的 jar。体积从 1GB+ 降到几十~百来 MB,攻击面也大幅缩小。
4.3 基础镜像的取舍
瘦身的另一半是选对基础镜像:
| 基础镜像 | 大小 | 特点 | 取舍 |
|---|---|---|---|
完整发行版(ubuntu/debian) | 大(100MB+) | 工具齐全、兼容性最好、好调试 | 体积大、CVE 多 |
-slim | 中 | 砍掉文档/多余包的官方精简版 | 平衡之选,兼容性仍好 |
alpine | 小(~5MB) | 用 musl libc + busybox | 极小,但 musl 与 glibc 有差异(DNS、某些 native 库偶尔踩坑) |
distroless | 小 | 只含运行时和依赖,无 shell、无包管理器 | 最安全(攻击面极小),但进不去容器 sh 调试,排障要靠 sidecar/ephemeral container |
经验法则:追求极致安全和最小攻击面 → distroless;要小又要保留基本兼容性 → slim;用 alpine 前先验证你的 native 依赖在 musl 下没问题(Java 生态尤其注意 glibc 差异)。广告高频拉取镜像的场景,瘦身几百 MB 直接等于扩容/发布快几十秒到几分钟。
5. 容器运行时:从 Docker 到 containerd 与 runc
日常我们敲 docker run,但”启动容器”这件事其实是分层协作的,理解这条链在 K8s 时代尤为重要(K8s 早已不直接用 Docker):
- Docker CLI / dockerd:面向用户的命令行和守护进程,负责镜像构建、网络、卷等用户体验层的封装。
- containerd:真正的容器生命周期管理器——拉取/管理镜像、管理容器的创建/启动/停止/删除、对接存储和网络。它是一个独立的、CNCF 毕业的守护进程。
- runc:最底层的 OCI 运行时,一个命令行工具,真正去调用 Linux 内核的
clone()、配置 namespace、写 cgroup、切 rootfs,把容器进程实际拉起来。它实现了 OCI(Open Container Initiative)运行时规范。
调用链大致是:docker run → dockerd → containerd → containerd-shim → runc → 内核(namespace + cgroup)→ 容器进程跑起来。
为什么 K8s 不用 Docker 了? K8s 通过 CRI(Container Runtime Interface) 对接运行时。Docker 本身不实现 CRI,早期靠一个叫 dockershim 的适配层桥接。K8s 1.24 起移除了 dockershim,直接对接实现了 CRI 的 containerd(或 CRI-O)。这不影响你用 Docker 构建镜像——镜像遵循 OCI 标准,Docker 构建的镜像 containerd 照样能跑;变的只是集群里”运行容器”这一环的组件。这条运行时链是下一篇 K8s 节点上 kubelet → CRI → containerd 的基础。
6. 数据、网络与容器的边界
容器”无状态、可随时销毁重建”的特性很美好,但真实应用总有状态和通信需求,靠两个机制补齐:
- Volume(数据卷):把宿主机目录或专门的卷挂进容器,让数据绕过随容器销毁的可写层、独立持久化。日志、上传文件、数据库数据都靠它。K8s 里对应
Volume/PersistentVolume(本系列聚焦无状态服务,PV/StatefulSet 只做提及)。 - 网络(Network):每个容器有独立 NET namespace(独立 IP)。单机 Docker 用 bridge 网络 + iptables NAT 让容器互通和对外;到了 K8s 则是 CNI 插件 + 每个 Pod 一个 IP 的扁平网络模型(下一篇 详解 Service/Ingress 怎么在这之上做服务发现和路由)。
核心心智:把”计算”(无状态、可随意重建的容器)和”状态”(volume/外部存储/数据库)彻底分开。容器负责跑逻辑、随时可被杀掉重建;状态沉到容器之外。这正是云原生弹性伸缩、滚动更新的前提——你敢随便重建一个容器,是因为它没揣着不可再生的数据。
参考
- Docker. Docker 官方文档 · Get Started 与 Build 最佳实践:镜像分层、多阶段构建、Dockerfile 最佳实践的一手权威说明。
- Docker. Storage drivers & overlay2(联合文件系统与写时复制):镜像分层与 CoW 的存储驱动实现细节。
- Open Container Initiative. OCI Runtime & Image Spec:容器镜像与运行时(runc/containerd 遵循)的标准规范。
- containerd. containerd 官方文档:容器运行时生命周期管理与 CRI 对接。
- Michael Kerrisk. Linux namespaces(7) / cgroups(7) man pages:namespace 与 cgroup 的内核机制权威参考。