云原生的演进系列上:容器化技术的诞生

159 阅读21分钟

所有新技术的诞生,都是为了解决某个具体问题,而非凭空而来。在发展的过程中,它们常常会经历一段探索和试错的弯路,最终沉淀出优秀的解决方案。云原生技术体系可以说是当今社会网络技术生产力的基石,同时也是每一位志存高远的软件工程师在无限技术学习旅途中必须翻越的一座大山。本系列文章预计分为两篇,以记录和整理个人的学习心得。本篇为上篇,将重点介绍容器化技术的起源与发展。

物理机

在 1960 年左右,市场是物理机的时代,如果想启动一个新的应用,那么就需要购买一个物理机,安装操作系统,配置软件运行环境,最后托管到机房。这个时代,我们的工作负载就是物理机,没有资源的隔离。部署服务需要登录机器,手动更新配置。

一开始,由于技术条件落后,网络应用能够提供的服务比较单一,网络服务的规模不大。这种通过人工维护物理机上的软件版还能够被接受,不是当时技术条件下的生产力的主要矛盾。

摩尔定律简而言之"18 个月机器性能就会提高一倍"。计算性能的提高也就会带来需求的提高,软件架构复杂性的提高。很快,物理机部署的低效就暴露出来。通过物理机直接部署有以下几个缺点。

  1. 资源浪费:机器资源无法按需分配,即使是应用利用率很低,也会占用一台物理机。
  2. 部署复杂:软件强依赖硬件和操作系统。更新软件需要人工介入。在大规模部署时,这是无法接受的。而且系统出现故障时,恢复成本高。

虚拟化

1998 年前后虚拟化逐渐成熟。其中最具有代表性的技术是 2001 年 VMware 发布了第一个针对 x 86 服务器的虚拟化产品 —— VMware ESX。使用 VMware ESX 之后,可以在一台物理机器上运行多个虚拟机,如果业务需要扩容,那就再开通一个虚拟机,整个过程只要几分钟。 自此,资源有了初级的隔离,并具有基本的分配/利用的能力。这种隔离硬件级别的隔离,每个虚拟机运行在独立的虚拟硬件环境上,并运行各自的操作系统。

虚拟化的成熟,催生的云计算的市场。基于虚拟化技术的云计算产品在 2006 年开始,陆续的出现了。比如 lass,pass,faas, 公有云,私有云,等多种服务模型。

  • lass: 基础设施即服务。通过按时计费的方式租借服务器(卖资源)。用户可以自己选择虚拟机实例和相关的资源。
  • Paas:平台即服务。使开发者不必费心考虑操作系统和开发工具更新或者硬件维护,这些有云服务提供商来进行维护,并提供扩展和可用性。
  • Faas: 功能即服务,物理硬件,虚拟机,web 服务器等都有云服务商提供,用户只需要关注逻辑实现,不需要关注任何的基础设施。是 severless 的概念。

但虚拟化虽然提高了资源的利用率,并具备一定的隔离能力。但每一个虚拟化实例都是一个完整的操作系统,操作系统本身需要占用一定的物理资源,这导致资源的有效利用率不足。 同时当物理机本身发生错误时,那么这个错误讲影响上层的所有实例。

容器化

容器能力是当前云计算,微服务等核心技术的基石,是本文详细介绍的内容。在了解容器化之前,这里先介绍软件的各级别的兼容能力。

隔离级别

"一次编译,到处运行"是 Java 早年的宣传口号,而一个计算机软件要能够正确的运行,就必须有以下三个方面的兼容性来共同保障。

  • ISA 兼容:目标机器的指令集兼容。例如 ARM 架构的计算机软件无法运行在 x 86 架构的计算机上。
  • ABI 兼容:依赖环境和目标系统兼容。比如 window 上的程序不能直接在 linux 中运行。Android 上的程序不能在 IOS 上运行。
  • 环境兼容:配置文件,数据库地址,文件权限等。任何一个环境环节出错,程序都无法正常运行。

