努力努力再努力wz头像
关注
【Docker 入门系列】:从容器运行时到整体架构:一文串联 LXC、runc、containerd、镜像、Registry 与 Docker整体架构以及生态封面图

【Docker 入门系列】:从容器运行时到整体架构:一文串联 LXC、runc、containerd、镜像、Registry 与 Docker整体架构以及生态

🔥 本文专栏:Docker
🌸作者主页:努力努力再努力wz

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

💪 今日博客励志语录:有时候坚持并不是相信自己一定会赢,而是不愿意让现在的自己,替未来的自己认输


思维导图

Docker 为什么能够创建容器?
        ↓
Docker 本身并没有重新实现一套操作系统
        ↓
真正的隔离与资源控制能力来自 Linux Kernel
        ↓
Namespace:隔离“能看到什么”
Cgroup:限制“能使用多少”
Mount / rootfs:构造容器文件系统视图
        ↓
早期:Docker 借助 LXC 组织这些 Linux 能力
        ↓
LXC 对外提供 create / start / stop / destroy 等容器语义
        ↓
Docker 逐渐希望自己掌握底层容器实现
        ↓
libcontainer
        ↓
直接封装 Linux namespace / cgroup / mount 等能力
        ↓
runc
        ↓
将底层容器能力做成独立、一次性的动作执行器
        ↓
但是 runc 干完动作就退出,不能承担长期管理
        ↓
containerd + shim
        ↓
containerd:长期管理容器生命周期
shim:长期承接具体容器/task 的运行状态
runc:需要动作时临时启动,执行完成后退出
        ↓
Docker 整体进一步形成 Client / Server 架构
        ↓
Docker CLI
        ↓
Docker Engine API + HTTP
        ↓
Unix Domain Socket / TCP
        ↓
dockerd
        ↓
containerd → shim → runc → libcontainer → Linux Kernel
        ↓
真正的容器进程运行在 Docker Host
        ↓

与此同时:
应用程序 + 依赖库 + 配置 + 用户态 rootfs
        ↓
Docker Image
        ↓
Registry 负责镜像存储与分发
        ↓
目标 Docker Host 拉取镜像并创建容器
        ↓
多服务 / 多节点 / 云计算 / 大数据 / 微服务场景
        ↓
Docker 的价值被进一步放大

引入:不要先把 Docker 理解成“一个命令工具”

在刚开始接触 Docker 时,很容易直接看到:

docker run
docker ps
docker pull
Dockerfile
Image
Container

然后把 Docker 理解成一个能够“启动容器”的命令行工具。

但是如果只从命令开始学,会出现一个比较明显的问题:

命令会用了,但是不知道 Docker 为什么要设计出 runc、containerd、shim、Image、Registry 这些东西,更不知道这些组件之间究竟是什么关系。

因此,这里仍然沿用此前学习 Docker 时建立的心智模型:

不从“它有什么”开始,而从“它为什么会一步一步变成现在这样”开始。

首先要明确一个最底层的认知:

Docker 并没有凭空创造容器。容器真正依赖的是 Linux 内核已经提供的 namespace、cgroup、mount 等能力。Docker 做的事情,是把这些底层能力进一步组织、封装和产品化。


一、先回到底层:容器真正依赖的是 Linux Kernel

1. Docker 并没有重新实现一套操作系统

此前已经学习过,所谓容器本质上仍然是 Linux 中的普通进程。

假设宿主机中运行:

Host Linux
├── process A
├── process B
└── container process

容器进程并没有自己的 CPU,也没有自己的物理内存,更没有自己的 Linux 内核。

它和其他进程一样,最终都需要经过宿主机 Linux Kernel:

容器进程
   ↓
Linux Kernel
   ↓
CPU / Memory / Disk / Network

只不过 Linux 内核能够给这个进程构造一个被隔离后的资源视图。

例如:

PID Namespace
→ 让进程看到独立的进程编号空间

Mount Namespace
→ 让进程看到独立的挂载视图

Network Namespace
→ 让进程看到独立的网络协议栈视图

UTS Namespace
→ 让进程看到独立的 hostname 等信息

所以 Namespace 解决的是:

这个进程“能够看到什么”。

而 Cgroup 解决的是另外一个问题:

这个进程组最多使用多少 CPU?
最多使用多少内存?
能够产生多少 I/O?

也就是说:

Cgroup 解决的是这个进程“能够使用多少资源”。

因此,最底层的容器模型其实一直没有变化:

Linux Kernel
├── Namespace:资源视图隔离
├── Cgroup:资源使用限制
├── Mount:文件系统视图
├── Capability:权限拆分
└── Seccomp:系统调用限制

Docker、LXC、runc 等都位于这些 Linux 能力的上层。


二、从 Linux 原始能力到 LXC:把“内核能力”封装成“容器语义”

1. Linux 提供的是能力,而不是一个 create_container 系统调用

