使用容器的正确方式,Docker在雪球的技术实践

1,434 阅读14分钟
原文链接: mp.weixin.qq.com

雪球目前拥有一千多个容器,项目数量大概有一百多个,规模并不是很大。但是得益于容器技术,雪球部署的效率非常高,雪球的开发人员只有几十个,但是每个月的发布次数高达两千多次。 雪球是一个投资者交流的社区,用户可以在上面买卖股票,代销基金等各种金融衍生业务,同时也可以通过雪盈证券来进行沪、深、港、美股的交易。

为什么要引入 Docker

随着业务的发展,不同的社区业务之间所受到影响的概率正在逐渐升高,因此我们希望各个业务之间既能够不被打扰,又能在资源上、机器间、甚至网络上根据监管的要求予以不同层面的隔离。 早在 2014 年时,我们就发现容器技术具有本身镜像小、灵活、启动速度快等特点,而且在性能上比较适合于我们当时物理机不多的规模环境。 相比而言,传统的虚拟化技术不但实现成本高,而且性能损耗也超过 10%。因此,基于对镜像大小、启动速度、性能损耗、和隔离需求的综合考虑,我们选用了两种容器引擎:LXC 和 Docker。 我们把 MySQL 之类有状态的服务放在 LXC 里;而将线上业务之类无状态的服务放在 Docker 中。

容器使用方法 众所周知,Docker 是以类似于单机的软件形态问世的,最初它的宣传口号是:Build/Ship/Run。 因此它早期的 Workflow(流程)是:
  • 在一台 Host 主机上先运行 Docker Build。

  • 然后运用 Docker Pull,从镜像仓库里把镜像拉下来。

  • 最后使用 Docker Run,就有了一个运行的 Container。

需要解决的问题 上述的流程方案伴随着如下有待解决的问题:
  • 网络连通性,由于是单机软件,Docker 最初默认使用的是 Bridge 模式,不同宿主机之间的网络并不相通。因此,早期大家交流最多的就是如何解决网络连通性的问题。

  • 多节点的服务部署与更新,在上马该容器方案之后,我们发现由于本身性能损耗比较小,其节点的数量会出现爆炸式增长。

    因此,往往会出现一台物理机上能够运行几十个节点的状况。容器节点的绝对数量会比物理节点的数量大一个数量级,甚至更多。那么这么多节点的服务部署与更新,直接导致了工作量的倍数增加。

  • 监控,同时,我们需要为这么多节点的运行状态采用合适的监控方案。

Docker 在雪球的技术实践

网络模式 首先给大家介绍一下我们早期的网络解决方案:在上图的左边,我们默认采用的是 Docker 的 Bridge 模式。 大家知道,默认情况下 Docker 会在物理机上创建一个名为 docker0 的网桥。 每当一个新的 Container 被创建时,它就会相应地创建出一个 veth,然后将其连到容器的 eth0 上。 同时,每一个 veth 都会被分配到一个子网的 IP 地址,以保持与相同主机里各个容器的互通。 由于在生产环境中不只一张网卡,因此我们对它进行了改造。我们产生了一个“网卡绑定”,即生成了 bond0 网卡。我们通过创建一个 br0 网桥,来替换原来的 docker0 网桥。 在该 br0 网桥中,我们所配置的网段和物理机所处的网段是相同的。由于容器和物理机同处一个网段,因此核心上联的交换机能够看到该容器和不同宿主机的 MAC 地址。这就是一个网络二层互通的解决方案。

该网络模式具有优劣两面性:
  • 优点:由于在网络二层上实现了连接互通,而且仅用到了内核转发,因此整体性能非常好,与物理机真实网卡的效率差距不大。

  • 缺点:管理较为复杂,需要我们自己手动的去管理容器的 IP 和 MAC 地址。 由于整体处于网络大二层,一旦系统达到了一定规模,网络中的 ARP 包会产生网络广播风暴,甚至会偶发出现 PPS(Package Per Second)过高,网络间歇性不通等奇怪的现象。

    由于处于底层网络连接,在实现网络隔离时也较为复杂。

服务部署

对于服务的部署而言,我们最初沿用虚拟机的做法,将容器启动起来后就不再停下了,因此:
  • 如果节点需要新增,我们就通过 Salt 来管理机器的配置。

  • 如果节点需要更新,我们就通过 Capistrano 进行服务的分发,和多个节点的部署操作,变更容器中的业务程序。