根据抽象目标和兼容性的高低不同,虚拟技术又被分为 5 大类。

  • 指令集虚拟化:通过软件来模拟不同的 isa 架构处理器的工作过程。通过增加转换层的方式,将程序的 isa 指令翻译成本机的 isa 指令。通过这种方式我们甚至可以直接在 web 游览器上运行一个完整的 linux 系统。但由于每条指令都需要进行转换或模拟,因此这种格式方式是性能损失是最大的。
  • 硬件层模拟化:通过软件来模拟硬件(芯片,内存,显卡,硬盘)的工作过程。可以通过软件直接模拟硬件,也可以将真实的硬件直通过到虚拟机中使用。市场上的代表产品为上面提到的 VMware ESX。但这种虚拟方式的每一个实例都需要运行一个操作系统,因此性能也会有一定的损失。
  • 操作系统层虚拟化:操作系统层虚拟化则不会提供真实的操作系统,而是采用隔离手段,使得不同进程拥有独立的系统资源和资源配额,在进程看起来看起来仿佛是独享了整个操作系统一般,其实系统的内核仍然是被不同进程所共享的。操作系统级的虚拟具有更轻量化,资源利用充分的特点。但这种虚拟化是依赖操作系统的,而且容器之间的管理方复杂,需要额外的工具(比如 k 8 s )来协调。操作系统虚拟化的代表就是今天的主角 docker。
  • 运行库虚拟化:通过软件翻译 api 接口的方式,在 ABI 层进行兼容,从而使得 linux 下的程序可以在 window 上运行。最常见的代表作是 wine,和 win 10 以后的 wsl(window subsystem for liunx)。但由于不同操作系统或运行库版本差异,运行库虚拟化可能导致某些应用程序不兼容或运行失败。这种兼容性功能受限且不稳定性能较差。
  • 语言层虚拟化:由虚拟机将高级语言生成的中间代码转换为目标机器可以执行的指令。最常见的是就是大名鼎鼎的 JVM。

容器的原理与演变。

以 Docker 为代表的容器化技术,依赖操作系统提供的操作系统级虚拟化功能,而非完全独立的虚拟化技术。它并不是从零开始创造虚拟化技术,而是在现有的虚拟化技术和操作系统功能基础上进一步发展和优化。它的关键特性(如隔离、资源限制和管理)依赖于以下操作系统级支持。

  • Chroot 和文件系统隔离:使用文件系统视图隔离(如 chroot)为每个容器提供独立的文件系统环境,确保容器只能访问授权的目录。
  • Namespace(命名空间): 提供进程级别的隔离机制,将容器的进程、网络、文件系统等资源与宿主机和其他容器隔离开。
  • Cgroups(控制组):提供资源限制和分配功能,用于管理容器对 CPU、内存、磁盘 I/O 等资源的使用。确保一个容器不会因资源耗尽影响整个系统的稳定性。

Chroot

chroot 是“change root”的缩写,1979 年被引入 unix 系统。它允许管理员将进程的根目录锁定到特定的位置。从而限制进程对文件系统的访问范围。由于程序只能访问 chroot 目录下的文件,因此也被称为 chroot 监狱。世界上第一个监控黑客行动的蜜罐程序就是使用 chroot 来实现的。后来 FreeBSD 4.0 重新实现了 chroot 命令,再后来,苹果公司以 FreeBsd 为基础研发了 ios 操作系统。此后,黑客将绕过 ios 沙箱机制而安装任意安装程序的方式就被称为 越狱

在 linux 中,一切资源都可以被视为文件,一切处理都可以被视为对文件的处理。理论上,只需要隔离了文件,就可以隔离一切系统资源。但在 Linux 系统中,从低层次的资源(如网络、磁盘、内存、处理器)到操作系统控制的高层次资源(如 UNIX 分时、进程 ID、用户 ID、进程间通信),都存在大量非文件暴露的操作入口。因此单靠 chroot 无法实现对资源的完美隔离。

Namespaces

名称空间(namespace)的概念在很多现代的高级程序语言中都存在,用于避免不同开发者提供的 API 相互冲突。 而 Linux 的名称空间是一种由内核直接提供的全局资源封装。进程在一个独立的 linux 名称空间中会感觉自己在独享这个 liunx 主机上的一切资源(如进程 ID、网络接口等)。

Namespace 类型隔离内容典型场景
PID NamespacePID(进程 ID)容器进程树独立
Mount Namespace挂载点、文件系统容器文件系统独立
Network Namespace网络接口、路由表、防火墙规则独立 IP 地址和网络接口
UTS Namespacehostnamedomainname容器拥有独立主机名
IPC Namespace信号量、消息队列、共享内存容器独立使用共享内存、消息队列等
User Namespace用户 ID(UID)、组 ID(GID)容器内 root 权限不影响宿主机

Cgroups

