背景
美团的容器集群管理平台叫做 HULK。漫威动画里的 HULK 在发怒时会变成“绿巨人”,它的这个特性和容器的“弹性伸缩”很像,所以我们给这个平台起名为 HULK。貌似有一些公司的容器平台也叫这个名字,纯属巧合。
2016 年,美团开始使用容器,当时美团已经具备一定的规模,在使用容器之前就已经存在的各种系统,包括 CMDB、服务治理、监控告警、发布平台等等。我们在探索容器技术时,很难放弃原有的资产。所以容器化的第一步,就是打通容器的生命周期和这些平台的交互,例如容器的申请/创建、删除/释放、发布、迁移等等。然后我们又验证了容器的可行性,证实容器可以作为线上核心业务的运行环境。
2018 年,经过两年的运营和实践探索,我们对容器平台进行了一次升级,这就是容器集群管理平台 HULK 2.0。
把基于 OpenStack 的调度系统升级成容器编排领域的事实标准 Kubernetes(以后简称 K8s)。
提供了更丰富可靠的容器弹性策略。
针对之前在基础系统上碰到的一些问题,进行了优化和打磨。
美团的容器使用状况是:目前线上业务已经超过 3000 个服务,容器实例数超过 30000 个,很多大并发、低延时要求的核心链路服务,已经稳定地运行在 HULK 之上。本文主要介绍我们在容器技术上的一些实践,属于基础系统优化和打磨。
美团容器平台的基本架构
首先介绍一下美团容器平台的基础架构,相信各家的容器平台架构大体都差不多。
首先,容器平台对外对接服务治理、发布平台、CMDB、监控告警等等系统。通过和这些系统打通,容器实现了和虚拟机基本一致的使用体验。研发人员在使用容器时,可以和使用 VM 一样,不需要改变原来的使用习惯。
此外,容器提供弹性扩容能力,能根据一定的弹性策略动态增加和减少服务的容器节点数,从而动态地调整服务处理能力。这里还有个特殊的模块——“服务画像”,它的主要功能是通过对服务容器实例运行指标的搜集和统计,更好的完成调度容器、优化资源分配。比如可以根据某服务的容器实例的 CPU、内存、IO 等使用情况,来分辨这个服务属于计算密集型还是 IO 密集型服务,在调度时尽量把互补的容器放在一起。再比如,我们可以知道某个服务的每个容器实例在运行时会有大概 500 个进程,我们就会在创建容器时,给该容器加上一个合理的进程数限制(比如最大 1000 个进程),从而避免容器在出现问题时,占用过多的系统资源。如果这个服务的容器在运行时,突然申请创建 20000 个进程,我们有理由相信是业务容器遇到了 Bug,通过之前的资源约束对容器进行限制,并发出告警,通知业务及时进行处理。
往下一层是“容器编排”和“镜像管理”。容器编排解决容器动态实例的问题,包括容器何时被创建、创建到哪个位置、何时被删除等等。镜像管理解决容器静态实例的问题,包括容器镜像应该如何构建、如何分发、分发的位置等等。
最下层是我们的容器运行时,美团使用主流的 Linux+Docker 容器方案,HULK Agent 是我们在服务器上的管理代理程序。
把前面的“容器运行时”具体展开,可以看到这张架构图,按照从下到上的顺序介绍:
最下层是 CPU、内存、磁盘、网络这些基础物理资源。
往上一层,我们使用的是 CentOS7 作为宿主机操作系统,Linux 内核的版本是 3.10。我们在 CentOS 发行版默认内核的基础上,加入一些美团为容器场景研发的新特性,同时为高并发、低延时的服务型业务做了一些内核参数的优化。
再往上一层,我们使用的是 CentOS 发行版里自带的 Docker,当前的版本是 1.13,同样,加入了一些我们自己的特性和增强。HULK Agent 是我们自己开发的主机管理 Agent,在宿主机上管理 Agent。Falcon Agent 同时存在于宿主机和容器内部,它的作用是收集宿主机和容器的各种基础监控指标,上报给后台和监控平台。
最上一层是容器本身。我们现在主要支持 CentOS 6 和 CentOS 7 两种容器。在 CentOS 6 中有一个 container init 进程,它是我们开发容器内部的 1 号进程,作用是初始化容器和拉起业务进程。在 CentOS 7 中,我们使用了系统自带的 systemd 作为容器中的 1 号进程。我们的容器支持各种主流编程语言,包括 Java、Python、Node.js、C/C++等等。在语言层之上是各种代理服务,包括服务治理的 Agent、日志 Agent、加密 Agent 等等。同时,我们的容器也支持美团内部的一些业务环境,例如 set 信息、泳道信息等,配合服务治理体系,可以实现服务调用的智能路由。
美团主要使用了 CentOS 系列的开源组件,因为我们认为 Red Hat 有很强的开源技术实力,比起直接使用开源社区的版本,我们希望 Red Hat 的开源版本能够帮助解决大部分的系统问题。我们也发现,即使部署了 CentOS 的开源组件,仍然有可能会碰到社区和 Red Hat 没有解决的问题。从某种程度上也说明,国内大型互联公司在技术应用的场景、规模、复杂度层面已经达到了世界领先的水平,所以才会先于社区、先于 Red Hat 的客户遇到这些问题。
容器遇到的一些问题
在容器技术本身,我们主要遇到了 4 个问题:隔离、稳定性、性能和推广。
隔离包含两个层面:第一个问题是,容器能不能正确认识自身资源配置;第二个问题是,运行在同一台服务器上的容器会不会互相影响。比如某一台容器的 IO 很高,就会导致同主机上的其他容器服务延时增加。
稳定性:这是指在高压力、大规模、长时间运行以后,系统功能可能会出现不稳定的问题,比如容器无法创建、删除,因为软件问题发生卡死、宕机等问题。
性能:在虚拟化技术和容器技术比较时,大家普遍都认为容器的执行效率会更高,但是在实践中,我们遇到了一些特例:同样的代码在同样配置的容器上,服务的吞吐量、响应时延反而不如虚拟机。
推广:当我们把前面几个问题基本上都解决以后,仍然可能会碰到业务不愿意使用容器的情况,其中原因一部分是技术因素,例如容器接入难易程度、周边工具、生态等都会影响使用容器的成本。推广也不是一个纯技术问题,跟公司内部的业务发展阶段、技术文化、组织设置和 KPI 等因素都密切相关。
容器的实现
容器本质上是把系统中为同一个业务目标服务的相关进程合成一组,放在一个叫做 namespace 的空间中,同一个 namespace 中的进程能够互相通信,但看不见其他 namespace 中的进程。每个 namespace 可以拥有自己独立的主机名、进程 ID 系统、IPC、网络、文件系统、用户等等资源。在某种程度上,实现了一个简单的虚拟:让一个主机上可以同时运行多个互不感知的系统。
此外,为了限制 namespace 对物理资源的使用,对进程能使用的 CPU、内存等资源需要做一定的限制。这就是 Cgroup 技术,Cgroup 是 Control group 的意思。比如我们常说的 4c4g 的容器,实际上是限制这个容器 namespace 中所用的进程,最多能够使用 4 核的计算资源和 4GB 的内存。
简而言之,Linux 内核提供 namespace 完成隔离,Cgroup 完成资源限制。namespace+Cgroup 构成了容器的底层技术(rootfs 是容器文件系统层技术)。
美团的解法、改进和优化
隔离
之前一直和虚拟机打交道,但直到用上容器,才发现在容器里面看到的 CPU、Memory 的信息都是服务器主机的信息,而不是容器自身的配置信息。直到现在,社区版的容器还是这样,比如一个 4c4g 的容器,在容器内部可以看到有 40 颗 CPU、196GB 内存的资源,这些资源其实是容器所在宿主机的信息。这给人的感觉,就像是容器的“自我膨胀”,觉得自己能力很强,但实际上并没有,还会带来很多问题。
上图是一个内存信息隔离的例子。获取系统内存信息时,社区 Linux 无论在主机上还是在容器中,内核都是统一返回主机的内存信息,如果容器内的应用,按照它发现的宿主机内存来进行配置的话,实际资源是远远不够的,导致的结果就是:系统很快会发生 OOM 异常。
我们做的隔离工作,是在容器中获取内存信息时,内核根据容器的 Cgroup 信息,返回容器的内存信息(类似 LXCFS 的工作)。
CPU 信息隔离的实现和内存的类似,不再赘述,这里举一个 CPU 数目影响应用性能例子。
大家都知道,JVM GC(垃圾对象回收)对 Java 程序执行性能有一定的影响。默认的 JVM 使用公式“ParallelGCThreads = (ncpus <= 8) ? ncpus : 3 + ((ncpus
/ 8)” 来计算做并行 GC 的线程数,其中 ncpus 是 JVM 发现的系统 CPU 个数。一旦容器中 JVM 发现了宿主机的 CPU 个数(通常比容器实际 CPU 限制多很多),这就会导致 JVM 启动过多的 GC 线程,直接的结果就导致 GC 性能下降。Java 服务的感受就是延时增加,TP 监控曲线突刺增加,吞吐量下降。针对这个问题有各种解法:
显式的传递 JVM 启动参数“-XX:ParallelGCThreads”告诉 JVM 应该启动几个并行 GC 线程。它的缺点是需要业务感知,为不同配置的容器传不同的 JVM 参数。
在容器内使用 Hack 过的 glibc,使 JVM(通过 sysconf 系统调用)能正确获取容器的 CPU 资源数。我们在一段时间内使用的就是这种方法。其优点是业务不需要感知,并且能自动适配不同配置的容器。缺点是必须使用改过的 glibc,有一定的升级维护成本,如果使用的镜像是原生的 glibc,问题也仍然存在。
我们在新平台上通过对内核的改进,实现了容器中能获取正确 CPU 资源数,做到了对业务、镜像和编程语言都透明(类似问题也可能影响 OpenMP、Node.js 等应用的性能)。
有一段时间,我们的容器是使用 root 权限进行运行,实现的方法是在 docker run 的时候加入‘privileged=true’参数。这种粗放的使用方式,使容器能够看到所在服务器上所有容器的磁盘,导致了安全问题和性能问题。安全问题很好理解,为什么会导致性能问题呢?可以试想一下,每个容器都做一次磁盘状态扫描的场景。当然,权限过大的问题还体现在可以随意进行 mount 操作,可以随意的修改 NTP 时间等等。
在新版本中,我们去掉了容器的 root 权限,发现有一些副作用,比如导致一些系统调用失败。我们默认给容器额外增加了 sys_ptrace 和 sys_admin 两个权限,让容器可以运行 GDB 和更改主机名。如果有特例容器需要更多的权限,可以在我们的平台上按服务粒度进行配置。
Linux 有两种 IO:Direct IO 和 Buffered IO。Direct IO 直接写磁盘,Buffered IO 会先写到缓存再写磁盘,大部分场景下都是 Buffered IO。
我们使用的 Linux 内核 3.X,社区版本中所有容器 Buffer IO 共享一个内核缓存,并且缓存不隔离,没有速率限制,导致高 IO 容器很容易影响同主机上的其他容器。Buffer IO 缓存隔离和限速在 Linux 4.X 里通过 Cgroup V2 实现,有了明显的改进,我们还借鉴了 Cgroup V2 的思想,在我们的 Linux 3.10 内核实现了相同的功能:每个容器根据自己的内存配置有对应比例的 IO Cache,Cache 的数据写到磁盘的速率受容器 Cgroup IO 配置的限制。
Docker 本身支持较多对容器的 Cgroup 资源限制,但是 K8s 调用 Docker 时可以传递的参数较少,为了降低容器间的互相影响,我们基于服务画像的资源分配,对不同服务的容器设定不同的资源限制,除了常见的 CPU、内存外,还有 IO 的限制、ulimit 限制、PID 限制等等。所以我们扩展了 K8s 来完成这些工作。
业务在使用容器的过程中产生 core dump 文件是常见的事,比如 C/C++程序内存访问越界,或者系统 OOM 的时候,系统选择占用内存多的进程杀死,默认都会生成一个 core dump 文件。
社区容器系统默认的 core dump 文件会生成在宿主机上,由于一些 core dump 文件比较大,比如 JVM 的 core dump 通常是几个 GB,或者有些存在 Bug 的程序,其频发的 core dump 很容易快速写满宿主机的存储,并且会导致高磁盘 IO,也会影响到其他容器。还有一个问题是:业务容器的使用者没有权限访问宿主机,从而拿不到 dump 文件进行下一步的分析。
为此,我们对 core dump 的流程进行了修改,让 dump 文件写到容器自身的文件系统中,并且使用容器自己的 Cgroup IO 吞吐限制。
稳定性
我们在实践中发现,影响系统稳定性的主要是 Linux Kernel 和 Docker。虽然它们本身是很可靠的系统软件,但是在大规模、高强度的场景中,还是会存在一些 Bug。这也从侧面说明,我们国内互联网公司在应用规模和应用复杂度层面也属于全球领先。
在内核方面,美团发现了 Kernel 4.x Buffer IO 限制的实现问题,得到了社区的确认和修复。我们还跟进了一系列 CentOS 的 Ext4 补丁,解决了一段时间内进程频繁卡死的问题。
我们碰到了两个比较关键的 Red Hat 版 Docker 稳定性问题:
在 Docker 服务重启以后,Docker exec 无法进入容器,这个问题比较复杂。在解决之前我们用 nsenter 来代替 Docker exec 并积极反馈给 RedHat。后来 Red Hat 在今年初的一个更新解决了这个问题。https://access.redhat.com/errata/RHBA-2017:1620
是在特定条件下 Docker Daemon 会 Panic,导致容器无法删除。经过我们自己 Debug,并对比最新的代码,发现问题已经在 Docker upstream 中得到解决,反馈给 Red Hat 也很快得到了解决。https://github.com/projectatomic/containerd/issues/2
面对系统内核、Docker、K8s 这些开源社区的系统软件,存在一种观点是:我们不需要自己分析问题,只需要拿社区的最新更新就行了。但是我们并不认同,我们认为技术团队自身的能力很重要,主要是如下原因:
美团的应用规模大、场景复杂,很多问题也许很多企业都没有遇到过,不能被动的等别人来解答。
对于一些实际的业务问题或者需求(例如容器内正确返回 CPU 数目),社区也许觉得不重要,或者不是正确的理念,可能就不会解决。
社区很多时候只在 Upstream 解决问题,而 Upstream 通常不稳定,即使有 Backport 到我们正在使用的版本,排期也很难进行保障。
社区会发布很多补丁,通常描述都比较晦涩难懂。如果没有对问题的深刻理解,很难把遇到的实际问题和一系列补丁联系起来。
对于一些复杂问题,社区的解决方案不一定适用于我们自身的实际场景,我们需要自身有能力进行判断和取舍。
美团在解决开源系统问题时,一般会经历五个阶段:自己深挖、研发解决、关注社区、和社区交互,最后贡献给社区。
性能
容器平台性能,主要包括两个方面性能:
业务服务运行在容器上的性能。
容器操作(创建、删除等等)的性能。
上图是我们 CPU 分配的一个例子,我们采用的主流服务器是两路 24 核服务器,包含两个 Node,每个 12 核,算上超线程共 48 颗逻辑 CPU。属于典型的 NUMA(非一致访存)架构:系统中每个 Node 有自己的内存,Node 内的 CPU 访问自己的内存的速度,比访问另一个 Node 内存的速度快很多(差一倍左右)。
过去我们曾经遇到过网络中断集中到 CPU0 上的问题,在大流量下可能导致网络延时增加甚至丢包。为了保证网络处理能力,我们从 Node0 上划出了 8 颗逻辑 CPU 用来专门处理网络中断和宿主机系统上的任务,例如镜像解压这类高 CPU 的工作,这 8 颗逻辑 CPU 不运行任何容器的 Workload。
在容器调度方面,我们的容器 CPU 分配尽量不跨 Node,实践证明跨 Node 访问内存对应用性能的影响比较大。在一些计算密集型的场景下,容器分配在 Node 内部会提升 30%以上的吞吐量。按 Node 的分配方案也存在一定的弊端:会导致 CPU 的碎片增加,为了更高效地利用 CPU 资源。在实际系统中,我们会根据服务画像的信息,分配一些对 CPU 不敏感的服务容器跨 Node 使用 CPU 资源。
上图是一个真实的服务在 CPU 分配优化前后,响应延时的 TP 指标线对比。可以看到 TP999 线下降了一个数量级,所有的指标都更加平稳。
性能优化:文件系统
针对文件系统的性能优化,第一步是选型,根据统计到的应用读写特征,我们选择了 Ext4 文件系统(超过 85%的文件读写是对小于 1M 文件的操作)。
Ext4 文件系统有三种日志模式:
Journal:写数据前等待 Metadata 和数据的日志落盘。
Ordered:只记录 Metadata 的日志,写 Metadata 日志前确保数据已经落盘。
Writeback:仅记录 Metadata 日志,不保证数据比 Metadata 先落盘。
我们选择了 Writeback 模式(默认是 oderded),它在几种挂载模式中速度最快,缺点是:发生故障时数据不好恢复。我们大部分容器处于无状态,故障时在别的机器上再拉起一台即可。因此我们在性能和稳定性中,选择了性能。容器内部给应用提供可选的基于内存的文件系统 tmpfs,可以提升有大量临时文件读写的服务性能。
如上图所示,在美团内部创建一个虚拟机至少经历三步,平均时间超过 300 秒。使用镜像创建容器平均时间 23 秒。容器的灵活、快速得到了显著的体现。
容器扩容 23 秒的平均时间包含了各个部分的优化,如扩容链路优化、镜像分发优化、初始化和业务拉起优化等等。接下来,本文主要介绍一下我们做的镜像分发和解压相关的优化。
上图是美团容器镜像管理的总体架构,其特点如下:
存在多个 Site。
支持跨 Site 的镜像同步,根据镜像的标签确定是否需要跨 Site 同步。
每个 Site 有镜像备份。
每个 Site 内部有实现镜像分发的 P2P 网络。
镜像分发是影响容器扩容时长的一个重要环节。
跨 Site 同步:保证服务器总能从就近的镜像仓库拉取到扩容用的镜像,减少拉取时间,降低跨 Site 带宽消耗。
基础镜像预分发:美团的基础镜像是构建业务镜像的公共镜像,通常有几百兆的大小。业务镜像层是业务的应用代码,通常比基础镜像小很多。在容器扩容的时候如果基础镜像已经在本地,就只需要拉取业务镜像的部分,可以明显的加快扩容速度。为达到这样的效果,我们会把基础镜像事先分发到所有的服务器上。
P2P 镜像分发:基础镜像预分发在有些场景会导致上千个服务器同时从镜像仓库拉取镜像,对镜像仓库服务和带宽带来很大的压力。因此我们开发了镜像 P2P 分发的功能,服务器不仅能从镜像仓库中拉取镜像,还能从其他服务器上获取镜像的分片。
从上图可以看出,随着分发服务器数目的增加,原有分发时间也快速增加,而 P2P 镜像分发时间基本上保持稳定。
Docker 的镜像拉取是一个并行下载,串行解压的过程,为了提升解压的速度,我们美团也做了一些优化工作。
对于单个层的解压,我们使用并行解压算法替换 Docker 默认的串行解压算法,实现上是使用 pgzip 替换 gzip。
Docker 的镜像具有分层结构,对镜像层的合并是一个“解压一层合并一层,再解压一层,再合并一层”的串行操作。实际上只有合并是需要串行的,解压可以并行起来。我们把多层的解压改成并行,解压出的数据先放在临时存储空间,最后根据层之间的依赖进行串行合并。前面的改动(并行解压所有的层到临时空间)导致磁盘 IO 的次数增加了近一倍,也会导致解压过程不够快。于是,我们使用基于内存的 Ramdisk 来存储解压出来的临时文件,减轻了额外文件写带来的开销。做了上面这些工作以后,我们又发现,容器的分层也会影响下载加解压的时间。上图是我们简单测试的结果:无论对于怎么分层的镜像并行解压,都能大幅提升解压时间,对于层数多的镜像提升更加明显。
推广
推广容器的第一步是能说出容器的优势,我们认为容器有如下优势:
轻量级:容器小、快,能够实现秒级启动。
应用分发:容器使用镜像分发,开发测试容器和部署容器配置完全一致。
弹性:可以根据 CPU、内存等资源使用或者 QPS、延时等业务指标快速扩容容器,提升服务能力。
这三个特性的组合,可以给业务带来更大的灵活度和更低的计算成本。
因为容器平台本身是一个技术产品,它的客户是各个业务的 RD 团队,因此我们需要考虑下面一些因素:
产品优势:推广容器平台从某种程度上讲,自身是一个 ToB 的业务,首先要有好的产品,它相对于以前的解决方案(虚拟机)存在很多优势。
和已有系统打通:这个产品要能和客户现有的系统很好的进行集成,而不是让客户推翻所有的系统重新再来。
原生应用的开发平台、工具:这个产品要易于使用,要有配合工作的工具链。
虚拟机到容器的平滑迁移:最好能提供从原有方案到新产品的迁移方案,并且容易实施。
与应用 RD 紧密配合:要提供良好的客户支持,(即使有些问题不是这个产品导致的也要积极帮忙解决)。
资源倾斜:从战略层面支持颠覆性新技术:资源上向容器平台倾斜,没有足够的理由,尽量不给配置虚拟机资源。
总结
Docker 容器加 Kubernetes 编排是当前容器云的主流实践之一,美团容器集群管理平台 HULK 也采用了这样的方案。本文主要分享了美团在容器技术上做的一些探索和实践。内容主要涵盖美团容器云在 Linux Kernel、Docker 和 Kubernetes 层面做的一些优化工作,以及美团内部推动容器化进程的一些思考,欢迎大家跟我们交流、探讨。
作者简介
欧阳坚,2006 年毕业于清华大学计算机系,拥有 12 年数据中心开发管理经验。曾任 VMware 中国 Staff Engineer,无双科技 CTO,中科睿光首席架构师。现任美团基础架构部/容器研发中心技术总监,负责美团容器化的相关工作。
评论