Docker 是一个开源的容器化平台,它允许开发者将应用程序及其所有依赖项打包到一个标准化的、可移植的、自包含的单元——容器 (Container) 中。与传统的虚拟机 (VM) 技术不同,Docker 容器不包含独立的操作系统内核,而是共享宿主机的 Linux 内核,这使得容器更加轻量、启动更快。Docker 的强大之处在于它巧妙地利用了 Linux 操作系统底层的一系列核心技术来实现这种轻量级隔离和资源管理。

核心思想:Docker 并非一个虚拟化产品,而是利用 Linux 内核提供的 Namespaces(命名空间)实现隔离,Cgroups(控制组)实现资源限制,以及 Union File Systems(联合文件系统)实现高效的镜像管理,最终通过容器运行时(如 containerd 和 runc)来编排和执行这些操作。


一、Linux 容器技术概述

Docker 的核心是 Linux 容器 (LXC) 技术,它并非一项单一技术,而是 Linux 内核中多项特性的结合。这些特性使得一个进程或一组进程可以在一个相对隔离的环境中运行,拥有自己独立的资源视图,并且其资源使用受到限制。

与传统虚拟机(通过 Hypervisor 模拟硬件,运行完整的 Guest OS)相比,容器的优势在于:

  • 轻量化:共享宿主机内核,无需独立的操作系统,体积更小。
  • 启动快:秒级甚至毫秒级启动,无需加载整个操作系统。
  • 资源效率高:由于共享内核,减少了资源开销。

Docker 作为容器化平台,在 LXC 的基础上增加了镜像管理、容器编排、网络管理等上层功能,极大地简化了容器的使用。

二、命名空间 (Namespaces):实现隔离

命名空间 (Namespaces) 是 Linux 内核提供的一项重要特性,它能够将系统资源进行抽象和隔离,使得不同进程组拥有各自独立的系统资源视图。每个容器都运行在自己独立的命名空间中,这使得容器内的进程感觉它们拥有独立的系统资源,但实际上是共享宿主机的内核。

2.1 关键概念

  • 隔离性:Namespaces 的核心功能是隔离,使得容器内的进程无法看到或影响宿主机或其他容器的资源。
  • 资源视图:通过 Namespaces,每个容器都有一个独立的、虚拟化的“视角”来看待各种系统资源。

2.2 主要类型

Linux 内核提供了多种命名空间,Docker 利用了其中的大部分来实现容器的隔离:

  1. PID Namespace (进程 ID 命名空间)

    • 为每个容器提供独立的进程 ID 视图。容器内的 PID 1 是容器内部的第一个进程,它看不到宿主机上其他进程的 PID,也看不到其他容器的进程。
    • Go 语言中,可以通过 syscall.CLONE_NEWPID 标志配合 clone 系统调用创建新的 PID 命名空间。
  2. NET Namespace (网络命名空间)

    • 为每个容器提供独立的网络接口、IP 地址、路由表、防火墙规则等。每个容器都有自己的虚拟网络栈。
    • Docker 通过虚拟以太网设备 (veth pair) 将容器的网络命名空间与宿主机的网络命名空间连接起来,从而实现容器与外部世界的通信。
    • Go 语言中,可以通过 syscall.CLONE_NEWNET 标志创建。
  3. MNT Namespace (挂载命名空间)

    • 为每个容器提供独立的挂载点视图。容器内的文件系统挂载操作不会影响宿主机或其他容器的挂载点。这是实现容器文件系统隔离的基础。
    • Go 语言中,可以通过 syscall.CLONE_NEWNS 标志创建。
  4. UTS Namespace (主机名命名空间)

    • 为每个容器提供独立的主机名 (hostname) 和域名 (NIS domain name) 视图。容器可以设置自己的主机名,而不会影响宿主机或其他容器。
    • Go 语言中,可以通过 syscall.CLONE_NEWUTS 标志创建。
  5. IPC Namespace (进程间通信命名空间)

    • 为每个容器提供独立的 System V IPC(消息队列、信号量、共享内存)和 POSIX 消息队列视图。隔离了容器间的进程间通信。
    • Go 语言中,可以通过 syscall.CLONE_NEWIPC 标志创建。
  6. USER Namespace (用户和组 ID 命名空间)

    • 允许容器内部的 root 用户映射到宿主机上的非特权用户,从而增强安全性,即使容器内的 root 用户被攻破,也难以在宿主机上获得 root 权限。
    • Go 语言中,可以通过 syscall.CLONE_NEWUSER 标志创建。

2.3 示例 (概念性 Go 语言代码)

虽然直接在 Go 中实现一个完整的容器复杂且涉及低层系统调用和权限,但我们可以通过 os/execunshare 命令来概念性地理解其作用。unshare 是一个 Linux 工具,用于在新的命名空间中运行程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package main