通过 NameSpace 已经完成对进程的资源隔离。但如果不独立控制分配给各个进程的资源使用配额的话,一个进程若发生了内存溢出或占满的处理器,那么其他进程就会被莫名挂起。而 cgroups 是 Linux 内核中用于隔离、分配并限制进程组使用资源配额的机制。例如控制 cpu 的占用时间,占用内存的大小,控制磁盘 io 速度等。下面列出的是常见的配额控制类型。

类型作用典型场景
CPU控制进程的 CPU 时间分配,限制或保证特定进程的 CPU 使用比例。高优先级任务预留 CPU、限制低优先级任务 CPU 使用率。
Memory限制和隔离进程的内存使用,包括物理内存和交换分区(swap)。防止内存泄漏程序耗尽系统内存;为容器设置最大内存限制。
BlkIO控制进程对块设备的 I/O 带宽,如磁盘读写速率。限制日志服务或数据分析任务对磁盘的高强度读写,保护关键应用性能。
NetCls为进程分配网络流量的分类标识符(ClassID),便于流量控制。配合 tc(流量控制)工具限制或优先处理特定进程的网络流量。
PIDs限制进程组中可以创建的最大进程数,防止进程数爆炸导致系统瘫痪。避免错误程序(如死循环 fork)大量生成进程耗尽资源。
Devices控制进程对特定设备的访问权限(如读、写、执行)。禁止非授权容器或用户访问敏感设备,如 /dev/sda 或 GPU 设备。
HugeTLB为进程分配和限制对大页内存(HugePages)的使用。高性能应用(如数据库)优化内存分配效率和访问性能。
RDMA控制对远程直接内存访问(RDMA)设备的资源使用限制。优化高性能网络应用,如分布式计算或存储服务。

LXC

当文件系统、访问、资源都可以被隔离后,容器已经有它降生所需的全部前置支撑条件。容器并不是轻量化的虚拟机,容器只是利用命名空间、cgroups 等技术进行资源隔离和限制,并拥有独立的根目录(rootfs)的特殊进程。为了为降低普通用户综合使用 namespacescgroups 这些低级特性的门槛,2008 年 Linux Kernel 2.6.24 内核刚刚开始提供 cgroups 的同一时间,就马上发布了名为 Linux 容器 (LinuX Containers,LXC)的系统级虚拟化功能。

通过 LXC 可以在同一主机上运行多个相互隔离的 Linux 容器,每个容器都有自己的完整的文件系统、网络、进程和资源隔离环境,容器内的进程如同拥有一个完整、独享的操作系统。

由于 LXC 直接利用 Linux 内核,因此它的性能损耗极小,启动速度快。适合运行在高性能和资源利用率要求较高的场景中。

Docker

[[docker]]

2013 年宣布开源的 Docker 毫无疑问是容器发展历史上里程碑式的发明,它不仅改变了开发者、运维人员和企业如何构建、部署和管理应用的方式,还推动了整个云计算和 DevOps 生态系统的迅猛发展。Docker 是一个开源的容器化平台,使得开发者能够将应用及其所有依赖项打包到一个标准化的容器中,从而实现更轻量、更高效的应用部署。

Docker 是 Go 语言实现,在最初的版本中,底层使用了 LXC 。Docker 可以让我们方便的创建和使用容器,只要你的程序打包到了 docker 中,那么无论运行在什么环境下程序的行为都是一致的,真正实现“build once, run everywhere”。

Docker 的实现

Docker 在最初实现是基于 LXC,从 0.7 版本以后开始去除 LXC,转而使用自行开发的 libcontainer。不过与 LXC 类似的是,Docker 的底层是通过 Liunx 名称空间(namespace),cgroups,和 UnionFs(用于实现类似于 Chroot 的能力)三大基本能力来实现的。

UnionFs 联合文件系统

NameSpace,Cgroups 的作用和实现方式上面已经介绍过了,不再赘述,这里主要讲一下 UnionFS 这个新概念。

UnionFS 其实是一种为 Linux 操作系统设计的用于把多个文件系统整合到同一个挂载点的文件系统服务。

比如有下面的目录结构

.
|-- apple
|   |-- ios
|   `-- mac
`-- microsoft
    |-- win
    `-- office


通过 mount 命令将 apple 和 microsoft 整合后,会生成如下的文件结构。

|-- ios
|-- mac
|-- win
`-- office