其中,优势为:
  • 与原来的基础设施相比,迁移的成本非常低。由于我们通过复用原来的基础设施,直接将各种服务部署在原先的物理机上进行,因此我们很容易地迁移到了容器之中。而对于开发人员来说,他们看不到容器这一层,也就如同在使用原来的物理机一样,毫无“违和感”。

  • 与虚拟机相比,启动比较快,运行时没有虚拟化的损耗。

  • 最重要的是一定程度上满足了我们对于隔离的需求。

而劣势则有:
  • 迁移和扩容非常繁琐。例如:当某个服务需要扩容时,我们就需要有人登录到该物理机上,生成并启动一个空的容器,再把服务部署进去。此举较为低效。

  • 缺乏统一的平台进行各种历史版本的管理与维护。我们需要通过文档来记录整个机房的容器数量,和各个容器的 IP/MAC 地址,因此出错的可能性极高。

  • 缺少流程和权限的控制。我们基本上采用的是原始的管控方式。

自研容器管理平台

面对上述缺点,我们需要自行研发一个容器管理平台,去管理各种物理机、容器、IP 与 MAC 地址、以及进行流程控制。

因此我们变更的整套发布流程为:
  • 由开发人员将代码提交到代码仓库(如 Github)之中。

  • 触发一个 Hook 去构建镜像,在构建的同时做一些 CI(持续集成),包括静态代码扫描和单测等。

  • 将报告附加到镜像的信息里,并存入镜像仓库中。

  • 部署测试环境。

  • 小流量上线,上线之后,做一些自动化的 API Diff 测试,以判断是否可用。

  • 继续全量上线。

镜像构建

有了容器管理平台,就会涉及到镜像的自动构建。和业界其他公司的做法类似,我们也使用的是基于通用操作系统的镜像。 然后向镜像中添加那些我们公司内部会特别用到的包,得到一个通用的 base 镜像,再通过分别加入不同语言的依赖,得到不同的镜像。 每次业务版本发布,将代码放入相应语言的镜像即可得到一个业务的镜像。构建镜像的时候需要注意尽量避免无用的层级和内容,这有助于提升存储和传输效率。 系统依赖

我们的这一整套解决方案涉及到了如下周边的开源项目与技术: 负载均衡 由于会频繁发生节点的增减,我们该如何通过流量的调度和服务的发现,来实现自动加入负载均衡呢?对于那些非 Http 协议的 RPC,又该如何自动安全地摘掉某个节点呢? 我们在此使用了 Nginx+Lua(即 OpenResty),去实现逻辑并动态更改 Upstream。 当有节点启动时,我们就能够将它自动注册与加入;而当有节点被销毁时,也能及时将其摘掉。 同时,我们在内部使用了 Finagle 作为 RPC 的框架,并通过 ZooKeeper 实现了服务的发现。 日志收集 由于节点众多,我们需要进行各种日志的收集。在此,我们大致分为两类收集方式:
  • 一类是 Nginx 这种不易侵入代码的,我们并没有设法去改变日志的流向,而是让它直接“打”到物理机的硬盘上,然后使用 Flume 进行收集,传输到 Kafka 中。

  • 另一类是我们自己的业务。我们实现了一个 Log4 Appender,把日志直接写到 Kafka,再从 Kafka 转写到 ElasticSearch 里面。

网络模式 在该场景下,我们采用的是上述提到的改进后的 Bridge+Host 模式。 监控系统

监控系统由上图所示的几个组件所构成。它将收集(Collector)到的不同监控指标数据,传输到 Graphite 上,而 Grafana 可读取 Graphite 的信息,并用图形予以展示。 同时,我们也根据内部业务的适配需要,对报警组件 Cabot 进行了改造和定制。

此时我们的平台已经与虚拟机的用法有了较大的区别。如上图所示,主要的不同之处体现在编译、环境、分发、节点变更,流程控制、以及权限控制之上。我们的用法更具自动化。

由于是自行研发的容器管理平台,这给我们带来的直接好处包括:
  • 流程与权限的控制。

  • 代码版本与环境的固化,多个版本的发布,镜像的管理。

  • 部署与扩容效率的大幅提升。

但是其自身也有着一定的缺点,包括:
  • 在流程控制逻辑,机器与网络管理,以及本身的耦合程度上都存在着缺陷。因此它并不算是一个非常好的架构,也没能真正实现“高内聚低耦合”。

  • 由于是自研的产品,其功能上并不完善,没能实现自愈,无法根据新增节点去自动选择物理机、并自动分配与管理 IP 地址。

引入 Swarm

2015 年,我们开始着手改造该容器管理平台。由于该平台之前都是基于 DockerAPI 构建的。 而 Swarm 恰好能对 Docker 的原生 API 提供非常好的支持,因此我们觉得如果引入 Swarm 的话,对于以前代码的改造成本将会降到最低。