import (
"fmt"
"os"
"os/exec"
"syscall"
)

func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: go run main.go <command> [args...]")
return
}

command := os.Args[1]
args := os.Args[2:]

// 创建一个新的 UTS 和 PID 命名空间来运行命令
// 实际 Docker 会创建更多类型的命名空间
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[1:]...)...) // 运行自身作为子进程
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if os.Args[1] == "child" {
// 在子进程中执行实际命令
// 这里的 child 只是一个标记,告诉程序在新的命名空间中执行后续命令
// 实际应用中,这里会设置 hostname,chroot等
syscall.Sethostname([]byte("my-container-hostname"))
fmt.Printf("Inside container (new UTS/PID namespace). Hostname: %s, PID: %d\n", getHostname(), os.Getpid())
fmt.Println("Running command:", command, args)
innerCmd := exec.Command(command, args...)
innerCmd.Stdin = os.Stdin
innerCmd.Stdout = os.Stdout
innerCmd.Stderr = os.Stderr
if err := innerCmd.Run(); err != nil {
fmt.Println("Error running inner command:", err)
}
return
}

fmt.Println("Parent process starting child in new namespace...")
if err := cmd.Run(); err != nil {
fmt.Println("Error starting child process:", err)
}
fmt.Println("Parent process finished.")
}

func getHostname() string {
name, err := os.Hostname()
if err != nil {
return "unknown"
}
return name
}

说明
上述 Go 语言代码是一个高度概念化的示例,它尝试在新的 UTS (主机名) 和 PID (进程 ID) 命名空间中运行一个子进程。当执行 go run main.go bash 时,它会:

  1. 父进程启动一个子进程,并为其设置 CLONE_NEWUTSCLONE_NEWPID 标志。
  2. 子进程在新的命名空间中运行,并将其主机名设置为 “my-container-hostname”。
  3. 子进程内部再执行 bash 命令。
    你会发现,bash 内部的主机名和 ps 命令看到的 PID 列表将与宿主机不同,体现了命名空间的隔离效果。

三、控制组 (Cgroups):实现资源限制

控制组 (Cgroups) 是 Linux 内核的另一个强大特性,它允许你将进程组织成具有层次结构的组,并对这些组的资源使用进行限制、审计和优先级管理。Cgroups 确保了容器不会耗尽宿主机的所有资源,从而保障了宿主机的稳定性和其他容器的正常运行。

3.1 关键概念

  • 资源限制:限制一个或一组进程可以使用的 CPU、内存、I/O 等资源。
  • 资源计量:跟踪一个或一组进程对各种资源的实际使用情况。
  • 优先级管理:可以为不同的进程组设置资源的优先级。

3.2 主要子系统 (Subsystems)

Cgroups 包含多个子系统,每个子系统负责一种类型的资源管理:

  1. cpu:用于限制 CPU 使用率,如分配 CPU 时间份额 (CPU Share) 或限制 CPU 核数。
  2. memory:用于限制内存使用量,包括物理内存和交换空间。
  3. blkio:用于限制块设备(如硬盘)的 I/O 带宽和操作速率。
  4. pids:用于限制一个 Cgroup 中可以运行的最大进程/线程数量。
  5. net_cls:用于对网络流量进行分类和标记,通常结合 tc 命令进行流量整形和管理。
  6. devices:用于控制 Cgroup 中进程对特定设备的访问权限。

3.3 Docker 如何使用 Cgroups

Docker 在创建和启动容器时,会根据用户定义的参数(如 --cpu-shares, --memory, --cpus 等)在 /sys/fs/cgroup 路径下为每个容器创建相应的 Cgroup 目录和配置文件,并将容器内的进程添加到这些 Cgroup 中。

例如,当你运行 docker run --memory="512m" myimage 时:

  1. Docker 会在 memory Cgroup 子系统中为该容器创建一个新的 Cgroup。
  2. 在这个 Cgroup 目录下的 memory.limit_in_bytes 文件中写入 536870912 (512MB 的字节数)。
  3. 将容器的进程 ID 写入该 Cgroup 目录下的 taskscgroup.procs 文件中。

Cgroup 文件系统层次结构 (概念图)

3.4 示例 (概念性 Go 语言代码)

直接通过 Go 操作 Cgroup 文件系统是可行的,但为了简洁和说明原理,我们以命令行方式理解,Docker 底层也是在做类似的文件操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 概念性的 Cgroup 操作在 Go 中
// 实际的 Go 库如 "github.com/containerd/cgroups" 提供了更高级的抽象
// 这里仅展示其底层原理是文件操作

package main

import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strconv"
)