Linux Kernel 并不存在:

create_container();

这样一个高级接口。

真正创建一个容器环境,需要组合大量操作,例如:

创建 Namespace
        ↓
创建 / 启动目标进程
        ↓
配置 Cgroup
        ↓
准备 rootfs
        ↓
配置 Mount Namespace
        ↓
配置 UID / GID
        ↓
配置 Capability
        ↓
配置 Seccomp
        ↓
最终 exec 用户程序

因此,Linux 提供的是一组比较底层的“积木”。

而真正的容器工具需要完成的是:

将这些积木组合起来,形成“创建容器、启动容器、停止容器、销毁容器”这样的高级语义。

2. LXC 做的事情就是封装这一组 Linux 能力

LXC 可以先简单理解成:

Linux Kernel
      ↑
     LXC
      ↑
create / start / stop / destroy ...

也就是说,LXC 将:

namespace
cgroup
mount
进程创建
权限配置
文件系统准备

这些底层动作组织起来,然后向上层暴露更容易使用的容器生命周期接口。

这和我们学习 C++ 时的“封装”思想是一致的。

不过这里需要注意:LXC 的封装并不是此前我们实现 Socket 类时那种特别薄的一层封装。

例如我们以前可能写:

int Socket::Bind(...) {
    int ret = bind(...);
    if (ret < 0) {
        // error
    }
    return ret;
}

这种封装基本是:

一个成员函数
≈
一个系统调用

但是容器运行时的封装要厚得多。

一个逻辑上的:

CreateContainer

内部可能对应:

namespace 创建
+
cgroup 配置
+
mount rootfs
+
权限设置
+
父子进程同步
+
exec
+
一系列错误处理

所以更准确地说:

LXC 把 Linux 提供的一系列原子能力组合成了一套真正具有“容器语义”的高层接口。


三、Docker 为什么逐渐从 LXC 转向 libcontainer

1. Docker 早期可以借助 LXC 创建容器

早期可以先抽象成:

Docker
  ↓
LXC
  ↓
Linux Kernel

Docker 不需要自己直接面对底层复杂的 namespace、cgroup、mount 等细节。

它只需要告诉 LXC:

创建一个容器
启动一个容器
停止一个容器

然后由 LXC 完成真正的底层工作。

这个设计在早期非常合理,因为 Docker 可以快速利用 Linux 已经成熟的容器技术。

2. 但是长期依赖 LXC 会带来控制权问题

随着 Docker 自身不断发展,它会逐渐希望更加精确地控制容器创建流程。

例如 Docker 可能希望自己决定:

Namespace 应该怎样组合
Cgroup 应该如何组织
rootfs 应该怎样准备
Capability 应该如何配置
容器生命周期应该怎样控制

如果所有事情都必须经过 LXC:

Docker
  ↓
LXC 对外提供什么能力
  ↓
Docker 才能使用什么能力

那么 Docker 的底层实现会受到 LXC 抽象边界的限制。

如果 Docker 想增加某些更深层的控制能力,就可能需要:

修改 LXC
或者
等待 LXC 提供新的能力

同时,依赖外部 LXC 还意味着运行环境中需要保证:

LXC 已经安装
+
LXC 版本正确
+
不同版本行为能够兼容

这和 Docker 自己希望解决的“减少环境依赖”在一定程度上也是冲突的。

因此 Docker 后来逐渐把这部分能力掌握到自己手中。

3. libcontainer:Docker 自己控制底层容器实现

于是出现了:

libcontainer

可以先理解成:

Docker 自己实现的一套 Linux 容器底层库。

它同样不会创造新的内核机制,而是直接组织:

namespace
cgroup
mount
capability
seccomp
进程管理

这些 Linux Kernel 已经提供的能力。

于是架构逐渐变成:

原来:
Docker
  ↓
LXC
  ↓
Linux Kernel

后来:
Docker
  ↓
libcontainer
  ↓
Linux Kernel

这里最重要的变化并不是“底层容器原理发生了变化”,而是:

Docker 将底层容器创建逻辑的控制权从 LXC 转移到了自己手中。


四、从 libcontainer 到 runc:把“库”变成真正的动作执行器

1. libcontainer 只是一个库

libcontainer 本质上仍然是程序内部调用的代码库。

它不是一个长期运行的容器管理进程。

也就是说,不能简单理解成:

libcontainer start xxx

真正需要有一个可执行程序,将这些底层能力组织成可以被上层调用的容器动作。

于是引出了:

runc

2. runc 可以理解成低级容器动作执行器

runc 内部集成了 libcontainer 的能力。

当上层需要完成某个具体动作时,可以启动一个 runc 进程,例如概念上:

runc create
runc start
runc exec
runc kill
runc delete

然后:

runc
 ↓
libcontainer
 ↓
Linux Kernel 接口
 ↓
