前言
微服务架构背景下,随着服务和服务实例的数量不断增加,如果依然用传统的方式部署、配置和管理这些服务进程,就会发现,越来越多的时间花在了管理部署和解决部署过程中出现的问题上了。比如,需要新增服务实例进行扩容,服务器环境搭建就挺费时间的。另外,很多人肯定会经历过,同一份代码的程序在测试环境跑得好好的,但到了生产环境就出错了。部署上线的时候,大部分问题其实都是运行环境和配置问题,开发和运维就为了解决这些问题花费了很多时间。
容器化就能很好地解决上面所说的问题。其实,虚拟机也是一种解决方案,但虚拟机相对来说太重了。容器则很轻量级,占用的资源也很少,启动又快。所以,无疑容器是解决微服务应用部署问题的更优选择。确切地说,虚拟机是为了实现在单个物理机上安装不同的操作系统,目的是操作系统级别的隔离;而容器则是为了实现在同一个操作系统中将不同的应用隔离开,目的是应用级别的隔离。
那说到容器化技术,Docker 和 Kubernetes(简称 K8S) 是最具代表性的两套工具。不过,容器技术其实远不只是这两套工具。下面,我们就来细聊这一块。
容器
说到容器,很多人就想到了 Docker,甚至认为容器=Docker,这无疑是错误的认识。其实,Docker 是一套容器引擎,也叫容器运行时,且容器引擎不只有 Docker 一家,还有 rkt、containerd、pouch、cri-o 等。容器的创建和管理都是由容器引擎实现的。
要直观地理解容器,其实和虚拟机对比一下就明白了,请看下图:
从图中也很明显看出,之所以容器比 VM 要轻量得多,主要就因为少了一个 Guest OS。另外,图中也看到了,容器的下层就是容器引擎。
一个容器本质上也是一个进程,多个容器就是运行在同个 OS 中的多个相互隔离的进程。实现容器间的隔离,其底层主要使用了 Linux 内核的 Namespaces 和 CGroups 来分隔进程,以便各应用进程能够相互独立运行。Namespace 又称为命名空间,主要对系统资源做访问隔离。其原理是针对一类资源进行抽象,并将其封装在一起提供给一个容器使用,对于这类资源,因为每个容器都有自己的抽象,而他们彼此之间是不可见的,所以就可以做到访问隔离。主要有六类 Namesapce:
- UTS:主机名与域名
- IPC:信号量、消息队列和共享内存
- PID: 进程编号
- Network:网络设备、网络栈、端口等等
- Mount:挂载点
- User:用户和用户组
CGroups 是 Control Groups 的简称,也叫控制组,主要做资源限制。原理是将一组进程放在一个控制组里,通过给这个控制组分配指定的可用资源,达到控制这一组进程可用资源的目的。比如可以限制 CPU 的使用率、内存的使用上限等。
最早的容器引擎应该算是 LXC 了,全称为 Linux Container,诞生于 2008 年,主要使用命令行创建和管理容器。不过,LXC 创建的容器无法有效跨机器进行迁移,这是最大的缺陷。但 LXC 也是现代容器技术的实现基础,包括 Docker,最初都是在 LXC 的基础上提供了更强大的功能。
Docker
Docker 发布于 2013年,在这之前,容器技术一直不温不火,是 Docker 让容器技术迎来了腾飞。主要就是因为 Docker 突破性的解决了容器标准化与可移植性的问题。Docker 的宣传口号是 “Build,Ship and Run Any App,Anywhere”。标准化了才可以 Run Any App,具备可移植性才能做到 Run Anywhere。而做到这两点的核心就是容器镜像(container image),一种新型的应用打包、分发和运行机制。容器镜像将应用运行环境,包括代码、依赖库、工具、资源文件和元信息等,打包成一种操作系统发行版无关的不可变更软件包。
容器镜像打包了整个容器运行依赖的环境,以避免依赖底层的操作系统,从而实现 “build once,run anywhere”。也同样有赖于容器镜像,用户可以打包任何应用所依赖的环境,而不用改造应用来适配运行环境,所以就可以 “run any app”。
容器镜像是基于联合文件系统(UnionFS) 的,具有分层结构,即一个镜像是由若干层级的不同镜像叠加组成的,且每一层都是只读的。容器镜像只是一个静态文件,在镜像的最上层再增加一层可写层(也称为容器层),就变成了 Docker 容器,即 Docker 容器 = 镜像 + 容器层。
另外,容器也是有生命周期的,主要有 5 种状态:Created、Running、Paused、Stopped、Deleted。各状态的流转如下图:
既然容器可以被删除,那容器中的程序产生的数据需要持久化又怎么办呢?为了解决此问题,Docker 引入了 Volume 机制,Volume 以独立于 Docker 文件系统的形式存在于宿主机中,不会随着容器的删除而删除。Volume 由 Docker 管理,包括创建、删除等。默认情况下,Volume 的存储空间来自于宿主机文件系统中的某个目录,如 /var/lib/docker/volumes/,Docker 系统外的程序不应该修改其中的数据。不过,每个容器只能挂载一个 Volume。虽然也有其他的持久化方案,但 Volume 是官方推荐的持久化方案。
再来看看 Docker 的整体架构,如下图:
Docker 采用的是 C/S 架构,Client 是提供给用户操作容器和镜像的工具集,主要是通过发送 docker 命令到 Docker daemon 实现的。docker 命令与 Docker daemon 通信实际上是调用了 Docker API,且一个 Client 是可以与多个 Docker daemon 通信的。Docker daemon 会监听 Docker API 的请求,并管理 Docker 各种对象,包括镜像、容器、网络和磁盘等。Docker daemon 和容器,以及本地镜像都在同一个 Docker Host 上,即 Docker 主机。Client 和 Docker Host 可以部署在同一台宿主机上,也可以分开部署。但一般都是分开部署,一个 Client 连接多个 Docker Host。
启动运行容器时,Docker daemon 从本地获取镜像,如本地没有,则从 Registry 获取镜像并缓存到本地。Registry 是存放镜像的仓库,有公共仓库,也可以搭建私有仓库。Docker 官方运营着一个公共的 Registry 叫 Docker Hub,用户可以在 Docker Hub 注册账号,分享并保存自己的镜像。
Kubernetes
Kubernetes,简称 K8S,是用来做容器编排的,是用于自动部署、扩展和管理容器化应用程序的开源系统。因为容器主要还是应用在分布式架构的系统中,拥有大量服务,而这些服务又要封装到容器中,那就存在大量容器需要进行创建、编排等工作。如果一个个容器还需要人工去编排管理,那工作量无疑是巨大的,所以才需要像 K8S 这样的自动化容器编排系统。
K8S 是在 2014 年 Google 基于内部使用的 Borg 系统所创建的开源项目,用于解决大规模集群的容器部署、运行、管理等问题。K8S 在容器的基础上增加了一层的新的管理抽象 Pod,以便更好地利用容器进行应用的功能模块切分。得益于 Google 在大规模集群基础设施建设的强大积累,脱胎于 Borg 的 K8S 很快成为了行业的标准应用,堪称容器编排的必备工具。
K8S 的架构图如下:
K8S 采用了主从分布式架构,主要由一个 Master 和多个 Worker Node 组成,以及包括客户端命令行工具 kubectl 和其它附加项。主要包含了以下核心组件:
- Master Components:作为控制组件,对集群进行调度管理,由 API Server、Scheduler、controller manager、etcd 等组成。在实际应用中,Master 一般会部署成多节点的高可用集群。
- Worker Node:作为真正的工作节点,运行业务应用的容器,包含 kubelet、Proxy 和 Container Runtime。
- kubectl:用于通过命令行与 API Server 进行交互,而对 K8S 进行操作,实现在集群中进行各种资源的增删改查等操作。
Container Runtime 即容器运行时,其实也可以理解为就是像 Docker 这样的容器引擎,后面我们会再细说这个概念。在 K8S 中,主要就是 kubelet 组件通过一个叫 CRI 的接口服务与 Container Runtime 进行通信的。
K8S 能够创建和部署的最小单元是 Pod,一个 Pod 可以包含一个或多个容器,还包括了存储、网络等各个容器共享的资源。通常一个 Pod 运行一个容器,不同 Pod 之间则是相互隔离的。另外,Pod 所支持的容器不只是 Docker,也支持很多其他容器。
Pod 和 Docker 一样也存在数据持久化的问题,解决方案也采用了 Volume 机制,但与 Docker Volume 有些不同。最大的不同就是 K8S 可以支持许多类型的 Volume,Pod 也能同时使用任意数量的 Volume。
容器规范
前面我们说过,容器引擎不只有 Docker,还有 rkt、containerd、pouch、cri-o 等。而容器编排工具也不只有 Kubernetes,还有 Docker Swarm、Apache Mesos 等。各大派系相互竞争,在竞争合作之间寻找平衡从而导致了标准规范的诞生。
2015 年 6 月,Docker 带头成立 OCI(Open Container Initiative),旨在“制定并维护容器镜像格式和容器运行时的正式规范(OCI Specifications)”,其核心产出是 OCI Runtime Spec(容器运行时规范)、OCI Image Spec(镜像格式规范)、OCI Distribution Spec(镜像分发规范)。所以 OCI 组织解决的是容器的构建、分发和运行问题。
一个月之后,Google 带头成立了 CNCF(Cloud Native Computing Foundation),旨在“构建云原生计算 —— 一种围绕着微服务、容器和应用动态调度的、以基础设施为中心的架构,并促进其广泛使用”。所以 CNCF 组织解决的是应用管理及容器编排问题。
这两个组织不再是竞争关系,而是相辅相成,共同制定了一系列行业事实标准。其中,与容器相关的最为重要的几个规范包括:OCI Distribution Spec、OCI Image Spec、OCI Runtime Spec、CRI、CNI、CSI 和 Shimv2。
- OCI Runtime Spec:容器运行时规范,旨在指定容器的配置、执行环境和生命周期。容器的配置文件指定命名为
config.json
,并包含了容器的一系列配置信息。定义执行环境是为了确保在容器内运行的应用程序与运行时具备一致的环境,以及为容器的生命周期管理定义了标准的公共操作。 - OCI Image Spec:镜像格式规范,由 4 块内容组成:清单(manifest)、镜像索引(image index)、配置(configuration)和文件系统层(filesystem layers)。清单描述了镜像的元数据。镜像索引是可选的,指向不同平台的 manifest 文件,相当于整个镜像的入口,从这个文件可以获取整个镜像依赖的所有文件信息。配置保存了文件系统的层级信息,以及容器运行时需要的一些信息。文件系统层描述了如何以 layer 的方式叠加成一个完整的文件系统,以及如何用 layer 去表示对文件作出的改动。
- OCI Distribution Spec:镜像分发规范,定义了一套 API 协议用来促进和标准化内容的分发。
- CRI:Container Runtime Interface,容器运行时接口,是容器编排系统和容器引擎之间交互的接口。
- CNI:Container Network Interface,容器网络接口,由一组用于配置 Linux 容器的网络接口的规范和库组成,同时还包含了一些插件。仅关心容器创建时的网络分配,和当容器被删除时释放网络资源。接口只有四个方法:添加网络、删除网络、添加网络列表、删除网络列表。
- CSI:Container Storage Interface,容器存储接口,是容器编排系统与容器存储系统之间交互的接口。
- Shimv2:这是用来对接基于虚拟机的容器(如 Kata)的接口规范。
其中,CRI、CNI、CSI 都是 K8S 系统里原本所定义的几个接口。
这些规范确立之后,基于这些标准规范的具体实现不断涌现,呈现出一片百花齐放的景象。Docker 也为了适应 OCI 标准规范而剥离出来了两个标准化的组件,一个叫 runc,一个叫 containerd。下面再来了解下这两个组件。
容器运行时
讲具体的组件之前,我想先聊聊另一个概念,叫 Container Runtime(容器运行时)。lxc、docker、rkt、cri-o、containerd、runc 等其实都属于 Container Runtime。但其实,Container Runtime 还可以再分为两大类:High-Level Container Runtime 和 Low-Level Container Runtime。它们的关系如下图:
要理解这两者,我们可以从运行容器的步骤说起。一般来说,运行一个容器的过程可分为三步:
- 下载镜像
- 解压镜像到文件系统包中
- 从解压的文件系统包上运行容器
只专注于解决第三步的运行时,即负责底层运行容器的 Runtime,就属于 Low-Level 容器运行时。lxc、runc 都归于这一类。对应地,High-Level 容器运行时位于 Low-Level 容器运行时的上层,则负责更为高级的容器功能,包括镜像的传输和管理,镜像的解压,并传递给 Low-Level 容器运行时来运行容器。这一类的 Runtime 主要有 docker、containerd、cri-o 等。
runc 应该算是应用最广泛的 Low-Level Container Runtime,是 Docker 为了适应 OCI Runtime Spec 而贡献给 OCI 组织的,而它的前身其实是 Docker 内部的一个 lib 库叫 libcontainer。runc 其实也是一个 CLI 工具,实际上,runc 也是通过调用 libcontainer 提供的接口来管理容器的。
其实,Docker 刚发布的时候,是基于 LXC 的。Docker 把 LXC 复杂的容器创建与使用方式简化为了自己的一套命令体系。但随着 Docker 的不断发展,LXC 已不能满足 Docker 的需求(比如跨平台),于是 Docker 公司把底层实现都抽象化,将底层容器的实现方式变成了一种可变的方案,从而诞生了 libcontainer。libcontainer 是一个用于容器管理的 lib 库,基于 Go 语言实现,通过管理 namespaces
、cgroups
、capabilities
以及文件系统来进行容器控制。可以使用 libcontainer 创建容器,并对容器进行生命周期管理。无论是使用 namespace、cgroups 技术抑或是使用 systemd 等其他方案,只要实现了 libcontainer 定义的一组接口,Docker 都可以运行。这也为 Docker 实现全面的跨平台带来了可能。后来,Docker 为了适应标准化,把它移交给了 OCI 组织,OCI 再对 libcontainer 进行了封装并取名为 runc。后来,Docker 也将 libcontainer 更换成了 runc 作为默认的容器 runtime 工具。
containerd 则是应用广泛的 High-Level Container Runtime,也是从 Docker 中剥离出来的,但 containerd 不是贡献给了 OCI 组织,而是捐给了 CNCF 组织,成为了 CRI 标准的一个实现。独立后的 containerd 的架构如下图:
containerd 采用了 C/S 架构,并不是直接面向最终用户的,主要用于集成到更上层的系统里,比如 K8S、 Swarm、Mesos 等容器编排系统。另外,其 CRI 实现是通过插件化的方式实现的。而底层的 Low-Level Container Runtime 则默认使用 runc,当然,实际上只要是符合 OCI 规范的 Runtime 都可以支持。
containerd 启动时主要也是以守护进程的方式运行的,可以管理一台主机上的所有容器。而运行其上的每个容器,则会启动每个单独的 containerd-shim 子进程并结合 runc 或其他 Runtime 进行管理。
其实,从符合哪个标准规范的维度来划分的话,High-Level 容器运行时一般也称为 CRI Runtime,而 Low-Level 容器运行时则称为 OCI Runtime。而说到这,不得不提一下一件事,2020 年底的时候,Kubernetes 官方发布公告,宣布自 v1.20 起放弃对 Docker 的支持,届时用户将收到 Docker 弃用警告,并需要改用其他容器运行时。这是因为 Docker 并不支持 CRI,而 Kubernetes 对 Docker 的支持主要是通过一个叫 dockershim 的桥接服务,能够将 Docker API 转换为 CRI。但在后续版本当中,Kubernetes 将不再提供这项桥接服务。因此,对于已经在 Kubernetes 中使用了 Docker 的系统,建议可以从 Docker 迁移到 containerd,毕竟 containerd 就是从 Docker 剥离出来的,迁移的成本最低。
容器网络
容器网络也是容器化平台中很核心的一个基础模块,主要就是解决容器之间互相通信的问题,包括同一主机上的容器,也包括不同主机的容器。另外,对于容器编排系统来说,容器还要能和集群中所有的节点直接通信。
早期的容器网络设计把重点放在了如何连接同一主机上的容器,让它们可以和外界进行交互。
Docker 的容器网络一开始采用的是 host 模式,运行在同一主机上的容器直接使用宿主机的 network namespace,和宿主机同一个 IP,而容器会占用宿主机上的一个端口,通过这个端口和外界通信。这样,就需要手动维护端口的分配,不同的容器服务需要使用不同的端口。这种模式另一个明显的劣势就是容器不再拥有隔离、独立的网络栈。
Docker 后来升级为 bridge 模式,这种模式下,首先会在宿主机创建一个网桥叫 docker0,当创建一个新的容器时,容器通过 DHCP 获取一个与 docker0 同网段的虚拟 IP 地址,并默认连接到 docker0,以此实现容器与宿主机的网络互通。同时,docker 会为容器创建独立的网络栈,实现容器之间、容器与宿主机之间的网络隔离。其网络模型如下图:
不过,如果容器想要和外界通信的话,还是需要使用 host 的 IP 地址。这时候需要用到 NAT 将 host-ip:port 转换成 private-ip:port。
而不管是 host 模式还是 bridge 模式,主要都是为了解决同一主机内的容器网络通信问题,而不是为了跨主机通信而设计的,因此并不能优雅地解决跨主机容器通信的问题。
而随着容器技术的快速发展,容器跨主机通信的需求越来越迫切。后来,Docker 公司和 CoreOS 公司相继提出了各自的容器网络标准 CNM(Container Network Model) 和 CNI(Container Network Interface)。
Docker 还将网络部分的代码从 Docker 核心代码中剥离出来,形成了单独的项目 libnetwork,这也成为了实现 CNM 的第一个项目,而且被作为 Docker 容器内置的网络插件。
而 CNI 相比 CNM 更加通用和简单,也因此,当 K8S 社区决定从 CNM 和 CNI 中选择一个作为 K8S 的容器网络标准时,最终选择了 CNI。也因为 K8S 的影响力,后来的很多项目也都选择了 CNI,因此,CNI 更有望成为未来容器网络的标准。
CNI 的基本思想为:Container Runtime 在创建容器时,先创建好 network namespace,然后调用 CNI 插件为这个 netns 配置网络,其后再启动容器内的进程。
支持 CNI 的网络方案被称为 CNI 插件。CNI 插件包括两部分:
- CNI Plugin:负责给容器配置网络,它包括两个基本的接口
- 配置网络:AddNetwork(net NetworkConfig, rt RuntimeConf) (types.Result, error)
- 清理网络:DelNetwork(net NetworkConfig, rt RuntimeConf) error
- IPAM Plugin:负责给容器分配IP地址,主要实现包括 host-local 和 dhcp。
常见的 CNI 插件包括 IPvlan、MACvlan、Bridge、Calico、Flannel、Weave、Open vSwitch、Contiv、CNI-Genie 等。
而这么多网络插件,其实可以根据实现模式分为三类:Underlay、Overlay、Routing。
Underlay 模式中容器和宿主机位于同一层网络,依赖于底层网络的能力来打通容器之间的网络,以 IPvlan、MACvlan 为代表。
Overlay 模式的典型特征是容器独立于主机的 IP 段,这个 IP 段进行跨主机网络通信时是通过在主机之间创建隧道的方式,将整个容器网段的包全都封装成底层的物理网络中主机之间的包。该方式不依赖于底层网络,实现方案以 Flannel、Weave 为代表。
Routing 模式中主机和容器也分属不同的网段,与 Overlay 模式的主要区别在于它的跨主机通信是通过路由打通,无需在不同主机之间做一个隧道封包。但路由打通就需要部分依赖于底层网络,比如说要求底层网络有二层可达的一个能力。该模式的典型方案为 Calico。
Flannel 和 Calico 是两款流行的插件,有必要再多了解一下。
Flannel 通过给每台宿主机分配一个子网的方式为容器提供虚拟网络,它基于 Linux TUN/TAP,使用 UDP 封装 IP 包来创建 Overlay 网络,并借助 etcd 维护网络的分配情况。其原理如下图所示:
目前已支持 udp、vxlan、host-gw、aws-vpc、gce、ali-vpc 等数据转发方式,其中以 vxlan 技术最为流行,因为 vxlan 性能更良好并且需要的手动干预更少。
与其他方案相比,Flannel 的优点就是相对容易安装和配置。一般来说,在初期使用 Flannel 是一个稳妥安全的选择,直到你开始需要一些它无法提供的东西。
Calico 目前的流行程度还比不上 Flannel,但其受众正在快速增长。虽然 Flannel 被公认为是最简单的选择,但Calico 以其性能、灵活性而闻名。Calico 的功能也更为全面,不仅提供主机和 Pod 之间的网络连接,还涉及网络安全和管理,支持网络策略就是其最受追捧的功能之一。此外,Calico 还可以与服务网格 Istio 集成,以便在服务网格层和网络基础架构层中解释和实施集群内工作负载的策略。
总地来说,Flannel 可以算是入门级插件,Calico 则是进阶级的。
容器化落地
前面讲了那么多,最后,我们就来看看如何将容器化技术落地到我们的项目中。
首先,从选型上来说,上了规模的微服务架构系统是需要使用容器编排系统的,而这块的竞争者虽然有 Kubernetes、Swarm 和 Mesos 等,但实际应用中,Kubernetes 可以说是一支独大,大厂中厂都已经选择了它,那我们其实也没必要再花费时间和精力去对比,选它就对了。
其次,需要选择使用哪个容器运行时。原本,使用最广泛的运行时当属 Docker,但前面我们说过,Kubernetes 已经宣布了不再提供 Docker 的专属桥接服务。而且 Docker 由于剥离出了两个独立的运行时 runc 和 containerd,所以,作为运行时的 Docker 其实也已经不具备太大优势,即是说,作为容器运行时的 Docker,已经不再是最佳选择,containerd + runc 很可能会成为替代 Docker 的最佳选择。
然后,还需要选择 CNI 插件。可在 Flannel 和 Calico 两者之中选一个,我比较倾向于选择 Calico,虽然 Flannel 更容易安装和配置,但 Calico 的部署也不难,而且后续需要用到高级特性时也不需要更换网络方案。
接着,还要考虑整个系统中哪些需要容器化,哪些不需要容器化?我们知道,应用其实可以分为有状态和无状态两种,像网关、业务逻辑层、数据访问层的服务实例大部分都可以做成无状态的,而像数据库、缓存、MQ 等保存数据的应用则是有状态的。虽然从技术上来说,不管是无状态还是有状态的应用,都可以容器化运行,但无状态应用更适合用容器化运行,可以实现服务的水平伸缩,通过容器编排避免单点故障的问题。但数据库、MQ 等需要持久化数据的服务容器化的效用则不大。Redis 如果只是用作缓存,不需要保证该数据持久化,那么数据没有丢失的风险,这时候用容器化也没有问题。
简而言之,我们主要采用 Kubernetes + containerd + runc + Calico 来搭建我们的容器化平台,从网关层,到业务逻辑层,再到数据访问层的所有微服务,都可以进行容器化运行,而数据库、MQ 则无需容器化。
总结
容器化解决的不是应用架构的问题,而是运维部署的问题。容器技术生态发展迅速,涉及到的概念和技术也比较多,所以搞清楚各种概念和技术很重要。本文聊了容器、Docker、Kubernets、容器规范、容器运行时、容器网络等各种概念和技术,并最后简单聊了下容器化落地的一点思路,希望能给到还不太了解容器技术生态的小伙伴一些认识。