func main() {
containerID := "my_test_container"
memoryLimitMB := 128
pidsLimit := 20

// 1. 创建 Cgroup 目录
baseCgroupPath := "/sys/fs/cgroup"
memoryCgroupPath := filepath.Join(baseCgroupPath, "memory", "docker", containerID)
pidsCgroupPath := filepath.Join(baseCgroupPath, "pids", "docker", containerID)

fmt.Printf("Creating memory cgroup at %s\n", memoryCgroupPath)
os.MkdirAll(memoryCgroupPath, 0755)
fmt.Printf("Creating pids cgroup at %s\n", pidsCgroupPath)
os.MkdirAll(pidsCgroupPath, 0755)

// 2. 写入内存限制
memoryLimitBytes := int64(memoryLimitMB) * 1024 * 1024
memLimitFile := filepath.Join(memoryCgroupPath, "memory.limit_in_bytes")
fmt.Printf("Setting memory limit to %d bytes in %s\n", memoryLimitBytes, memLimitFile)
ioutil.WriteFile(memLimitFile, []byte(strconv.FormatInt(memoryLimitBytes, 10)), 0644)

// 3. 写入进程数限制
pidsLimitFile := filepath.Join(pidsCgroupPath, "pids.max")
fmt.Printf("Setting pids limit to %d in %s\n", pidsLimit, pidsLimitFile)
ioutil.WriteFile(pidsLimitFile, []byte(strconv.Itoa(pidsLimit)), 0644)

// 4. 将当前进程(模拟容器的第一个进程)加入到这些 Cgroup
// 实际中,Docker 会将容器的第一个进程(PID)写入 tasks 文件
pid := os.Getpid()
memoryTasksFile := filepath.Join(memoryCgroupPath, "cgroup.procs") // 或 tasks
pidsTasksFile := filepath.Join(pidsCgroupPath, "cgroup.procs") // 或 tasks

fmt.Printf("Adding PID %d to memory cgroup %s\n", pid, memoryTasksFile)
ioutil.WriteFile(memoryTasksFile, []byte(strconv.Itoa(pid)), 0644)
fmt.Printf("Adding PID %d to pids cgroup %s\n", pid, pidsTasksFile)
ioutil.WriteFile(pidsTasksFile, []byte(strconv.Itoa(pid)), 0644)

fmt.Printf("\nCgroups for container '%s' configured. Check:\n", containerID)
fmt.Printf(" cat %s\n", memLimitFile)
fmt.Printf(" cat %s\n", pidsLimitFile)
fmt.Printf(" cat %s\n", memoryTasksFile)
fmt.Printf(" cat %s\n", pidsTasksFile)

// 生产环境中,容器退出时,这些 Cgroup 目录通常会被清理
// 简单演示,这里不自动清理。
}

说明
上述 Go 代码演示了如何手动/sys/fs/cgroup 文件系统下创建 Cgroup 目录并设置资源限制。它模拟了 Docker 在底层做的事情:为每个容器创建特定的 Cgroup 路径,然后向这些路径下的控制文件写入限制参数,并将容器的进程 ID 写入 cgroup.procs 文件以将其纳入管理。

四、联合文件系统 (Union File Systems):实现高效镜像管理

联合文件系统 (Union File Systems) 是一种特殊的文件系统,它允许将多个目录(层)以堆栈的形式组合在一起,形成一个统一的视图。Docker 利用这项技术实现了其核心的镜像 (Image) 和容器 (Container) 机制,即分层存储。

4.1 关键概念

  • 分层存储:Docker 镜像由一系列只读层组成,这些层叠加在一起构成完整的文件系统。
  • 写时复制 (Copy-on-Write, CoW):当容器尝试修改一个文件时,该文件不会直接在只读层上修改,而是被复制到容器的读写层进行修改。这保证了底层镜像的完整性,并高效地利用了存储空间。

4.2 工作原理

当一个 Docker 镜像被创建时,它被构建成一系列只读的层。当基于这个镜像启动一个容器时:

  1. Docker 会在这些只读镜像层之上再添加一个可写层 (Writable Layer),这就是容器层。
  2. 容器对文件系统的所有修改(新建、修改、删除文件)都只发生在最上层的可写层。
  3. 读操作:如果文件在可写层不存在,则从下层只读镜像层读取。
  4. 写操作 (CoW):如果文件在可写层不存在,但容器对其进行了修改,那么该文件会首先从只读层复制到可写层,然后再进行修改。
  5. 删除操作:如果删除一个文件,实际上是创建一个“白障文件” (whiteout file) 在可写层,覆盖掉下层同名文件,使得该文件在容器视图中不可见。

4.3 主要实现

Linux 内核支持多种联合文件系统,Docker 会根据宿主机内核版本和配置选择合适的驱动:

  • OverlayFS:目前 Docker 推荐和默认使用的存储驱动,因其高效和稳定性。
  • AUFS (Another UnionFS):Docker 早期使用的驱动,功能强大但未被合并到主线 Linux 内核。
  • Device Mapper / Btrfs / ZFS:基于块设备或更高级文件系统的存储驱动,也曾被使用。