namespace / cgroup / mount ...

最终完成真正的容器操作。

这里需要建立一个非常重要的认知:

runc 不是一个一直运行在后台的容器管理守护进程,而是一个“需要做动作时启动,动作完成后退出”的一次性执行器。

可以抽象成:

上层发出一个动作
      ↓
启动 runc 进程
      ↓
runc 调用 libcontainer
      ↓
完成 Linux 底层操作
      ↓
runc 退出

所以runc 则是:

为了完成某一次具体任务而临时拉起的执行程序。


五、既然 runc 会退出,谁负责长期管理容器?

这就继续引出了:

containerd
+
containerd-shim

1. containerd:长期存在的容器生命周期管理者

runc 做完动作就退出,因此它并不适合长期维护:

有哪些容器
容器现在是什么状态
什么时候需要 exec
什么时候需要 stop
什么时候需要 delete

于是需要一个长期存在的后台进程来完成管理工作。

这里就是:

containerd

可以先形成一个非常简单的认知:

containerd
= 长期运行的容器生命周期管理进程

而:

runc
= 某一次具体容器动作的执行器

所以此前最初建立的简化模型:

containerd
    ↓
  runc
    ↓
容器进程

在学习早期并没有问题。

只是随着继续深入,会发现中间还存在 shim。

2. shim:随着容器/task 生命周期长期存在的运行时代理

进一步展开以后:

containerd
    ↓
   shim
    ↓
   runc
    ↓
容器进程

这里可以先使用一个简化模型:

每一个容器旁边有一个长期存在的 shim,负责承接这个容器运行期间的具体事务。

严格来说,现代 containerd 的 Runtime V2 可以让一个 shim 管理一组 task / container,所以“一容器一个 shim”不是绝对规则。

但是在 Docker 入门阶段,用:

一个容器
↔
一个长期存在的 shim

来建立心智模型非常直观。

3. containerd、shim、runc 的关系

假设现在已经存在容器 A,需要执行一次 exec

可以抽象成:

containerd
:“我要操作容器 A”
        ↓
定位容器 A 对应的 shim
        ↓
shim 接收这次操作
        ↓
启动一个新的 runc 进程
        ↓
runc exec ...
        ↓
libcontainer
        ↓
Linux Kernel
        ↓
动作完成
        ↓
runc 退出

如果以后又执行另外一个动作:

kill / delete / exec ...

则可以再次临时启动 runc。

所以真正应该形成的生命周期模型是:

containerd
→ 长期运行

shim
→ 容器 / task 运行期间长期存在

runc
→ 只有真正执行某个底层动作时才短暂存在

容器进程
→ 业务真正运行的进程

因此可以把职责压缩成:

containerd
= 总管理者

shim
= 具体容器运行期间的长期代理

runc
= 临时动作执行器

libcontainer
= Linux 容器底层能力封装库

Linux Kernel
= 真正提供 Namespace / Cgroup / Mount 等能力

六、Docker 版本与历史旁支:几个容易混淆的点

这一部分不属于 Docker 核心运行原理,但学习时经常会遇到,因此简单建立认知即可。

1. Docker 本身主要使用 Go 实现

一个容易记混的地方是:

Docker 并不是 Google 后来用 Go 重写出来的。

Docker 自身从早期就是 Go 技术栈中的重要项目。

Google 当时同样有自己的容器技术积累,也曾开源:

lmctfy
Let Me Contain That For You

它可以理解成 Google 自己的一套 Linux 容器管理方案。

但是它并不是“Google 用 Go 重写的 Docker”,其核心实现主要是 C++。

这里真正应该建立的是:

LXC
Docker / libcontainer
Google lmctfy

虽然实现方案不同,但最终仍然都需要建立在:

Linux Kernel
↓
namespace / cgroup / mount ...

这些能力之上。

也就是说:

大家不是各自发明了一套新的“容器内核”,而是在用户态用不同方式组织 Linux 已经提供的容器能力。

2. Docker 曾经存在 CE / EE 的版本划分

Docker 后续逐渐商业化以后,历史上曾经使用:

Docker CE
Community Edition
社区版

Docker EE
Enterprise Edition
企业版

来区分社区方向和企业商业方向。

对于当前学习 Docker 原理来说,没有必要过度纠结商业版本历史。

真正需要关心的是:

我们学习的核心仍然是 Docker Engine、容器运行时、镜像、网络、存储等技术本身。


七、Docker 整体为什么采用 Client / Server 架构

在学习 Redis、MySQL 时,我们已经非常熟悉 Client / Server 模型。

例如 Redis:

redis-cli
   ↓
redis-server

MySQL:

mysql client
   ↓
mysqld

客户端负责:

接收用户输入
构造请求
发送请求
接收响应

而真正的数据处理、资源管理都发生在服务端。

Docker 也是同样的思想。

可以先抽象成:

Docker CLI
    ↓