那么我们该如何对原先的网络二层方案进行改造呢?如前所述,我们一直实现的是让容器的 IP 地址与物理机的 IP 地址相对等。 因此并不存在网络不通的情况。同时,我们的 Redis 是直接部署在物理机上的。 所以依据上图中各个列表的对比,我们觉得 Calico 方案更适合我们的业务场景。

因此,我们在上层使用 Rolling 来进行各种流程的操作,中下层则用 Swarm+Calico 来予以容器和网络的管理。

Calico 使用的是 DownwardDefault 模式,该模式通过运用 BGP 协议,来实现对于不同机器之间路由信息的分发。 在默认情况下,Calico 是 Node 与 Node 之间的 Mesh 方式,即:任意两个 Node 之间都有着 BGP 连接。 当我们在一台物理机上启动了某个容器之后,它就会添加一条包含着从容器 IP 地址到物理机的路由信息。 由于多台物理机同处一个 Mesh,那么每一台机器都会学习到该路由信息。而随着我们系统规模的逐渐增大,每一台物理机上的路由表也会相应地增多,这就会影响到网络的整体性能。 因此我们需要采用这种 Downward Default 部署模式,使得不必让每台物理机都拥有全量的路由表,而仅让交换机持有便可。 众所周知,BGP 会给每一台物理机分配一个 AS(自治域是 BGP 中的一个概念)号,那么我们就可以给各台物理机都分配相同的 AS 号。 而给它们的上联交换机分配另一个 AS 号,同时也给核心交换机再分配第三种 AS 号。 通过此法,每一台物理机只会和自己上联的交换机做路由分发,那么当有一个新的节点启动之后,我们便可以将这条路由信息插入到该节点自己的路由表中,然后再告知与其相连的上联交换机。 上联交换机在学习到了这条路由之后,再进一步推给核心交换机。 总结起来,该模式的特点是:
  • 单个节点不必知道其他物理机的相关信息,它只需将数据包发往网关便可。因此单台物理机上的路由表也会大幅减少,其数量可保持在“单机上的容器数量 +一个常数(自行配置的路由)”的水平上。

  • 每个上联交换机只需掌握自己机架上所有物理机的路由表信息。

  • 核心交换机则需要持有所有的路由表。而这恰是其自身性能与功能的体现。

当然,该模式也带来了一些不便之处,即:对于每一个数据流量而言,即使目标 IP 在整个网络中并不存在,它们也需要一步一步地向上查询直到核心交换机处,最后再判断是否真的需要丢弃该数据包。 后续演进

在此之后,我们也将 DevOps 的思想和模式逐步引入了当前的平台。具体包括如下三个方面:
  • 通过更加自助化的流程,来解放运维。让开发人员自助式地创建、添加、监控他们自己的项目,我们只需了解各个项目在平台中所占用的资源情况便可,从而能够使得自己的精力更加专注于平台的开发与完善。

  • 如今,由于 Kubernetes 基本上已成为了业界的标准,因此我们逐步替换了之前所用到的 Swarm,并通过 Kubernetes 来实现更好的调度方案。

  • 支持多机房和多云环境,以达到更高的容灾等级,满足业务的发展需求,并完善集群的管理。

上图展示了一种嵌套式的关系:在我们的每一个 Project 中,都可以有多个 IDC。 而每个 IDC 里又有着不同的 Kubernetes 集群。同时在每一个集群里,我们为每一个项目都分配了一个 Namespace。 根据不同的环境,这些项目的 Namespace 会拥有不同的 Deployment。例如想要做到部署与发布的分离,我们就相应地做了多个 Deployment,不同的 Deployment 标示不同的环境。 默认将流量引入第一个 Deployment,等到第二个 Deployment 被部署好以后,需要发布的时候,我们再直接把流量“切”过去。 同时,鉴于我们的平台上原来就已经具有了诸如日志、负载均衡、监控之类的解决方案。 而 Kubernetes 本身又是一个较为全面的解决方案,因此我们以降低成本为原则,谨慎地向 Kubernetes 进行过渡,尽量保持平台的兼容性,不至让开发人员产生“违和感”。

如今,我们的容器只有一千多个,项目数量大概有一百多个。但是我们在部署效率方面的提升还是非常显著的,我们的几十个开发人员每个月所发布的次数就能达到两千多次,每个交易日的日志量大概有 1.5T。 作者:董明鑫,雪球网运维开发架构师。曾就职百度,2014 年加入雪球,目前主要负责保障雪球稳定性、提升资源利用率及提高开发效率等方面。关注容器生态圈的技术发展。 本文转载自公众号:51CTO技术栈,点击查看原文