4.4 优势

  • 存储效率:多个容器可以共享相同的只读镜像层,节省磁盘空间。
  • 启动速度:容器启动时无需复制整个文件系统,只需挂载层即可。
  • 快速构建:Dockerfile 的每一步都创建一个新层,增量式构建和缓存使得镜像构建和分发非常高效。

UnionFS 结构示意图

五、容器运行时 (Container Runtimes):执行与管理容器

容器运行时 (Container Runtimes) 是真正负责创建、运行和管理容器生命周期的组件。它们位于 Docker 引擎或 Kubernetes 等容器编排平台之下,直接与 Linux 内核交互,利用 Namespaces 和 Cgroups 等技术来启动和隔离容器进程。

5.1 OCI (Open Container Initiative) 规范

为了标准化容器生态系统,Linux 基金会成立了 OCI,定义了两项核心规范:

  1. OCI Runtime Specification (运行时规范):定义了容器应该如何被创建和运行。所有兼容 OCI 的低层运行时都必须实现这个规范。
  2. OCI Image Specification (镜像规范):定义了容器镜像的格式和内容。

5.2 低层运行时:runc

  • 定义runc 是 OCI 运行时规范的参考实现。它是一个轻量级、命令行工具,负责根据 OCI Bundle (一个包含 config.json 文件和文件系统根目录的目录) 来创建并运行容器。
  • 核心功能
    • 解析 OCI Bundle。
    • 调用 Linux 内核的 Namespaces 和 Cgroups 来设置容器的隔离环境和资源限制。
    • 通过 pivot_rootchroot 设置容器的根文件系统。
    • 启动容器内的第一个进程。
  • 地位:它是所有现代容器技术栈(包括 Docker、containerd、CRI-O)的底层基石。

5.3 高层运行时:containerdCRI-O

低层运行时 runc 只能管理单个容器。为了实现更高级的功能,如镜像管理、容器生命周期管理、网络插件集成等,需要更高级别的运行时。

  1. containerd

    • 定义:一个 CNCF (Cloud Native Computing Foundation) 项目,由 Docker 公司捐赠。它是一个守护进程,提供了一套完整的容器运行时服务。
    • 核心功能
      • 镜像管理:拉取、推送、存储容器镜像。
      • 容器生命周期管理:创建、启动、停止、删除容器 (通过调用 runc)。
      • 存储管理:管理容器的文件系统层。
      • 网络管理:与 CNI (Container Network Interface) 插件集成,配置容器网络。
    • 地位:它是 Docker 引擎的默认核心运行时,也是 Kubernetes 兼容的 CRI (Container Runtime Interface) 运行时之一。
  2. CRI-O

    • 定义:另一个 CNCF 项目,专门为 Kubernetes 设计的 OCI 兼容容器运行时。它的目标是提供一个轻量级、专注于 CRI 规范的运行时。
    • 核心功能:与 containerd 类似,但更侧重于满足 Kubernetes 的 CRI 接口要求,不包含 Docker 引擎的其他高级功能。
    • 地位:在 Kubernetes 生态中,CRI-Okubelet 与容器运行时之间进行交互的一个流行选择。

5.4 容器技术栈关系

Docker 引擎下的技术栈

说明

  • Docker CLI:用户通过命令行与 Docker 交互。
  • Docker Daemon:Docker 引擎的核心服务,负责接收 CLI 命令,管理镜像、容器、网络和存储卷等。它不再直接运行容器,而是将容器运行的职责委托给 containerd
  • containerd:接收 Docker Daemon 的指令,管理镜像的拉取、存储以及容器的生命周期。
  • runccontainerd 会根据 OCI 规范生成 config.json 文件,并调用 runc 来真正地创建和启动容器进程,设置其 Namespaces 和 Cgroups。
  • Linux Kernel:最终执行 runc 的指令,利用内核特性创建隔离的容器环境。

六、总结

Docker 的成功并非创造了全新的虚拟化技术,而是巧妙地整合并封装了 Linux 内核已有的强大特性。命名空间 (Namespaces) 提供了进程、网络、文件系统等资源的隔离视图;控制组 (Cgroups) 限制了容器对 CPU、内存、I/O 等系统资源的滥用;联合文件系统 (Union File Systems) 实现了高效的镜像分层存储和写时复制机制。而 容器运行时 (如 containerd 和 runc) 则负责将这些底层内核特性组织起来,创建一个功能完备、标准化的容器运行环境。

通过对这些关键技术的深入理解,我们不仅能更好地使用 Docker,也能更清晰地认识到容器化技术在现代云原生架构中的核心作用和其背后的操作系统原理。