dockerd

其中:

Docker CLI
= 客户端

dockerd
= Docker 服务端守护进程

这里需要建立一个非常关键的认知:

Docker CLI 只是控制入口,真正创建容器、管理镜像、配置网络、调用 containerd / runc 的能力都位于 Docker Host,也就是服务端一侧。

这一点和 MySQL、Redis 完全一致。

例如 MySQL 客户端发送:

SELECT * FROM user;

真正查询数据的是:

mysqld

因为:

数据在服务端
+
真正处理 SQL 的能力也在服务端

Docker 同理。

客户端发送:

“我要启动一个容器”

真正的:

namespace 创建
cgroup 配置
rootfs 挂载
容器进程启动

全部发生在 Docker Host 上。


八、Docker CLI 到 dockerd:请求到底怎么发送?

1. Docker 并不是直接把 docker run nginx 原字符串发送过去

假设用户在 Shell 中输入:

docker run nginx

从用户视角来看这是一条字符串命令。

但是程序真正执行时,一般会先由 Shell 完成命令行解析,然后 Docker CLI 获得对应的参数,例如概念上:

argv[0] = docker
argv[1] = run
argv[2] = nginx

Docker CLI 再根据这些参数理解用户真正想做的事情。

所以这里不是:

"docker run nginx"
        ↓
原样编码
        ↓
服务端还原出 "docker run nginx"

而是:

Docker CLI 解析命令参数
        ↓
理解业务语义
        ↓
按照 Docker Engine API 构造 HTTP 请求

这里需要额外注意一点:Docker CLI 本身并不是一个长期运行、持续等待用户输入的客户端进程。它和我们之前接触到的 LXC 命令行工具比较类似,通常是“执行一次命令,就启动一次进程”。

例如当我们在 Shell 中执行:

docker run nginx

此时 Shell 会启动一个 docker CLI 进程,并将 runnginx 等参数传递给它。Docker CLI 再解析这些参数,理解当前需要执行的 Docker 操作,并按照 Docker Engine API 的规范构造相应的 HTTP 请求,通过 Unix Domain Socket 或 TCP 发送给后台长期运行的 dockerd。当本次请求处理完成、结果输出之后,这个 Docker CLI 进程也就随之退出。

整个过程可以简单理解为:

用户输入 docker run nginx
        ↓
Shell 启动 Docker CLI 进程
        ↓
Docker CLI 解析本次命令参数
        ↓
构造并发送 HTTP 请求
        ↓
dockerd 执行对应操作
        ↓
Docker CLI 获取结果并输出
        ↓
Docker CLI 进程退出

下一次再执行:

docker ps

则会重新启动一个新的 Docker CLI 进程,完成这一次请求后再次退出。因此,Docker 架构中真正长期运行的是服务端的 dockerdcontainerd 等后台进程,而 Docker CLI 只是一次性完成某次控制请求的命令行客户端

这一点和 redis-cli 的交互模式有所区别。redis-cli 可以进入一个持续运行的交互环境,不断接收 SETGET 等命令;而 Docker CLI 常见的使用方式则更接近:

一次 docker 命令
→ 一个 Docker CLI 进程
→ 一次或一组 API 请求
→ 执行完成
→ 进程退出

因此,后面分析 Docker 整体架构时,可以把 Docker CLI 看成一个短生命周期的控制端工具,而把真正长期存在、负责容器管理与运行的主体放在 Docker 服务端这一侧。

2. 和 Redis RESP 的区别

此前学习 Redis 时,我们知道客户端和服务端之间使用 RESP。

例如:

SET name wang

redis-cli 会将:

SET
name
wang

这些命令参数按照 RESP 格式组织,然后发送给 redis-server。

redis-server 解析 RESP 后,得到 Redis 命令和参数,再执行对应操作。

Docker 的思想类似,但它没有为 CLI 和 dockerd 再设计一套类似 RESP 的命令协议,而是直接大量利用 HTTP。

因此可以对比:

Redis:

用户命令
↓
redis-cli 解析
↓
RESP 序列化
↓
TCP
↓
redis-server 解析 RESP
↓
执行命令

Docker:

用户命令
↓
Docker CLI 解析
↓
按照 Docker Engine API 构造 HTTP 请求
↓
HTTP 序列化
↓
Unix Domain Socket / TCP
↓
dockerd 解析 HTTP
↓
执行对应业务逻辑

3. Docker Engine API 到底是什么

这里最容易误解成:

先构造一种 Docker 自己的 API 报文
↓
再转换成 HTTP 报文

实际上没有必要这样理解。

Docker Engine API 更像一套:

接口规范。

它规定:

哪个操作应该使用什么 HTTP Method
访问哪个 URL
参数放在哪里
Body 使用什么格式
服务端应该返回什么结果

例如概念上:

POST /containers/create

表示创建容器。

Body 中可能携带:

