Kubernetes 怎样用容器重现虚拟机
目录
把 Kubernetes 的核心概念对应回虚拟机时代的架构构件,Pod 对应一台跑着几个进程的机器,Deployment 对应一组配置相同的机器,Service 对应挂在这组机器前面的稳定名字与负载均衡。这是 Ivan Velichko 在 iximiuz 上一篇系统解析(现已改版为 iximiuz Labs 的交互教程)给出的核心洞察。从这个视角切入,正好可以完整梳理软件部署在过去十余年里是怎样一步步演进到今天的。
虚拟机时代:单机 Box 与分布式运维瓶颈#
2010 年前后,业界主流的部署方式是一台 Linux 虚拟机(VM)。一台机器里往往同时跑着 Web 应用进程、前面的 Nginx 或 Apache 反向代理(负责处理 TLS 终结与静态资源缓存),以及旁边负责日志收集、监控打点或定时任务的辅助 daemon。这样一台机器构成了一个完整的业务实例,即原文所称的「Box」。整个服务则是分布在网络中的一组相同 Box,前面挂一个外部负载均衡器或域名。
这种模式的好处是直观且自洽:多个协同进程天然共享同一套网络栈、同一组挂载目录与 localhost,进程间通信与端口绑定没有任何网络虚拟化屏障。
然而,当业务规模扩大、实例数量从几台增至数百台时,基于 VM 的运维开始遇到瓶颈:
- 环境一致性与依赖漂移:机器初始化(Provisioning)需要安装特定版本的 OS 包和运行库。尽管有 Puppet、Chef、Ansible 等工具批量执行脚本,但长期运行的宿主机不可避免会出现配置漂移,「在预发机器上能跑,到生产机器跑不起来」是当时的典型故障。
- 扩缩容笨拙与部署复杂:启动一台完整的虚拟机需要数分钟,面对突发流量扩容效率很低。发布新代码时,需要跨机器同步文件、停止进程、更新符号链接或重启,极易因为个别机器网络抖动导致集群版本状态不一致。
- 服务发现的高维护成本:为了让动态变动的机器组对外暴露,团队必须自建并维护像 ZooKeeper 或 Consul 这样的分布式协调与服务发现集群,运维门槛和机器成本都不低(正如原文的反问:「在大规模集群下维护过 ZooKeeper 或 Consul 吗?」)。
Docker 的飞跃与单进程容器的断层#
2013 年 Docker 的兴起改变了软件交付方式。利用 Linux 内核的 cgroups、Namespace 和联合文件系统,容器把应用程序及其所有依赖封装成了不可变镜像。
这一跃迁消除了环境一致性问题:同一份镜像在本地开发机、测试环境和生产服务器上行为完全一致。同时,容器消除了硬件虚拟化的 Hypervisor 损耗,启动时间从分钟级降到秒级,单机密度大幅提升。
但伴随 Docker 普及的官方理念打破了既有的服务架构模式:Docker 官方推崇「一个容器只跑一个主进程」,并明确不鼓励在容器内部运行 systemd 这类 init 进程或多服务管理工具。
这一单进程理念直接影响了生产环境的实践。一个原先由反向代理、主 Web 进程与辅助 daemon 共同组成的「Box」,在容器世界里被迫拆成了两到三个独立的容器。
[Nginx + Web 应用 + 辅助 Daemon]
天然共享单一 IP 与 localhost"] end VM -.单进程容器拆分.-> Docker subgraph Docker["Docker 时代:容器碎片化"] direction LR subgraph Inst1["实例 1(松散容器)"] direction TB C1["Nginx 容器"] -.共享网络.-> C2["Web 容器"] C3["Daemon 容器"] end subgraph Inst2["实例 2(松散容器)"] direction TB C4["Nginx 容器"] -.共享网络.-> C5["Web 容器"] C6["Daemon 容器"] end Notice["扩缩容需 3 个容器同步进退
缺乏统一的实例抽象边界"] end
这种拆分带来了新的抽象断层:
- 协同边界丢失:如果要部署两个服务实例,原先是拉起两台 VM,现在却要分别调度、绑定和管理六个容器。反向代理与业务容器之间必须共享网络栈(
localhost),新旧版本发布时必须保持同步切换。如果业务容器更新了端口,旧版反向代理容器就会转发失败。 - 缺乏原子扩缩原语:Docker 引擎本身只管理单机上的单容器生命周期,没有成组管理多容器的原生能力。
当时业界面临这一断层,分化出了两种主流应对方案:
- 一种是「胖容器(Fat Container)」的实用主义做法:比如 Phusion 团队维护的
baseimage-docker,或者通过 supervisord 在容器内管理多个子进程,把容器当成轻量 VM 用,保全了实例完整性; - 另一种则是拼装胶水调度工具:比如利用 CoreOS Fleet 的 systemd 单元依赖做同机亲和调度,或者像 Yelp 自研 PaaSTA(封装 Mesos 与 Docker)来协调关联容器。但这些方案通用性有限,跨节点原子扩缩容依然容易出错。
正如 Ivan Velichko 在文中所指出的,当时的容器生态明显缺失了一层关键抽象:它既要有容器的轻量性,又要有经典虚拟机 Box 的表达力。
Kubernetes 的破局:用容器原语重构虚拟机模型#
Kubernetes 之所以最终成为容器编排的标准,关键在于它没有执着于单进程容器的教条,选择承认虚拟机时代「单机多进程协同」的合理性,用三层核心对象将经典服务模型重做了一遍:Pod 在单机尺度重构了 Box,Deployment 在集群尺度重构了机器组的自动化发版与扩缩,Service 在入口尺度重构了网络拓扑与服务发现。
foo.default.svc.cluster.local
内置 DNS 解析 + 稳定 VIP + 负载均衡"] Deploy["Deployment (app=foo)
声明 replicas: 3 + Pod 模板
管理调度、故障自愈与滚动更新"] Svc -->|"按标签路由"| Pods Deploy -.维护 3 副本.-> Pods subgraph Pods["工作节点与 Pod 副本 (app=foo)"] direction TB subgraph P1["Node A: Pod 1"] direction LR P1N["Nginx (sidecar)"] <-->|"localhost"| P1A["App (main)"] end subgraph P2["Node B: Pod 2"] direction LR P2N["Nginx (sidecar)"] <-->|"localhost"| P2A["App (main)"] end subgraph P3["Node C: Pod 3"] direction LR P3N["Nginx (sidecar)"] <-->|"localhost"| P3A["App (main)"] end end end
Pod:重现单机多进程协同#
Kubernetes 把 Pod 作为最小调度单位。一个 Pod 可以包含多个紧密耦合的容器(一个主业务容器配合若干 sidecar 容器)。
在底层,同一个 Pod 内的所有容器共享同一个 Network Namespace(拥有同一个 IP 地址,通过 localhost 互通)和 IPC Namespace,还能通过共享 Volume 读写同一套临时文件或套接字;与此同时,每个容器依然拥有独立的根文件系统和镜像版本。
这种设计让 Nginx 反向代理、业务主容器与日志采集 daemon 能够像以前在 VM 实例中一样协作,同时保留了各自独立构建与分发的优势。改版后的教程也补充说明了原生 sidecar(Native Sidecar Containers):在 Kubernetes 1.28 引入、1.33 转为稳定特性的机制,进一步从控制器层面解决了 sidecar 与主容器的启停依赖顺序。
Deployment:重现虚拟机集群的声明式运维#
有了新的 Box,接下来的问题是如何把一组 Box 组成弹性集群。
在 VM 时代,扩容需要运维编写脚本调用云厂商 API 申请机器,配置网络并运行配置管理工具;更新时需要编写滚动执行逻辑。Deployment 彻底将这一流程转为声明式:开发者只需声明期望的副本数(replicas)和 Pod 模板,具体的调度、跨节点放置、故障自愈以及版本滚动更新(RollingUpdate)全部交由底层的 Deployment Controller 自动处理。
开发者由此摆脱了物理节点(Node)的琐碎细节,直接面向一组弹性的逻辑实例进行操作。
Service:内置服务发现与动态负载均衡#
一组动态扩缩、漂移的 Pod 实例还需要一个稳定的访问入口。在虚拟机时代,这往往需要独立的 Consul 或 ZooKeeper 集群来注册 IP,并在外部硬件或软件负载均衡器中动态刷新机器池。
Kubernetes 通过 Service 对象将这一能力内置到了集群基础设施中。Service 为后端 Pod 分配一个虚拟的集群内部 IP(ClusterIP)和 CoreDNS 域名。
关键在于,Service 并不直接感知 Deployment,而是通过标签选择器(Label Selector)与 Pod 松耦合。这种解耦让蓝绿发布与金丝雀发布变得自然:两个跑着不同版本镜像的 Deployment,可以通过共享相同的标签同时承接 Service 分发的流量。
这三层职责在历史演进中的映射关系可以归纳为一张对照表:
| 架构职责 | 虚拟机时代(2010 年代) | 单 Docker 容器模型的局限 | Kubernetes 的重构方式 |
|---|---|---|---|
| 单实例(Box) | 单台 VM 跑多进程(App + Nginx + Daemons),走 localhost 通信 |
提倡单进程单容器;多进程协同需手工拼容器,缺乏统一生命周期边界 | Pod:多容器共享网络栈(localhost)与 IPC,保留独立文件系统 |
| 集群扩缩与发版 | 编写 Puppet / Ansible 脚本批量起停机器,跨机发版和回滚复杂 | 容器各自起停;协同容器组难以原子扩缩容 | Deployment:声明式副本数与 Pod 模板,自动跨 Node 调度与滚动更新 |
| 服务入口与发现 | 外部负载均衡器 + 外部 Consul / ZooKeeper 集群维护地址池 | 端口容易冲突,多容器的地址池需要外部工具动态维护 | Service:内置 CoreDNS 与 ClusterIP,按 Label Selector 动态路由 |
托管与自建:复杂度守恒的工程账#
Kubernetes 用统一的声明式 API 重构了虚拟机的运维体验,但系统的本质复杂度并没有凭空消失,而是沉降到了集群控制面。
Ivan 在文中引用了 Kelsey Hightower 的形象比喻:开车的复杂度与修车的复杂度必须分开看。大部分工程师的目标只是开车(把业务 Pod 跑起来),但如果要自己维护整套 Kubernetes 集群,就必须精通修发动机。
在自建集群场景下,运维团队必须处理 etcd 的高可用与磁盘延迟、CNI 网络插件的性能与排障、CSI 存储挂载、证书定期轮换以及集群平滑版本升级。这也是为什么各大公有云的托管 Kubernetes(如 GKE、EKS)迅速占据主流:由云厂商承担修发动机的成本,企业团队才能专注于开车的业务逻辑。
现代反思:部署演进的当下分流#
回望这十余年的演进历程,软件部署并没有在 Kubernetes 这里画上句号。Kubernetes 解决了复杂系统的编排难题,但也带来了较高的认知负载和资源底噪。
今天的软件部署呈现出两条更加轻量的分流:
- 托管容器与 Serverless(如 AWS Fargate、Google Cloud Run):对于大量独立的微服务或无状态 Web 应用,并不需要精细的跨节点调度或自定义网络拓扑。这类平台进一步抹平了集群与节点概念,开发者只提交容器镜像或源码,平台按请求自动伸缩,卸下了编排负担。
- 微型虚拟机(MicroVM,如 AWS Firecracker、Fly.io Machines):硬件虚拟化技术的突破让 VM 的启动时间缩短到毫秒级。MicroVM 既享有传统虚拟机的强硬件隔离、完整内核支持与多进程自由,又具备容器的轻量敏捷。在一些需要强租户隔离或有状态的场景下,部署模型以现代化的方式回到了最初那个轻巧的「Box」。
几点笔记(个人观点)#
- 好的技术抽象往往尊重工程直觉:Docker 的早期理念试图消灭 Box,把一切拆成单一进程;而 Kubernetes 的破局在于它承认了多进程协同是业务的刚性需求,用 Pod 重现了 Box。相比颠覆既有模式,把经受住考验的成熟模式用更轻量的积木重写一遍往往更能获得工程上的成功。
- 控制面复杂度的转移需要谨慎评估:Kubernetes 把过去分散在脚本里的运维逻辑收敛到了声明式控制器里,如果使用成熟托管云服务,这种标准化带来的收益显著。但如果是业务规模小、只有两三台机器的小型项目,自建和维护一套 Kubernetes 集群的开销往往远超过它节省的脚本成本。
- 架构选型应警惕过度设计:今天如果只需要部署一个常规 Web 应用,首选往往是托管容器平台(如 Cloud Run)或轻量托管服务;只有当系统面临复杂的多服务协同、深度定制的调度流水线或多租户隔离时,引入完整的 Kubernetes 生态才具有真正的工程合理性。
参考资料#
- 原文:How Kubernetes Reinvented Virtual Machines - In a Good Sense,Ivan Velichko,iximiuz Labs(初版发在 iximiuz.com,原地址已跳转到这里)
- 原文引用的讨论:Don’t Use Kubernetes, Yet,Matt Rickard
- Sidecar Containers、Kubernetes Components,Kubernetes 文档
- 站内相关文章:梳理 kubernetes-best-practices 仓库,补上 Gateway API 等新特性