UnionFS(联合文件系统)就是一种文件系统服务,它可以将多个目录(文件系统层)合并成一个虚拟的目录结构,并为上层提供统一的视图。这种设计使得文件系统支持 写时复制(Copy-on-Write)分层存储

主要功能包括:

  1. 分层存储:不同层之间逻辑独立,但呈现为一个整体。
  2. 写时复制:只在写操作时复制需要修改的部分,减少资源占用。
  3. 灵活组合:支持动态加载和卸载层,方便系统扩展。

Docker 的基本概念

Docker 中有以下几个基本的概念。

Docker 容器。

Docker 利用容器来运行应用,容器是从镜像创建的运行实例,它可以被启动,开始、停止、删除、每个容器都是互相隔离的。容器包含了应用及其运行所需的所有依赖,而不需要额外的操作系统层级开销,提供快速的启动速度和轻量级的资源消耗。

Docker 镜像文件

Docker 镜像是一个轻量级、独立的可执行包,其中包含运行软件所需的一切,包括代码、运行时、库、环境变量和配置文件。镜像不包含任何动态数据,其内容在构建之后也不会被改变。

当我们使用 Docker 容器时,首先需要下载对应的镜像。与虚拟机相比,镜像更加轻量化,因为它本质上主要是一个 rootfs。对于精简的操作系统,rootfs 可以非常小,仅包含基本的命令、工具和程序库即可,因为容器直接共享宿主机的内核。

Docker 镜像采用分层存储的方式,每个镜像由多个只读层叠加而成。这种特性大大提升了镜像的复用性、定制性和共享性。容器的文件系统是这些镜像层的叠加结果,每一层都可以被容器读取。在镜像构建过程中,按层逐步构建,前一层作为后一层的基础。每一层一旦构建完成就不会再改变,任何后续的更改仅影响新增的层。

  • rootfs 是 Docker 容器启动后内部进程可见的文件系统,即容器的根目录。它通常包含操作系统运行所需的基础文件系统,例如类 Unix 操作系统中的 /dev/proc/bin/etc/lib/usr/tmp 等目录,以及运行容器所需的配置文件和工具。

Docker 镜像采用分层存储的方式,每个镜像由多个只读层叠加而成,而多层的 rootfs 的叠加就是通过 UnionFs 来完成的。在合并的目录中进行操作时,各个目录之间有上下顺序,上层目录的同名文件会遮盖住下层的文件。

Docker 镜像可以通过一个 Dockerfile 定义,并使用 docker build 命令进行构建。Dockerfile 中的每一条指令都会创建镜像的一层,这些层通过 UnionFS 叠加,最终形成完整的镜像,供容器运行时使用。

以下是一个简单的 Dockerfile 示例,

# 使用官方的基础镜像 (选择 lightweight 的 Python 镜像)
# FROM命令:指定基础镜像,这一层是所有其他层的基础。
FROM python:3.9-slim 

# 设置工作目录为 /app
# WORKDIR命令:设置容器内的工作目录,简化后续路径管理。
WORKDIR /app

# 将当前目录的内容复制到容器的 /app 目录
# ADD命令:将代码和文件添加到镜像中,创建一层。
ADD . /app

# 安装依赖包
# RUN命令:执行命令(如安装依赖),生成新的镜像层。
RUN pip install --no-cache-dir -r requirements.txt

# 设置默认的环境变量
ENV PYTHONUNBUFFERED=1

# 暴露容器的端口号
# EXPOSE命令:声明容器使用的端口,方便配置网络。 
EXPOSE 8000

# 定义容器启动时执行的命令
# CMD命令:定义容器启动时运行的默认命令。
CMD ["python", "app.py"]

[!不可变基础设施] 在传统的服务器管理模式中,线上服务器需要不断更新和修改。当变更需求出现时,工程师和管理员通常通过 SSH 登录到服务器,手动升级或降级软件包版本,逐台调整配置文件,并直接在现有服务器上部署新代码。这种方式属于可变基础设施模型,因为变更是直接对运行中的服务器进行修改。

而在 Docker 的不可变基础设施模型中,若需要调整线上配置,并不会直接登录容器并手动修改配置,而是通过更新镜像文件(如 Dockerfile),构建新的镜像,并启动新的容器实例来替换旧实例。经过验证后,新服务逐步上线,旧服务则逐步下线。这种方式确保每次变更都以全新的环境部署,避免了手动修改带来的不一致问题。