{
  "Image": "nginx"
}

因此最直接的模型就是:

Docker CLI 解析参数
        ↓
按照 Docker Engine API 的规则
直接构造 HTTP 请求
        ↓
序列化为 HTTP 字节流
        ↓
发送

这里没有必要额外插入一层“Docker 私有报文格式”。

4. REST API 又是什么

REST API 不是一种新的传输协议。

可以先简单理解成:

利用 HTTP 已经提供的 Method、URL、Header、Body 等机制来设计程序接口的一种风格。

例如:

GET /users/100
→ 获取用户

POST /users
→ 创建用户

DELETE /users/100
→ 删除用户

REST 并不是完全忽略 HTTP 原本的语义,只借一个 HTTP 外壳。

相反,它通常会尽量利用 HTTP 已经存在的语义:

GET
→ 获取

DELETE
→ 删除

POST
→ 提交 / 创建 / 触发操作

然后再由具体业务定义:

/users
/containers
/images

这些资源到底代表什么。

Docker Engine API 可以简单理解成:

一组基于 HTTP 暴露的 Docker 控制接口,其中很多设计带有 REST 风格。

没有必要在入门阶段死抠它是否完全满足“纯 REST”。


九、Unix Domain Socket 与 TCP:HTTP 请求最终从哪里发送?

HTTP 只是应用层请求格式。

真正把请求字节从 Docker CLI 送到 dockerd,还需要底层通信通道。

1. 本机通信:Unix Domain Socket

如果 Docker CLI 和 dockerd 位于同一台 Linux 主机,默认常见方式是:

Docker CLI
    ↓
/var/run/docker.sock
    ↓
dockerd

这里使用的是:

Unix Domain Socket

本质上属于本机进程间通信,不需要经过完整的 IP + TCP 网络路径。

2. 远程通信:TCP

如果:

Docker CLI 在机器 A

dockerd 在机器 B

那么就可以通过网络使用 TCP:

机器 A
Docker CLI
    ↓
HTTP
    ↓
TCP
    ↓
机器 B
 dockerd

所以这里可以总结为:

Docker Engine API / HTTP
= 请求表达方式

Unix Domain Socket / TCP
= 请求字节的传输通道

本机常用 Unix Domain Socket,跨主机则需要 TCP 等网络通信方式。


十、最关键的 C/S 认知:容器一定运行在 Docker Host 上

这一点非常重要,因为后面讨论 Docker 部署时,很容易不自觉地把客户端和服务端混在一起。

假设:

机器 A
→ Docker CLI

机器 B
→ dockerd

此时 A 发送:

启动容器

真正执行:

containerd
shim
runc
libcontainer
namespace
cgroup

的是机器 B。

因为:

runc 在服务端
libcontainer 在服务端
Linux Kernel 也在服务端

所以真正的容器进程自然也运行在机器 B。

完整链条是:

机器 A:Docker CLI
        ↓
     HTTP / TCP
        ↓
机器 B:dockerd
        ↓
     containerd
        ↓
       shim
        ↓
       runc
        ↓
   libcontainer
        ↓
机器 B 的 Linux Kernel
        ↓
机器 B 上的容器进程

因此后续学习 Docker 原理时,客户端这一侧其实可以逐渐折叠掉。

真正需要重点关心的是:

Docker Host
│
├── dockerd
├── containerd
├── shim
├── runc
├── image
└── Linux Kernel

也就是说:

客户端只是控制端;容器真正的运行、资源隔离、镜像使用以及生命周期管理,全都发生在 Docker 服务端所在的节点。


十一、在理解 Registry 之前,先弄清楚 Image 到底是什么

Docker 最开始想解决的问题之一,就是:

程序换一台机器以后还能不能直接运行?

一个 C++ 服务并不是只有一个可执行文件。

它可能还依赖:

server
├── libstdc++.so
├── glibc
├── libssl.so
├── protobuf
├── libmysqlclient.so
├── 配置文件
└── 其他用户态工具与文件

如果每部署到一台新机器都重新:

安装依赖
检查版本
配置环境
准备目录
准备配置文件

那么节点越多,部署成本越高,也越容易出现环境不一致。

因此 Docker 希望把:

可执行程序
+
依赖库
+
配置
+
工具
+
用户态文件系统环境

提前准备好。

这就引出了:

Docker Image

1. 镜像不是完整操作系统

这里需要非常注意:

镜像不会包含一套独立的 Linux Kernel。

但是它可以包含一套看起来很像某个 Linux 发行版的用户态文件系统,例如:

/
├── bin
├── lib
├── usr
├── etc
└── app

比如一个 Ubuntu 镜像中可能包含:

/bin/bash
/bin/ls
/lib/...
/usr/...
/etc/...

这些属于:

用户态 rootfs

但是不会包含一个独立内核。

真正运行时仍然是:

镜像提供:
程序 + 库 + rootfs
        ↓
