Skip to content
Charles Shao
Go back

容器化(开篇)· Docker 原理与镜像分层

views

服务写完了、测试过了,最后一个问题永远是:它到底怎么跑到线上那一堆机器上去? 十年前的答案是”打个包、写份部署文档、运维照着装环境”,然后在”我本地明明是好的”里反复扯皮。容器把这套彻底改写了——把应用连同它的整个运行环境打成一个不可变的镜像,一次构建、处处运行。这个系列专门深挖容器化与 Kubernetes 部署,从容器的内核原理,到 K8s 的核心对象、调度弹性,一路落到广告竞价服务真正上云的实战调优。

开篇这一篇先钻最底层:容器到底是什么? 它不是”轻量虚拟机”这么一句话能糊弄过去的——把 Linux namespace、cgroup、联合文件系统这三块地基挖开,你才会明白后面 K8s 的 Pod、资源 limit、OOMKilled 是怎么一回事。

本文是容器化与 K8s 部署系列的第 1 篇(开篇 · Docker 原理与镜像分层)。 全系列 5 篇:

  1. 容器化(开篇)· Docker 原理与镜像分层(本篇)
  2. Kubernetes 核心对象 · Pod/Deployment/Service/Ingress
  3. K8s 调度、资源与弹性伸缩
  4. CI/CD 流水线 · Jenkins 构建镜像与自动部署到 K8s
  5. 广告服务上云实战 · 容器化部署与调优

一句话定位:容器不是虚拟机,而是”被 namespace 隔离视图、被 cgroup 限制资源、用联合文件系统分层打包”的一组宿主机进程;Docker 只是把这套内核能力封装成”构建镜像 + 运行容器”的顺手工具。

TL;DR

Table of contents

Open Table of contents

1. 容器到底是什么:一个被”骗”了的进程

先破除最大的误解:容器里跑的,就是宿主机上一个普通的进程。你在容器里 ps 看到自己是 PID 1、ip addr 看到独立网卡、df 看到独立文件系统——这些全是”障眼法”。从宿主机上 ps -ef 看,那个所谓”容器里的进程”就明晃晃地列在宿主机进程表里,只是被内核用 namespace 蒙住了眼睛、用 cgroup 拴住了手脚。

所以理解容器,本质是理解三个 Linux 内核机制怎么协作,把一个普通进程”包装”成一个看似独占整台机器的沙盒:

容器隔离原理示意图。上方两个容器面板并排:容器 A(竞价服务进程)与容器 B(广告网关进程),每个容器内部含四类 namespace 芯片——PID ns(独立进程树)、NET ns(独立网卡/IP)、MNT ns(独立文件系统)、UTS/IPC/USER(独立主机名/信号/UID)。中间一条 amber 色 cgroup 资源限额面板,横向列出四种配额:CPU 配额(cpu.max/shares)、内存上限(memory.max 超限触发 OOMKill)、Block IO(io.max 带宽)、PIDs 数(pids.max),表示对每个容器强制配额。最底部是宿主机 Host 面板,标注为单一共享的 Linux Kernel,说明 namespace 与 cgroup 都是内核特性、容器只是被隔离限额的普通进程、不含独立 OS 内核。两个容器用虚线箭头向下连到内核,标注共享内核·系统调用。

容器 = 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) 就是内核用来给一组进程做资源限额与统计的机制:

记住这条因果链:容器的内存 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 虚拟机:别再叫它”轻量虚拟机”

“容器是更轻的虚拟机”是最流行也最误导的说法。两者的隔离层次根本不同

容器与虚拟机对比图。左侧面板是虚拟机(Hypervisor 虚拟化硬件):自上而下两列 App A/App B(各含依赖),每个 App 下方都有一个红色的 Guest OS 内核块(整套操作系统),再下方是横跨的 Hypervisor(虚拟化层),最底是 Host OS + 物理硬件;标注"每个 VM 一套 Guest OS:GB 级、启动分钟级、隔离最强"。右侧面板是容器(共享宿主内核):上方两列容器 A/容器 B(App + 依赖),下方是容器运行时(containerd + runc),再下方是共享的 Host OS 内核(namespace + cgroup 隔离);标注"无 Guest OS:MB 级、启动毫秒~秒级、隔离弱于 VM"。底部说明容器没有自己的内核、只是被隔离限额的宿主进程,所以密度高启动快但内核级安全边界弱于 VM。

虚拟机各带一整套 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)

镜像分层与多阶段构建示意图。左侧面板"镜像分层 + 容器可写层(Copy-on-Write)":自上而下堆叠五层——最上是绿色的容器可写层(薄·CoW,运行时写入落在此层),下面依次是镜像层 L4(COPY app.jar 应用产物)、L3(pip/mvn 依赖,变动较多)、L2(apt 安装依赖,较稳定)、L1(base 基础镜像 alpine/distroless,只读·多镜像共享);底部说明下层只读且被多镜像/容器共享,写入时把文件复制到可写层再改。右侧面板"多阶段构建:编译环境不进最终镜像":上方 build 阶段(FROM maven:3-jdk-17)含 JDK+Maven 全套编译工具,源码编译产出 app.jar;一个向下箭头标注 COPY --from=build 只拷产物;下方 runtime 阶段(FROM distroless/java17)仅含 JRE + app.jar、无编译器/shell,镜像约 80MB、攻击面最小。底部说明最终镜像不含 JDK/Maven/源码,更小更快更少 CVE。

左:镜像是只读层堆叠 + 一个容器可写层(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)

这解释了两件事:① 为什么说容器”应该无状态”——可写层随容器生灭,重启即丢;② 为什么同一镜像能秒起 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 通用最佳实践

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 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/外部存储/数据库)彻底分开。容器负责跑逻辑、随时可被杀掉重建;状态沉到容器之外。这正是云原生弹性伸缩、滚动更新的前提——你敢随便重建一个容器,是因为它没揣着不可再生的数据。

参考


views
Share this post on:

Previous Post
Kubernetes 核心对象 · Pod/Deployment/Service/Ingress
Next Post
Dubbo 深挖 · 流量治理、Filter 链与优雅上下线