云原生时代的开端:为什么我们需要容器
第 1 章 云原生时代的开端:为什么我们需要容器
1.1 一个让无数工程师失眠的真实故事
2017 年某天下午,某电商公司的运维工程师张磊接到了一个紧急电话。生产环境的订单服务在大促预热期间突然宕机,大量用户无法完成支付。技术总监在电话那头语气急促:"线上挂了,抓紧排查!"张磊打开监控面板,看到 CPU 占用率正常、内存充足、数据库连接数也没有异常,但应用进程就是不断地重启。
张磊登录到生产服务器,手动启动了一遍应用,进程能正常起来。可一旦让负载均衡把流量切回来,应用就立刻崩溃。他把日志拉出来反复看了十几遍,终于在最后一行发现了一行不起眼的报错:`GLIBC_2.28 not found`。他瞬间明白了:生产环境的操作系统是 CentOS 7,自带的 glibc 库版本是 2.17;而他本机开发用的是 Ubuntu 20.04,glibc 版本是 2.31。开发机上跑得好好的代码,到了生产环境却因为一个底层 C 库版本不一致而直接挂掉。
这不是张磊第一次遇到这种问题。他回忆起过去几年踩过的坑:在开发机上一切正常,测试环境却出现莫名的乱码;本地能连上 MySQL,部署到生产环境就报权限错误;同事 A 用 Python 3.9 写的脚本,同事 B 的电脑装着 Python 3.7,跑起来语法报错。每一个问题排查起来都不算复杂,可它们反复出现、消磨精力、拖延上线。
如果把视角拉到整个软件行业,你会发现张磊的遭遇并不是个例,而是一种普遍现象。它甚至有一个专门的名字:"在我电脑上能跑"综合征(Works on My Machine Syndrome)。这种问题之所以长期存在,根本原因在于传统的软件交付方式里,应用程序和它所依赖的运行环境是松耦合的。开发人员把代码写好、提交到代码仓库、交给运维部署,运维拿到的是一堆源代码或者编译产物,但代码究竟要跑在什么样的操作系统、依赖哪些系统库、装什么版本的运行时、设置哪些环境变量——这些信息全靠文档、口头约定,甚至运气。
容器技术之所以在 2013 年 Docker 出现之后迅速席卷整个行业,正是因为它瞄准了这个根本问题:把代码和它运行所需要的一切环境,打包成一个不可分割的整体。容器不是一项花哨的新技术,它解决的是软件工程领域最古老、最令人头疼的问题之一——环境一致性。
理解这一点,是踏入云原生世界的第一步。在本章接下来的内容中,我们先从部署模式的演进史讲起,看看人类是如何一步步从"物理机时代"走到"容器化时代"的;然后我们会深入剖析 Docker 与传统虚拟机的本质差异,建立起对容器技术最基础的认知;接着我们会一起浏览云原生计算基金会(CNCF)的技术全景图,看清楚容器在更大的生态中处于什么位置;最后我们会用一个完整的"白板推演",把本章的核心思想浓缩成一张可以讲给别人听的对比图。读完之后,你不仅能回答"为什么需要容器",还能用一张图、一个故事、五个判断标准,向身边的人清晰地解释清楚这件事。
1.2 部署模式的演进史:从物理机到虚拟化再到容器化
要理解容器为什么会出现,我们有必要先回顾一下过去三十多年间,软件部署方式是如何演进的。这段历史并不只是怀旧,它会帮助你理解每一次技术变革背后真正的驱动力——不是技术人的浪漫情怀,而是生产环境中一个又一个无法回避的现实痛点。
1.2.1 物理机时代:粗放但直接的部署方式
在 2000 年之前,绝大多数企业的应用都是直接部署在物理服务器上的。所谓物理服务器,就是一台台看得见、摸得着、摆在机房机柜里的实体机器。每一台物理服务器都有独立的 CPU、内存、硬盘、网卡,操作系统直接安装在物理硬件之上,应用则运行在操作系统之上。
这种部署方式非常直观。你买一台服务器,装一个 Linux,把应用部署上去,它就跑起来了。运维人员对硬件有完全的控制权,性能损耗几乎为零,因为没有虚拟化层在中间"抽成"。对于当时的应用规模来说,这种方式是够用的。
但是物理机时代有几个绕不开的痛点。
第一,资源利用率极低。一个典型的企业应用——比如某个内部 OA 系统——平时 CPU 占用率可能只有 5%,内存也只用了几百兆。但你不可能把 20 个这样的应用塞到一台服务器上,因为它们可能运行着不同版本的操作系统、不同版本的 Java、不同的系统库。这些应用之间会相互冲突,就像几个人想住同一间宿舍,但一个要早睡、一个要开夜车、一个怕吵,最后谁都过不好。所以运维只能给每个应用单独分配一台物理机,CPU 大部分时间都在空转。
第二,交付和迁移成本高。当一台物理机要下线维护,或者要把应用从 A 机房迁移到 B 机房时,运维需要做大量的准备工作:备份数据、准备新机器、安装操作系统、安装各种依赖、配置网络、恢复数据、测试验证。整个流程可能持续几天甚至几周。
第三,环境不一致是常态。开发人员用的是自己的笔记本或者公司统一配的台式机,操作系统可能是 Windows、macOS、Ubuntu 的某个版本;测试环境用的是一台装了 CentOS 的物理机;生产环境则是另一台物理机,配置可能又不一样。同一个应用,在三个环境里跑出来的行为可能截然不同。
这三个痛点在 2000 年代初期开始变得越来越突出。当时互联网公司迅速崛起,Web 应用的用户量从几万级跳到百万级、千万级,对服务器数量的需求呈指数级增长。如果继续按"一个应用一台物理机"的方式扩张,机房会爆炸,预算也会爆炸。于是,虚拟化技术应运而生。
1.2.2 虚拟化时代:VMware 与 OpenStack 开启的新篇章
虚拟化(Virtualization)的核心思想很简单:在一台物理机上,通过软件模拟出多个相互隔离的"虚拟计算机",每个虚拟计算机都有自己的虚拟 CPU、虚拟内存、虚拟磁盘、虚拟网卡。运行在这些虚拟计算机上的操作系统以为自己独占了一台物理机,但实际上它们共享底层硬件资源。
这个想法并不是 2000 年代才出现的。早在 1960 年代,IBM 就在大型机上实现了类似的虚拟化能力。但虚拟化技术真正大规模进入企业数据中心,是从 VMware 在 2001 年前后推出 VMware Workstation 和 VMware ESX Server 开始的。后来 Xen、KVM、Hyper-V 等虚拟化方案陆续出现,Linux 基金会孵化的 OpenStack 项目又把虚拟化资源的调度和管理能力提升到了一个新的高度。
虚拟化时代解决了物理机时代的几个核心痛点。
首先,资源利用率大幅提升。一台配置较强的物理服务器,可以同时跑 5 到 20 个虚拟机,每个虚拟机运行一个独立的应用。CPU 和内存得到了更充分的利用,硬件成本显著下降。
其次,环境隔离得到加强。每个虚拟机有自己独立的操作系统、独立的文件系统、独立的网络栈,应用之间的冲突被大大减少。你可以在同一台物理机上同时跑一个 CentOS 5 的应用和一个 CentOS 7 的应用,它们互不干扰。
再次,交付和迁移更加灵活。虚拟机本质上就是几个大文件:虚拟磁盘文件、配置文件、内存快照等。复制这几个文件,就相当于复制了一台完整的服务器。VMware 的 vMotion 技术甚至允许在不中断服务的情况下,把一台正在运行的虚拟机从一台物理机迁移到另一台物理机。
但是虚拟化并不是银弹。它在解决老问题的同时,也带来了新的挑战。
第一,资源开销仍然较大。每个虚拟机都要运行一个完整的操作系统,这个操作系统本身就要消耗几 GB 的磁盘空间、几百 MB 的内存、相当一部分 CPU 资源来运行系统服务和内核。如果一台物理机上跑 10 个虚拟机,就相当于有 10 个操作系统在同时运行,开销是实实在在的。
第二,启动速度慢。一个完整的操作系统从启动到可用,通常需要 30 秒到几分钟。虚拟机虽然比物理机快一些,但启动过程仍然涉及 BIOS 自检、内核加载、驱动初始化、服务启动等一长串步骤。
第三,管理和编排复杂。当虚拟机的数量从几十台增长到几百台、几千台时,单纯靠人工管理已经不现实。虽然 OpenStack、vSphere 等工具提供了一定的自动化能力,但配置复杂、学习曲线陡峭,对于中小团队来说门槛仍然太高。
虚拟化时代解决了"硬件资源如何被多个应用共享"的问题,但并没有很好地解决"应用如何快速、一致、可靠地交付"的问题。这正是容器技术要登场的舞台。
1.2.3 容器时代的开端:Linux 内核能力的厚积薄发
容器并不是一个全新的概念。Unix 系统的 chroot、FreeBSD 的 Jail、Solaris 的 Zones,都是早期容器思想的体现。但容器技术真正走向大众,要等到 2013 年 3 月,Docker 公司(前身是 dotCloud)发布了开源的 Docker 引擎。
Docker 的成功不是凭空出现的。它背后是 Linux 内核已经积累多年的一系列能力:cgroups(控制组,用来限制和隔离进程的资源使用)、namespaces(命名空间,用来隔离进程的网络、文件系统、进程号、主机名等视图)、UnionFS(联合文件系统,用来把多个文件系统层叠加挂载为一个统一的视图)。这些能力在 2000 年代中后期就在 Linux 内核中逐步成熟,但很长一段时间里,直接使用它们来构建容器需要写大量复杂的脚本和配置文件。
Docker 做的事情,本质上是把这些底层能力封装成了一个简单易用的工具链。开发者只需要写一个 `Dockerfile`,用几行声明式的语法描述清楚应用需要什么样的环境,然后执行 `docker build` 命令,就能把应用和它的依赖打包成一个标准化的"镜像"(image)。这个镜像可以推送到镜像仓库,在任何安装了 Docker 引擎的机器上执行 `docker run` 就能跑起来。整个过程简单到让人难以置信。
容器化时代解决了虚拟化时代没有解决好的几个问题。
第一,资源开销极小。容器共享宿主机的操作系统内核,不需要运行一个完整的 guest OS。一个典型的 Linux 容器镜像可能只有几十 MB,启动时间通常在 1 秒以内。同一台物理机上可以轻松运行几十甚至上百个容器。
第二,环境一致性得到根本保证。镜像里包含了应用运行所需的一切:操作系统的基础文件系统、运行时(如 Java、Python、Node.js)、应用代码、配置文件、环境变量、启动命令。无论这台机器是开发人员的笔记本,还是测试环境的虚拟机,还是生产环境的物理机,跑出来的行为都是一致的,因为它们运行的是同一个镜像。
第三,标准化了交付物。在容器时代之前,"应用"的交付物是模糊的——可能是一份压缩包、一段编译命令、一个部署脚本。容器把交付物明确为"镜像 + 配置",任何能运行 Docker 的地方,都能跑这个应用。
如果用一句话来概括部署模式的演进,那就是:物理机时代是把应用部署到机器上,虚拟化时代是把操作系统部署到机器上,容器时代是把运行环境部署到机器上。每一步演进,抽象层次都更高一层,对应用本身的关注度都更强一层。
1.3 Docker 与传统虚拟机的本质区别
理解容器和虚拟机的区别,是真正掌握容器技术的基石。市面上有不少关于这个话题的资料,但很多只是停留在"容器轻、虚拟机重"这种表层对比上。我们这一节要做更深入一些,从架构层面把两者的差异讲透。
1.3.1 架构层面的差异:内核共享 vs 硬件虚拟化
我们用一张架构对比图来直观展示两者的差异。图 1-1 展示了物理机、虚拟机、容器三种部署模式在层次结构上的根本区别。
```mermaid flowchart TD A1[应用 A] --> B1[容器引擎 Docker] A2[应用 B] --> B1 A3[应用 C] --> B1 B1 --> C1[Host OS 内核] A4[应用 A] --> B2[Guest OS 1] A5[应用 B] --> B3[Guest OS 2] A6[应用 C] --> B4[Guest OS 3] B2 --> D1[Hypervisor 虚拟化层] B3 --> D1 B4 --> D1 D1 --> E1[Host OS] E1 --> F1[物理硬件] C1 --> F1 ```
从这张图可以看出,虚拟机走的是硬件虚拟化路线,而容器走的是操作系统层虚拟化路线。
具体来说,虚拟机在硬件层和操作系统层之间插入了一个叫 Hypervisor 的软件层。Hypervisor 的职责是模拟出多个独立的虚拟硬件环境,每个虚拟机都认为自己独占了 CPU、内存、磁盘、网卡。Hypervisor 之上,每个虚拟机都要运行一个完整的客户操作系统(Guest OS),比如 CentOS、Ubuntu、Windows Server 等。这些 Guest OS 都有自己的内核、独立启动的 init 进程、独立管理的系统服务。应用运行在 Guest OS 之上,和真实物理机上跑应用没有本质区别。
容器则完全不同。容器没有 Hypervisor 这一层,也没有 Guest OS。所有的容器共享宿主机(Host)的同一个操作系统内核,容器引擎(Docker、containerd 等)直接运行在 Host OS 之上。容器引擎利用 Linux 内核的 cgroups 和 namespaces 能力,为每个容器构造出独立的"视图":独立的进程树、独立的网络栈、独立的文件系统挂载点、独立的用户和用户组。容器内的进程以为自己运行在一个独立的操作系统里,但实际上它和宿主机上的其他进程共享同一个内核。
这种架构差异带来了几个重要的工程后果。
第一,资源占用不同。一个空的 CentOS 虚拟机镜像通常有 1-2 GB,因为它要包含完整的操作系统;一个空的 Ubuntu 容器镜像通常只有 50-100 MB,因为它只包含必要的文件系统层和元数据,不包含内核。内存方面,虚拟机需要为每个 Guest OS 分配独立的内存空间(哪怕应用不用),而容器只在应用真正需要时才占用内存。
第二,启动速度不同。虚拟机启动需要经历 BIOS、引导加载器、内核初始化、init 进程、系统服务启动这一长串流程,通常需要 30 秒到几分钟。容器启动本质上就是启动一个进程(或者几个进程),通常在 1 秒以内。
第三,隔离强度不同。虚拟机的隔离发生在硬件层,Guest OS 之间的隔离非常彻底,一个虚拟机内核崩溃不会影响其他虚拟机。容器的隔离发生在操作系统层,多个容器共享同一个内核,如果某个容器因为漏洞逃逸出 namespace,理论上可能影响宿主机。容器可以通过 seccomp、AppArmor、SELinux 等安全机制加强隔离,但默认的隔离强度不如虚拟机。
第四,内核兼容性约束。容器必须和宿主机使用相同的内核(或者兼容的内核)。如果你想跑一个 Windows 容器,就不能在 Linux 宿主机上直接跑;反过来也一样。这一点和虚拟机的灵活性形成对比——你可以在 Linux 宿主机上跑 Windows 虚拟机,因为 Hypervisor 模拟了完整的硬件。
1.3.2 性能损耗的真实对比
很多初学者会问:容器的性能是不是比虚拟机好?答案是:在大多数场景下,是的;但并不是因为容器有什么黑魔法,而是因为它少了一层抽象。
以 CPU 计算为例。在虚拟机里,Guest OS 看到的虚拟 CPU 是由 Hypervisor 模拟的。当 Guest OS 执行一条 CPU 指令时,Hypervisor 需要拦截这条指令、判断它应该映射到哪个物理 CPU 上执行,这个过程会带来一定的性能损耗。Intel 和 AMD 后来推出了硬件辅助虚拟化技术(VT-x、AMD-V),让大多数 CPU 指令可以直接在物理 CPU 上执行,性能损耗大大降低,但仍然不是零。
在容器里,应用直接通过系统调用和宿主机内核交互,没有中间的模拟和映射层。从 CPU 指令执行的角度看,容器内的进程和宿主机上的普通进程几乎没有任何区别。
对于 I/O 密集型应用(比如网络服务、数据库),容器的优势更明显。因为虚拟机的 I/O 路径更长:Guest OS 发起 I/O 请求 → 虚拟设备驱动 → Hypervisor 拦截 → 物理设备驱动 → 真实硬件。容器的 I/O 路径则要短得多:进程发起系统调用 → 宿主机内核 → 真实硬件。
当然,这一切并不意味着容器在所有场景下都比虚拟机好。容器的设计目标是"快速、密集、可编排",最适合微服务、Web 应用、批处理任务等场景。对于那些对安全性隔离要求极高、需要运行不同内核、或者要承载传统单体应用的场景,虚拟机仍然是更稳妥的选择。
1.3.3 一个容易混淆的概念:容器不是轻量级虚拟机
最后要特别强调一点:容器不是轻量级虚拟机。这是一个流传很广但具有误导性的说法。容器和虚拟机从设计哲学、隔离机制、适用场景上都存在根本差异。容器更像是"一个有边界的进程",虚拟机才是"一台虚拟出来的计算机"。
理解这一点非常重要,因为它会直接影响你对容器、容器编排、容器网络的理解方式。如果你的思维模型是"容器就是虚拟机",你会不自觉地用管理虚拟机的方式去管理容器,结果会发现很多概念对不上号。如果你一开始就把容器理解为"一个运行在受限环境中的进程",那么后面学习 Kubernetes 这样的编排系统时会顺畅很多。
1.4 容器化思维:一次构建,处处运行的工程哲学
技术本身并不复杂,复杂的是技术背后折射出的工程思维。容器化之所以能引发一场基础设施的革命,根本原因不在于 cgroups 和 namespaces 这些内核能力有多巧妙,而在于它重新定义了"应用"这个概念的边界。
1.4.1 重新定义"应用"的边界
在传统思维里,一个应用就是一堆源代码。你把它从代码仓库拉下来,编译,配置,部署到服务器上,它才变成一个可运行的服务。在这个流程里,"应用"和"运行环境"是分离的。运行环境由运维负责,应用由开发负责,两者之间的衔接靠的是一份"部署文档"或者一段"部署脚本"。
这种分离带来一个根本问题:应用的边界是模糊的。一段 Java 代码要能跑起来,需要什么?需要 JRE 或者 JDK;JRE 又依赖 glibc;glibc 又依赖操作系统的某些底层库;操作系统的版本、CPU 的架构、内核参数都会影响程序的运行。这些东西有多少应该由开发负责?有多少应该由运维负责?答案在不同的团队、不同的项目里都不一样。
容器化思维给出了一个非常清晰的答案:应用的边界就是镜像的边界。
当你把一个应用容器化时,你要在 `Dockerfile` 里明确声明:这个应用要基于什么基础镜像(`FROM`),需要安装哪些系统依赖(`RUN apt-get install ...`),需要复制哪些代码进去(`COPY`),启动命令是什么(`CMD`