容器进程
        ↓
宿主机 Linux Kernel

2. rootfs 和此前学习的 mount namespace 正好接起来

此前已经学习过:

容器进程需要看到一套独立的文件系统视图

那么这套文件从哪里来?

其中很重要的一部分就是镜像提供的 rootfs。

可以抽象成:

Docker Image
↓
提供用户态 rootfs
↓
准备为容器进程使用的根文件系统
↓
结合 Mount Namespace
↓
容器进程看到:
/
├── bin
├── lib
├── usr
├── etc
└── app

因此:

Image
→ 解决“容器里面有哪些文件”

Namespace
→ 解决“容器进程看到什么资源视图”

Cgroup
→ 解决“容器进程能够使用多少资源”

三者是不同层次的问题。

3. Image 与 Container 不是一个东西

镜像本身只是静态数据。

例如:

nginx image

放在磁盘中,它并不会自己执行。

只有 Docker 根据这个镜像创建真正的进程以后,才形成运行中的容器。

所以可以先类比:

Image
≈ 模板

Container
≈ 根据模板创建出来的运行实例

一个镜像完全可以创建多个容器:

             nginx image
                 │
        ┌────────┼────────┐
        ↓        ↓        ↓
   container1 container2 container3

这些容器:

进程不同
Namespace 不同
Cgroup 不同
网络环境不同
运行状态不同

但是都可以共享同一个镜像作为基础。

因此,当前阶段最重要的镜像认知可以压缩成一句:

Docker Image 是一份静态、只读的容器运行模板,包含应用程序、依赖库、配置以及用户态 rootfs;真正创建容器时,再结合宿主机 Linux Kernel 提供的 Namespace、Cgroup 等能力启动业务进程。


十二、Registry:镜像已经有了,下一步就是“镜像放在哪里”

理解镜像以后,Registry 就非常自然了。

假设开发机 A 已经制作出了:

myapp:v1

如果需要将它部署到:

Node-B
Node-C
Node-D

那么这些节点都需要先获得同一份镜像。

当然可以手动复制,但是随着节点数量增加,这种方式很快就会变得不可维护。

因此需要一个专门负责:

存储镜像
+
分发镜像

的远程服务。

这就是:

Registry

可以先类比:

GitHub
→ 存储和分发代码仓库

百度网盘
→ 存储和分发普通文件

Docker Registry
→ 存储和分发 Docker 镜像

1. push 与 pull

Registry 当前阶段最核心的两个动作就是:

push
→ 将镜像上传到 Registry

pull
→ 从 Registry 获取镜像

于是部署流程变成:

开发 / 构建节点
      ↓
制作 Image
      ↓
    push
      ↓
  Registry
      ↓
    pull
      ↓
目标 Docker Host
      ↓
本地获得 Image
      ↓
创建 Container

所以:

Registry 不负责运行容器。它只是镜像的存储和分发中心。

真正的容器仍然由目标 Docker Host 自己创建。

Registry 不参与:

namespace
cgroup
shim
runc

这些运行时动作。

2. 不要死记成“客户端上传、服务端下载”

学习初期可以看到典型场景:

开发机
→ push
Registry
→ pull
生产服务器

但是从本质上说,只要拥有权限,任何 Docker Host 都可以:

push
或者
pull

所以更准确的关系是:

Docker Host ↔ Registry

而不是 Registry 天然绑定“客户端”或者“服务端”角色。

当前阶段认识到这里就足够了,后面再继续深入:

Repository
Tag
Layer
Manifest

这些镜像体系中的概念。


十三、把目前所有组件放回一张 Docker 整体架构图

经过前面的铺垫,现在再看 Docker 架构就不会只看到一堆名词。

在这里插入图片描述

说明: 上图用于表达整体心智模型,重点是“控制链路、运行时链路和镜像分发链路”的职责划分。实际实现中,Docker CLI 的 push / pull 仍然首先是对 Docker Engine 发起控制请求,真正和 Registry 进行镜像数据交互的是 Docker Engine 一侧;因此图中的 Registry 箭头可以理解为“镜像从构建侧进入 Registry,再由目标 Docker Host 获取”的简化数据流,而不是强调精确的进程调用路径。

整张图可以拆成三条链路。

1. 控制链路

用户
 ↓
Shell
 ↓
Docker CLI
 ↓
Docker Engine API
 ↓
HTTP
 ↓
Unix Domain Socket / TCP
 ↓
dockerd

这一条链路解决:

用户如何告诉 Docker 服务端“我要做什么”。

2. 容器运行时链路

dockerd
   ↓
containerd
   ↓
shim
   ↓
runc
   ↓
libcontainer
   ↓
Linux Kernel
   ↓
Container Process

这一条链路解决:

服务端真正怎样把“创建 / 启动 / exec / stop”等操作落实到 Linux Kernel。