Docker 仓库

Docker 仓库是集中存放镜像文件的地方。当需要在其他服务器上使用我们构建的 docker 镜像时,就需要从仓库中获取对应的镜像文件。跟 Maven 的仓库作用类似,我们可以通过<仓库名>:<标签>的格式来指定具体是这个软件哪个版本的镜像。仓库分为两种,公有仓库和私有仓库,最大的公开仓库是 docker Hub,存放了数量庞大的镜像供用户下载。

Docker 和 LXC

同为容器化技术解决方案,大多数程序员对 Docker 如雷贯耳,而听说过 LXC 的却寥寥无几。这主要是因为 LXC 的设计理念是封装机器,而 Docker 的设计理念是封装应用

封装机器意味着将容器视为虚拟化的系统环境,模拟一个完整的操作系统实例。LXC 容器内可以运行多个进程,就像一个完整的 Linux 系统。然而,Docker 的容器更轻量化,其内部的操作系统仅为运行单个应用提供必要环境,而不强调完整的操作系统功能。Docker 推崇“单容器单进程”的最佳实践,设计上通过 Dockerfile 的单一 ENTRYPOINT 来实现。这并非人为限制,而是为了通过监控容器内主进程(PID 1)的状态,判断容器运行是否正常。

LXC 更接近底层,是一种轻量级的虚拟化解决方案,性能接近裸机。由于需要大量手动配置,它更适合对性能和灵活性要求极高的场景,比如嵌入式设备或专用高性能服务。

相比之下,Docker 专注于上层应用封装,通过高封装度降低了技术门槛。它不仅支持 Linux,还兼容 Windows 和 macOS,跨平台能力使其更适合开发和生产环境。此外,Docker 提供了构建、打包和分发镜像的完整工具链,极大提升了开发效率。其丰富的镜像生态和高效的分发能力,使开发者能够轻松共享和部署应用。这些特性使 Docker 成为大多数开发者和企业的首选容器化技术。

容器间协作

假设我们有以下场景:三个 Docker 镜像,分别封装了:

  1. 一个提供 HTTP 服务的 Nginx 容器;
  2. 一个用于日志收集的 Filebeat 容器;
  3. 一个封装了 confd(用于在配置管理系统中动态生成配置文件的工具)的容器。

我们的目标是让 Filebeat 容器收集 Nginx 容器产生的日志,并通过 confd 监听配置中心的变化,自动更新 Nginx 的配置。

那么我们将如何解决容器之前的这种协同工作的需求呢?

首先,我们可以通过将 Filebeat 容器和 Nginx 容器挂载在同一个目录,来使 Filebeat 收集 Nginx 产生的日志。同时,为了使 confd 能与 Nginx 容器交互并发送 HUP 信号进行配置更新,我们需要让 Nginx 容器与 confd 容器共享 IPC 名称空间。

尽管这种通过共享磁盘位置和 IPC 名称空间的方式能解决问题,但它并不优雅,因为它打破了 Docker 中 cgroups 和 namespaces 提供的隔离性。而 Linux 的 cgroups 和 namespaces 原本都是针对进程组而不仅仅是单个进程来设计的,进程组中的多个进程天然的可以共享访问权限和资源配额。而在 Kubernetes 中,有一个类似进程组的概念,即 Pod。我们可以将 Pod 理解为容器组,Pod 内的容器可以像同一进程组中的进程一样共享资源和数据。同处于一个 Pod 内的多个容器,在 Kubernetes 中被称为超亲密的容器关系。

在 Kubernetes 中,一个物理机上可以运行多个 pod,一个 pod 内有多个容器,多个容器共享资源和数据,被称为超亲密容器。通过这种方式,我们能够确保容器间的高效协作,同时保持必要的隔离性。这也是 Kubernetes 技术的核心优势之一。Kubernetes 在云原生领域也是一个非常重要且相对复杂的技术,我将会放到下一篇文章中来进行详细探讨。

这篇文章主要介绍了容器化技术,特别是 Docker 技术。但当的容器的数量级达到成千上万时,那么又该如何管理呢?这就是容器编排的技术,而下面我将把重点放在容器编排相关的技术探讨。 敬请期待《云原生的演进系列下:容器编排技术》

参考资料

  1. icyfenix.cn/
  2. www.thebyte.com.cn/architectur…
  3. blog.csdn.net/crazymakerc…
  4. cloud.tencent.com/developer/a…