Published at 2023-03-02 | Last Update 2023-03-02
本文翻译自 Borg, Omega, and Kubernetes, acmqueue Volume 14,issue 1(2016), 原文副标题为 Lessons learned from three container-management systems over a decade。 作者 Brendan Burns, Brian Grant, David Oppenheimer, Eric Brewer, and John Wilkes, 均来自 Google。
文章介绍了 Google 在过去十多年设计和使用前后三代容器管理(编排)系统的所得所思, 虽然是 7 年前的文章,但内容并不过时,尤其能让读者能更清楚地明白 k8s 里的很多架构、功能和设计是怎么来的。
由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。
以下是译文。
业界这几年对容器的兴趣越来越大,但其实在 Google,我们十几年前就已经开始大规模容器实践了, 这个过程中也先后设计了三套不同的容器管理系统。 这三代系统虽然出于不同目的设计,但每一代都受前一代的强烈影响。 本文介绍我们开发和运维这些系统所学习到的经验与教训。
Google 第一代统一容器管理系统,我们内部称为 Borg 7。
Borg 既可以管理 long-running service 也可以管理 batch job; 在此之前,这两种类型的任务是由两个系统分别管理的,
Global Work Queue 主要面向 batch job,但它强烈影响了 Borg 的架构设计;
需要说明的是,不论是我们设计和使用 Global Work Queue 还是后来的 Borg 时, Linux cgroup 都还没有出现。
Borg 实现了 long-running service 和 batch job 这两种类型的任务共享计算资源, 提升了资源利用率,降低了成本。
在底层支撑这种共享的是Linux 内核中新出现的容器技术(Google 给 Linux 容器技术贡献了大量代码),它能实现延迟敏感型应用和 CPU 密集型批处理任务之间的更好隔离。
随着越来越多的应用部署到 Borg 上,我们的应用与基础设施团队开发了大量围绕 Borg 的管理工具和服务,功能包括:
也就是产生了一个围绕 Borg 软件生态,但驱动这一生态发展的是 Google 内部的不同团队, 因此从结果来看,这个生态是一堆异构、自发的工具和系统(而非一个有设计的体系), 用户必须通过几种不同的配置语言和配置方式来和 Borg 交互。
虽然有这些问题,但由于其巨大的规模、出色的功能和极其的健壮性,Borg 当前仍然是 Google 内部主要的容器管理系统。
为了使 Borg 的生态系统更加符合软件工程规范,我们又开发了 Omega6,
Omega 继承了许多已经在 Borg 中经过验证的成功设计,但又是完全从头开始开发, 以便架构更加整洁一致。
这种解耦使得 Borgmaster 的功能拆分为了几个彼此交互的组件, 而不再是一个单体的、中心式的 master,修改和迭代更加方便。 Omega 的一些创新(包括多调度器)后来也反向引入到了 Borg。
Google 开发的第三套容器管理系统叫 Kubernetes4。
开发这套系统的背景:
因此与 Borg 和 Omega 不同的是:Kubernetes 是开源的,不是 Google 内部系统。
接下来的内容将介绍我们在设计和使用以上三代容器管理系统时学到的经验和教训。
容器管理系统属于上层管理和调度,在底层支撑整个系统的,是 Linux 内核的容器技术。
chroot
到 cgroupschroot
);cgroups
) 吸收了这些理念,成为集大成者。
内核 cgroups 子系统今天仍然处于活跃开发中。容器技术提供的资源隔离(resource isolation)能力,使 Google 的资源利用率远高于行业标准。 例如,Borg 能利用容器实现延迟敏感型应用和CPU 密集型批处理任务的混布(co-locate), 从而提升资源利用率,
容器提供的资源管理工具使以上需求成为可能,再加上强大的内核资源隔离技术, 就能避免这两种类型任务的互相干扰。我们是开发 Borg 的过程中,同步给 Linux 容器做这些技术增强的。
但这种隔离并未达到完美的程度:容器无法避免那些不受内核管理的资源的干扰,例如三级缓存(L3 cache)、 内存带宽;此外,还需要对容器加一个安全层(例如虚拟机)才能避免公有云上各种各样的恶意攻击。
现代容器已经不仅仅是一种隔离机制了:还包括镜像 —— 将应用运行所需的所有文件打包成一个镜像。
在 Google,我们用 MPM (Midas Package Manager) 来构建和部署容器镜像。 隔离机制和 MPM packages 的关系,就像是 Docker daemon 和 Docker image registry 的关系。在本文接下来的内容中,我们所说的“容器”将包括这两方面, 即运行时隔离和镜像。
随着时间推移,我们意识到容器化的好处不只局限于提升资源利用率。
容器化使数据中心的观念从原来的面向机器(machine oriented) 转向了面向应用(application oriented),
Management API 的这种从面向机器到面向应用的转变,显著提升了应用的部署效率和问题排查能力。
资源隔离能力与容器镜像相结合,创造了一个全新的抽象:
这种镜像和操作系统的解耦,使我们能在开发和生产环境提供相同的部署环境; 这种环境的一致性提升了部署可靠性,加速了部署。 这层抽象能成功的关键,是有一个hermetic(封闭的,不受外界影响的)容器镜像,
/proc
、ioctl
参数等等产生很大的暴露面。
我们希望 Open Container Initiative
等工作可以进一步明确容器抽象的 surface area。虽然存在不完美之处,但容器提供的资源隔离和依赖最小化特性,仍然使得它在 Google 内部非常成功, 因此容器成为了 Google 基础设施唯一支持的可运行实体。这带来的一个后果就是, Google 内部只有很少几个版本的操作系统,也只需要很少的人来维护这些版本, 以及维护和升级服务器。
实现 hermetic image 有多种方式,
在 Borg 中,程序可执行文件在编译时会静态链接到公司托管的特定版本的库5;
但实际上 Borg container image 并没有做到完全独立:所有应用共享一个所谓的
base image,这个基础镜像是安装在每个 node 上的,而非打到每个镜像里去;
由于这个基础镜像里包含了 tar
libc
等基础工具和函数库,
因此升级基础镜像时会影响已经在运行的容器(应用),偶尔会导致故障。
Docker 和 ACI 这样的现代容器镜像在这方面做的更好一些,它们地消除了隐藏的 host OS 依赖, 明确要求用户在容器间共享镜像时,必须显式指定这种依赖关系,这更接近我们理想中的 hermetic 镜像。
围绕容器而非机器构建 management API,将数据中心的核心从机器转移到了应用,这带了了几方面好处:
容器能提供一些通用的 API 注册机制,使管理系统和应用之间无需知道彼此的实现细节就能交换有用信息。
/healthz
endpoint 向 orchestrator 汇报应用状态,当检测到一个不健康的应用时,
就会自动终止或重启对应的容器。这种自愈能力(self-healing)是构建可靠分布式系统的最重要基石之一。容器还能提供或展示其他一些信息。例如,
annotation
,
存储在每个 object metadata 中,可以用来传递应用结构(application structure)信息。
这些 annotations 可以由容器自己设置,也可以由管理系统中的其他组件设置(例如发布系统在更新完容器之后更新版本号)。容器管理系统还可以将 resource limits、container metadata 等信息传给容器, 使容器能按特定格式输出日志和监控数据(例如用户名、job name、identity), 或在 node 维护之前打印一条优雅终止的 warning 日志。
容器还能用其他方式提供面向应用的监控:例如, cgroups 提供了应用的 resource-utilization 数据;前面已经介绍过了, 还可以通过 export HTTP API 添加一些自定义 metrics 对这些进行扩展。
基于这些监控数据就能开发一些通用工具,例如 auto-scaler 和 cAdvisor3,
它们记录和使用这些 metrics,但无需理解每个应用的细节。
由于应用收敛到了容器内,因此就无需在宿主机上分发信号到不同应用了;这更简单、更健壮,
也更容易实现细粒度的 metrics/logs 控制,不用再
ssh
登录到机器执行 top
排障了 —— 虽然开发者仍然能通过 ssh
登录到他们的
容器,但实际中很少有人这样做。
监控只是一个例子。面向应用的转变在管理基础设施(management infrastructure)中产生涟漪效应:
到目前为止我们关注的都是 application:container = 1:1
的情况,
但实际使用中不一定是这个比例。我们使用嵌套容器,对于一个应用:
alloc
,在 K8s 中成为 pod
;实际上 Borg 还允许不使用 allocs,直接创建应用 container;但这导致了一些不必要的麻烦, 因此 K8s 就统一规定应用容器必须运行在 pod 内,即使一个 pod 内只有一个容器。 常见方式是一个 pod hold 一个复杂应用的实例。
相比于把所有功能打到一个二进制文件,这种方式能让不同团队开发和管理不同功能,好处:
Borg 使得我们能在共享的机器上运行不同类型的 workload 来提升资源利用率。 但围绕 Borg 衍生出的生态系统让我们意识到,Borg 本身只是开发和管理可靠分布式系统的开始, 各团队根据自身需求开发出的围绕 Borg 的不同系统与 Borg 本身一样重要。下面列举其中一些, 可以一窥其广和杂:
开发以上提到的那些服务都是为了解决应用团队面临的真实问题,
K8s 尝试通过引入一致 API 的方式来降低这里的复杂度。例如,每个 K8s 对象都有三个基本字段:
Object Metadata
:所有 object 的 Object Metadata
字段都是一样的,包括
Spec
:用于描述这个 object 的期望状态;
Spec
and Status
的内容随 object 类型而不同。Status
:用于描述这个 object 的当前状态;这种统一 API 提供了几方面好处:
基于前辈 Borg 和 Omega 的经验,K8s 构建在一些可组合的基本构建模块之上,用户可以方便地进行扩展, 通用 API 和 object-metadata 设计使得这种扩展更加方便。 例如,pod API 可以被开发者、K8s 内部组件和外部自动化工具使用。
为了进一步增强这种一致性,K8s 还进行了扩展,支持用户动态注册他们自己的 API, 这些 API 和它内置的核心 API 使用相同的方式工作。 另外,我们还通过解耦 K8s API 实现了一致性(consistency)。 API 组件的解耦考虑意味着上层服务可以共享相同的基础构建模块。一个很好的例子: replica controller 和 horizontal auto-scaling (HPA) 的解耦。
解耦确保了多个相关但不同的组件看起来和用起来是类似的体验,例如,k8s 有三种不同类似的 replicated pods:
ReplicationController
: run-forever replicated containers (e.g., web servers).DaemonSet
: ensure a single instance on each node in the cluster (e.g., logging agents).Job
: a run-to-completion controller that knows how to run a (possibly parallelized) batch job from start to finish.这三种 pod 的策略不同,但这三种 controller 都依赖相同的 pod object 来指定它们希望运行的容器。
我们还通过让不同 k8s 组件使用同一套设计模式来实现一致性。Borg、Omega 和 k8s 都用到了 reconciliation controller loop 的概念,提高系统的容错性。
首先对观测到的当前状态(“当前能找到的这种 pod 的数量”)和期望状态(“label-selector
应该选中的 pod 数量”)进行比较;如果当前状态和期望状态不一致,则执行相应的行动
(例如扩容 2 个新实例)来使当前状态与期望相符,这个过程称为 reconcile
(调谐)。
由于所有操作都是基于观察(observation)而非状态机, 因此 reconcile 机制非常健壮:每次一个 controller 挂掉之后再起来时, 能够接着之前的状态继续工作。
K8s 的设计综合了 microservice 和 small control loop 的理念,这是 choreography(舞蹈编排)的一个例子 —— 通过多个独立和自治的实体之间的协作(collaborate)实现最终希望达到的状态。
舞蹈编排:场上没有指挥老师,每个跳舞的人都是独立个体,大家共同协作完成一次表演。 代表分布式、非命令式。
我们特意这么设计,以区别于管弦乐编排中心式编排系统(centralized orchestration system),后者在初期很容易设计和开发, 但随着时间推移会变得脆弱和死板,尤其在有状态变化或发生预期外的错误时。
管弦乐编排:场上有一个指挥家,每个演奏乐器的人都是根据指挥家的命令完成演奏。 代表集中式、命令式。
这里列举一些经验教训,希望大家要犯错也是去犯新错,而不是重复踩我们已经踩过的坑。
在 Borg 中,容器没有独立 IP,所有容器共享 node 的 IP。因此, Borg 只能在调度时,给每个容器分配唯一的 port。 当一个容器漂移到另一台 node 时,会获得一个新的 port(容器原地重启也可能会分到新 port)。 这意味着,
53
端口)这样的传统服务,只能用一些内部魔改的版本;IP:port
。因此在设计 k8s 时,我们决定给每个 pod 分配一个 IP,
此外,所有公有云平台都提供 IP-per-pod 的底层能力;在 bare metal 环境中,可以使用 SDN overlay 或 L3 routing 来实现每个 node 上多个 IP 地址。
用户一旦习惯了容器开发方式,马上就会创建一大堆容器出来, 因此接下来的一个需求就是如何对这些容器进行分组和管理。
Borg 提供了 jobs 来对容器名字相同的 tasks 进行分组。
例如,
作为对比,k8s 主要使用 labels 来识别一组容器(groups of containers)。
role=frontend
和 stage=production
两个 label,表明这个容器运行的是生产环境的前端应用;可以通过 label selector
(例如,stage==production && role==frontend
)来选中一组 objects;
在某些场景下,能精确(静态)知道每个 task 的 identity 是很有用的(例如,静态分配 role 和 sharding/partitioning), 在 k8s 中,通过 label 方式也能实现这个效果,只要给每个 pod 打上唯一 label 就行了, 但打这种 label 就是用户(或 k8s 之上的某些管理系统)需要做的事情了。
Labels 和 label selectors 提供了一种通用机制, 既保留了 Borg 基于 index 索引的能力,又获得了上面介绍的灵活性。
在 Borg 中,task 并不是独立于 job 的存在:
这种方式很方便,但有一个严重不足:Borg 中只有一种 grouping 机制,也就是前面提到的 vector index 方式。例如,一个 job 需要存储某个配置参数,但这个参数只对 service 或 batch job 有用,并不是对两者都有用。 当这个 vector index 抽象无法满足某些场景(例如, DaemonSet 需要将一个 pod 在每个 node 上都起一个实例)时, 用户必须开发一些 workaround。
Kubernetes 中,那些 pod-lifecycle 管理组件 —— 例如 replication controller —— 通过 label selector 来判断哪些 pod 归自己管; 但这里也有个问题:多个 controller 可能会选中同一个 pod,认为这个 pod 都应该归自己管, 这种冲突理应在配置层面解决。
label 的灵活性带来的好处:例如, controller 和 pod 分离意味着可以 "orphan" 和 "adopt" container。 考虑一个 service, 如果其中一个 pod 有问题了,那只需要把相应的 label 从这个 pod 上去掉, k8s service 就不会再将流量转发给这个 pod。这个 pod 不再接生产流量,但仍然活在线上,因此就可以对它进行 debug 之类的。 而与此同时,负责管理这些 pod 的 replication controller 就会立即再创建一个新的 pod 出来接流量。
Borg、Omega 和 k8s 的一个核心区别是它们的 API 架构。
Borgmaster 是一个单体组件,理解每个 API 操作的语义:
Omega 除了 store 之外没有中心式组件,
k8s 在 Omega 的分布式架构和 Borg 的中心式架构之间做了一个折中,
虽然我们已经了十几年的大规模容器管理经验,但仍然有些问题还没有很好的解决办法。 本节介绍几个供讨论,集思广益。
在我们面临的所有问题中,耗费了最多脑力、头发和代码的是管理配置(configurations)相关的。 这里的配置指的是应用配置,即如何把应用的参数在创建容器时传给它, 而不是 hard-code。这个主题值得单独一整篇文章来讨论,这里仅 highlight 几方面。
首先,Borg 仍然缺失的那些功能,最后都能与应用配置(application configuration)扯上关系。 这些功能包括:
为了满足这些需求,配置管理系统倾向于发明一种 domain-specific 语言, 并希望最终成为一门图灵完备的配置语言:解析配置文件,提取某些数据,然后执行一些计算。 例如,根据一个 service 的副本数量,利用一个函数自动调整分给一个副本的内存。 用户的需求是减少代码中的 hardcode 配置,但最终的结果是一种难以理解和使用的“配置即代码”产品,用户避之不及。 它没有减少运维复杂度,也没有使配置的 debug 变得更简单;它只是将计算从一门真正的 编程语言转移到了一个 domain-specific 语言,而后者通常的配置开发工具更弱 (例如 debuggers, unit test frameworks 等)。
我们认为最有效的方式是接受这个需求,承认
programmatic configuration
的不可避免性,
在计算和数据之间维护一条清晰边界。
表示数据的语言应该是简单、data-only 的格式,例如 JSON or YAML,
而针对这些数据的计算和修改应该在一门真正的编程语言中完成,后者有
完善的语义和配套工具。
有趣的是,这种计算与数据分离的思想已经在其他领域开始应用,例如一些前端框架的开发, 比如 Angular 在 markup (data) 和 JavaScript (computation) 之间。
上线一个新服务通常也意味着需要上线一系列相关的服务(监控、存储、CI/CD 等等)。 如果一个应用依赖其他一些应用,那由集群管理系统来自动化初始化后者(以及它们的依赖)不是很好吗?
但事情并没有这么简单:自动初始化依赖(dependencies)并不是仅仅启动一个新实例 —— 例如,可能还需要将其注册为一个已有服务的消费者, (e.g., Bigtable as a service),以及将认证、鉴权和账单信息传递给这些依赖系统。
但几乎没有哪个系统收集、维护或暴露这些依赖信息,因此即使是一些常见场景都很难在基础设施层实现自动化。 上线一个新应用对用户来说仍然是一件复杂的事情,导致开发者构建新服务更困难, 经常导致最新的最佳实践无法用上,从而影响新服务的可靠性。
一个常见问题是:如果依赖信息是手工提供的,那很难维持它的及时有效性, 与此同时,自动判断(例如,通过 tracing accesses)通常会失败,因为无法捕捉理解相应结果的语义信息。 (Did that access have to go to that instance, or would any instance have sufficed?)
一种可能的解决方式是:每个应用显式声明它依赖的服务,基础设施层禁止它访问除此之外的所有服务 (我们的构建系统中,编译器依赖就是这么做的1)。 好处就是基础设施能做一些有益的工作,例如自动化设置、认证和连接性。
不幸的是,表达、分析和使用系统依赖会导致系统的复杂性升高, 因此并没有任何一个主流的容器管理系统做了这个事情。我们仍然希望 k8s 能成为一个构建此类工具的平台,但这一工作目前仍困难重重。
过去十多年开发容器管理系统的经历教会了我们很多东西,我们也将这些经验用到了 k8s —— Google 最新的容器管理系统 —— 的设计中, 它的目标是基于容器提供的各项能力,显著提升开发者效率,以及使系统管理(不管是手动还是自动)更加方便。 希望大家能与我们一道,继续完善和优化它。