3. 镜像分发链路

Docker Host / 构建节点
        ↓ push
      Registry
        ↓ pull
目标 Docker Host
        ↓
      Image
        ↓
    Container

这一条链路解决:

程序及其运行环境怎样在不同节点之间标准化地分发。

因此整个 Docker 架构可以进一步压缩成四块:

Docker CLI
= 控制客户端

dockerd + containerd + shim + runc
= 服务端容器管理与运行时体系

Linux Kernel
= 真正提供容器隔离和资源控制能力

Registry
= 镜像的存储与分发中心

十四、从 Docker 核心能力继续过渡到 Docker 生态

理解前面的架构以后,再看所谓“Docker 生态”就不会觉得它是一些完全无关的名词堆积。

Docker 最开始解决的一个核心问题就是:

程序部署到不同机器
        ↓
环境可能不同
        ↓
依赖库可能缺失
        ↓
版本可能不兼容
        ↓
需要人工配置

Docker 将:

程序
+
依赖
+
用户态 rootfs

统一做成 Image 后,部署流程可以逐渐变成:

Node-A → pull image → run container
Node-B → pull image → run container
Node-C → pull image → run container

节点越多,这种标准化交付的价值越明显。

1. 为什么 Docker 特别适合多节点、多服务场景

假设系统只有一台机器、一个服务,手动安装环境可能暂时还能接受。

但是如果变成:

100 台机器
×
每台多个服务

那么传统方式可能变成:

Node-1:安装依赖、配环境、部署服务
Node-2:安装依赖、配环境、部署服务
Node-3:安装依赖、配环境、部署服务
...

很容易出现:

Node-A:运行正常
Node-B:缺库
Node-C:库版本不对
Node-D:配置文件不同

而 Docker 可以将部署单位统一成 Image:

同一个 Image
     ↓
Node-A
Node-B
Node-C
Node-D

所以多节点、多服务、频繁部署的场景会天然放大 Docker 的价值。

需要注意的是:

Docker 并不是只有多节点场景才有价值。

即使单机环境,它也能够解决:

服务之间依赖冲突
开发 / 测试 / 生产环境一致性
快速创建和销毁环境
环境复现

只是当系统规模扩大以后,这些优势会更加明显。


十五、Docker 为什么会和云计算、大数据、微服务联系起来

1. 云计算:底层本来就是大规模资源池

像大型云平台,底层通常由大量物理主机和虚拟化资源组成。

可以先建立一个简化模型:

大量 Physical Host
        ↓
组成资源池
        ↓
上面运行 VM / Container / Service

一台物理主机上可能进一步运行:

Physical Host
├── VM-1
│   ├── Web Service
│   └── Redis
│
├── VM-2
│   └── MySQL
│
└── Containers
    ├── Service-A
    ├── Service-B
    └── Service-C

这里需要注意,“主机”“节点”“服务器”几个词在不同语境中容易混用。

更准确地说:

一个物理或虚拟节点上,可以部署和运行多个服务实例,而服务实例又可能直接运行在宿主机、虚拟机或者容器内部。

当节点规模非常大时,如果每个服务都需要人工配置环境,维护成本会迅速上升。

Docker 正好可以将:

应用 + 用户态运行环境

标准化成 Image,再统一分发到不同节点。

所以 Docker 和云计算天然具有很强的结合关系。

2. 大数据:经常天然就是多组件、多节点部署

大数据系统中经常会出现:

多个计算节点
多个存储节点
多个消息组件
多个协调组件

例如一些分布式计算、存储、消息系统,往往并不是只部署一个进程就结束,而是需要在多个节点上维护一组服务。

节点数量增加后,同样会遇到:

环境一致性
依赖版本
组件部署
快速扩容

这些问题。

Docker 并不是用来“处理大数据”的计算框架。

更准确的关系是:

大数据系统本身具有多节点、多组件、部署复杂的特点,而 Docker 可以帮助它们标准化部署环境。

3. 微服务:服务数量增加以后,容器化价值更明显

微服务会将原来单体应用中的模块拆分为:

LoginService
MessageService
FriendService
AdminService
...

每一个服务都需要:

独立部署
独立启动
独立升级
独立扩容

服务数量一多,如果每个服务都依赖宿主机手动配置,就会非常麻烦。

因此微服务和 Docker 同样天然契合。

可以先形成:

微服务
→ 服务实例数量增加
→ 部署频率增加
→ 环境管理复杂度增加
→ Docker 将每个服务标准化成 Image
→ 容器化部署

十六、为什么后面又会自然出现 Kubernetes

当 Docker 解决了:

一个应用怎样标准化打包
一个容器怎样在单台主机上运行

以后,规模继续扩大就会出现另一个问题。

假设现在有:

100 台服务器
10000 个容器

这时即使每一个容器都已经能够通过 Docker 很方便地启动,也不可能继续靠人工决定:

这个容器部署在哪台机器?
哪个节点还有资源?
容器挂了以后谁重启?
压力变大以后应该增加多少实例?
压力降低以后应该减少多少实例?
服务之间怎样发现?

于是进一步引出了:

容器编排

其中最典型的就是:

Kubernetes

所以可以进一步建立这条生态链:

应用
 ↓
Docker Image
 ↓
Container
 ↓
大量 Container
 ↓
容器编排
 ↓
Kubernetes
 ↓
大量服务器节点
 ↓
云平台 / 大规模集群

因此所谓 Docker 生态,并不是说“大数据、云计算、微服务本身就是 Docker”。

更准确地说:

这些场景普遍具有多节点、多服务、部署频繁、环境复杂等特点,而 Docker 恰好解决了应用环境标准化和容器化运行问题,因此 Docker 在这些场景中被大量采用,并进一步形成了云原生、容器编排等周边生态。


十七、最终建立完整心智模型

学习到这里,可以把本轮所有知识重新压缩成一条逻辑链。

1. 容器实现层

Linux Kernel
↓
Namespace:隔离资源视图
Cgroup:限制资源使用
Mount / rootfs:构造文件系统环境
↓
LXC / libcontainer
把底层原子能力组织成容器语义
↓
runc
一次性执行真正的容器动作
↓
shim
长期承接具体容器 / task
↓
containerd
长期管理容器生命周期
↓
dockerd
Docker 服务端总入口

2. Docker Client / Server 层

用户
↓
Docker CLI
↓
按照 Docker Engine API 构造 HTTP 请求
↓
Unix Domain Socket / TCP
↓
dockerd
↓
真正执行 Docker 业务逻辑

这里最关键的认知是:

客户端只负责控制,真正的容器一定运行在 Docker Host。

3. 镜像与容器层

可执行程序
+
依赖库
+
配置
+
用户态 rootfs
↓
Docker Image
↓
结合 Linux Kernel 的 Namespace / Cgroup 等能力
↓
Container

因此:

Image
= 静态运行模板

Container
= 真正运行起来的实例

4. Registry 层

Image
↓ push
Registry
↓ pull
目标 Docker Host
↓
Container

Registry 的核心职责就是:

镜像存储
+
镜像分发

5. Docker 生态层

节点越来越多
+
服务越来越多
+
部署越来越频繁
↓
环境一致性与交付问题越来越突出
↓
Docker 的标准化镜像 + 容器化部署价值被放大
↓
微服务 / 云计算 / 大数据等大量采用
↓
容器规模继续扩大
↓
Kubernetes / 云原生生态

总结

本轮学习的重点并不是记住 Docker 有多少组件,而是将它们按照“为什么出现”的逻辑连接起来。

最底层,Docker 并没有创造容器。真正提供资源隔离和限制能力的是 Linux Kernel:

Namespace
+
Cgroup
+
Mount
+
其他 Linux 能力

LXC 首先将这些底层能力组合成更高级的容器语义。Docker 早期可以依赖 LXC,但是随着自身发展,为了获得更强的底层控制能力,逐渐使用自己的 libcontainer。

随后 libcontainer 又进一步被组织到 runc 中,使 runc 成为真正执行容器底层动作的低级运行时。

但是 runc 是一次性的动作执行器,执行结束以后就会退出,因此又需要 containerd 这样的长期管理进程,以及 shim 这样的长期运行时代理来承接具体容器的生命周期。

在更上层,Docker 又采用 Client / Server 架构:

Docker CLI
↓
Docker Engine API + HTTP
↓
Unix Domain Socket / TCP
↓
dockerd

客户端只是控制端,真正的容器执行能力始终位于 Docker Host。

与此同时,Docker 还需要解决“程序及其运行环境如何交付”的问题,于是出现 Image:

程序
+
依赖
+
配置
+
用户态 rootfs
↓
Image

镜像本身并不是正在运行的容器,它只是一个静态运行模板。真正创建容器时,再利用宿主机 Linux Kernel 的 namespace、cgroup 等能力启动进程。

当镜像需要在不同节点之间传播时,又自然引出了 Registry:

构建节点
↓ push
Registry
↓ pull
部署节点

最终,当系统从单节点逐渐扩大到多节点、多服务、大规模集群时,Docker 对环境一致性、镜像分发和标准化部署的价值会越来越明显,也就自然进入了微服务、云计算、大数据以及后续 Kubernetes / 云原生生态。

因此,如果只用一句话总结这一整轮知识,可以是:

Docker 的核心不是“发明了容器”,而是以 Linux Kernel 的容器能力为基础,通过运行时体系完成容器生命周期管理,再通过 Image + Registry 完成应用运行环境的标准化封装与分发,最终让应用能够更稳定、更一致地部署到不同节点。

在这里插入图片描述

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2301_80260194/article/details/165220164

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--