Docker 核心原理详解:镜像、容器、Namespace、Cgroups 与 UnionFS

在当前的后端开发与部署工作中,容器化技术已经成为标准配置。绝大多数现代软件项目在交付时,不再提供单纯的源代码或编译后的可执行文件,而是提供容器镜像。

这篇文章将介绍 Docker 的核心工作原理、底层的技术实现,以及它与传统虚拟机在架构、资源调度和应用场景上的各项差异。同时,也会对容器的数据持久化、网络模型以及跨平台内核限制等相关问题进行说明。

软件环境部署的问题背景

在说明 Docker 的原理之前,需要明确它解决的实际业务问题。在传统的软件开发流程中,开发人员在本地计算机上编写代码并测试通过,然后将其部署到测试服务器或生产服务器。在这个过程中,经常会出现程序在本地运行正常,但在服务器上运行报错的情况。

导致这种问题的原因是软件运行环境的不一致 。一个软件的运行不仅仅依赖于代码本身,还依赖于操作系统的发行版本、系统基础库的版本、环境变量的配置、以及外部依赖软件(如特定版本的数据库或缓存服务)。在传统的部署模式下,运维人员需要手动或通过脚本在服务器上逐一安装这些依赖。如果生产环境的基础库版本与开发环境不一致,就会导致程序运行失败

Docker 的出现就是为了解决环境一致性问题。它采用的方案是将应用程序的代码,连同其运行所需的全部依赖环境(包括操作系统的文件系统、系统库、配置文件等),统一打包成一个独立的文件。这个文件在任何安装了 Docker 引擎的服务器上解包运行,其内部的运行环境都是完全相同的。

Docker 的核心组件

理解 Docker 的运行机制,需要先清楚其三个最核心的基本概念:镜像、容器和仓库。

镜像

镜像是 Docker 中的静态模板。可以将其理解为一个只读 的文件系统包。镜像中包含了应用程序的代码、运行时的支持程序、所需的系统库文件以及配置参数。镜像在构建完成后是不可更改的 。如果需要更新程序,必须重新构建一个包含新代码的新镜像。这种不可变性保证了环境的绝对一致

容器

容器是镜像的运行实例。当 Docker 引擎通过一个镜像启动服务时,就是在运行一个容器。镜像本身是静态的文件,而容器是包含了实际执行进程、内存数据以及网络连接的动态实体。容器的本质是宿主机操作系统上的一个进程,但是这个进程被施加了各种限制和隔离措施

仓库

仓库是集中存储和分发镜像的场所。开发人员在本地构建好镜像后,需要将其上传到仓库。服务器在部署时,会从仓库中下载对应的镜像。官方提供的公共仓库是 Docker Hub,企业内部通常会搭建私有的镜像仓库用于存放业务代码镜像。

Docker 的底层运行原理

Docker 本身并不提供操作系统内核,它是一个运行在宿主机操作系统上的应用程序。Docker 容器能够实现相互隔离并独立运行,完全依赖于 Linux 内核提供的三种核心底层机制:Namespace、Cgroups 和 UnionFS。

进程与资源隔离:Namespace

在普通的 Linux 操作系统中,所有的进程共享同一个系统环境。一个进程可以看到系统里的其他进程,可以看到所有的网络接口,也可以读取系统目录下的文件。为了让容器内部的进程认为自己运行在一个独立的环境中,Linux 内核提供了 Namespace(命名空间)机制。

Namespace 的作用是限制进程的视图。当一个进程被放入特定的 Namespace 后,它只能看到该 Namespace 内的资源,无法看到宿主机或其他 Namespace 中的资源。Docker 主要使用了以下几种 Namespace:

PID Namespace

用于隔离进程号。在宿主机上,操作系统的初始化进程(如 systemd)拥有进程号 1。当 Docker 启动一个容器时,会为该容器创建一个新的 PID Namespace。容器内部运行的主进程在这个 Namespace 内部会被分配进程号 1。虽然在宿主机的全局视图中,这个进程只是一个普通的进程,拥有一个普通的进程号(例如 12450),但在容器内部,该进程认为自己是系统的核心启动进程。

Mount Namespace

用于隔离文件系统挂载点 。它使得容器内的进程只能看到自己专用的文件系统目录。当容器启动时,Docker 会将镜像中的文件系统挂载为容器的根目录(/)。容器内的进程对文件系统的读取和修改,都被限制在这个根目录之下,无法访问宿主机的真实文件系统目录

Network Namespace

用于隔离网络资源 。每个 Network Namespace 都有自己独立的网络接口、IP 地址、路由表和防火墙规则。Docker 为每个容器创建一个独立的网络命名空间,这就解释了为什么不同的容器可以绑定相同的端口号(例如多个容器都在内部监听 80 端口)而不发生冲突。Docker 默认会通过创建虚拟网卡(veth pair)并将其中一端接入宿主机的虚拟网桥(docker0),来实现容器与宿主机、容器与容器之间的网络通信。

UTS Namespace

用于隔离主机名和域名。这使得每个容器可以拥有独立的主机名。在容器内部执行 hostname 命令看到的结果,独立于宿主机的主机名。

IPC Namespace

用于隔离进程间通信。Linux 进程间可以通过共享内存、信号量等方式通信。IPC Namespace 确保了只有在同一个 Namespace 内的进程才能进行这些方式的通信,防止不同容器的进程互相干扰。

User Namespace

用于隔离用户和用户组。通过映射机制,容器内部的 root 用户可以映射为宿主机上的一个普通非特权用户。这样即使容器内的进程被攻破,攻击者在宿主机上也只拥有普通用户的权限,从而提高了系统的安全性。

资源限制:Cgroups

Namespace 解决了资源可见性和隔离的问题,但无法解决资源争抢的问题。如果容器 A 内部的程序出现死循环,耗尽了宿主机的全部 CPU 或内存,那么容器 B 以及宿主机上的其他服务都会受到影响。

为了防止容器消耗过多的系统资源,Docker 使用了 Linux 内核的 Cgroups(Control Groups,控制组)机制。Cgroups 的主要作用是限制、记录和隔离进程组所使用的物理资源

内存限制

Docker 可以通过 Cgroups 明确设置一个容器最多只能使用多少物理内存。如果容器内的进程申请的内存超过了设定阈值,Cgroups 就会触发 OOM(Out of Memory)机制,强制杀死容器内的进程,从而保护宿主机的内存不被耗尽。

CPU 限制

Cgroups 可以限制容器对 CPU 的使用。这种限制可以分为绝对限制和相对限制。绝对限制可以直接指定容器最多使用多少个 CPU 核心的处理能力。相对限制则是设置 CPU 权重(CPU Shares),当宿主机的 CPU 资源紧张时,系统会根据各个容器设定的权重比例来分配 CPU 时间片。

磁盘 I/O 限制

Cgroups 还可以限制进程对磁盘的读取和写入速度(Block I/O)。在多容器同驻的场景中,防止某个进行大量日志写入或文件下载的容器占用所有的磁盘 I/O 吞吐量,导致其他容器的数据读写被阻塞。

文件系统:UnionFS

镜像是如何构建和存储的,依赖于 UnionFS(联合文件系统)。联合文件系统允许将不同的物理目录挂载到同一个虚拟目录中

Docker 镜像并不是一个单一的大文件,而是由多个文件系统层(Layer)组成的。每次在 Dockerfile 中执行一条指令(例如执行 apt-get install 或者 COPY 代码),都会在现有的层之上创建一个新的只读层。

Docker 主要使用 overlay2 作为存储驱动,它是 UnionFS 类技术的现代实现。

镜像分层与共享

分层设计最大的优势在于资源复用。如果多个应用都基于同一个操作系统的基础镜像(例如基于 Ubuntu 20.04),那么在宿主机上,这个基础镜像的底层数据只会被下载和存储一次。所有基于该基础镜像运行的容器,都会共享这一份底层只读数据。这极大地节省了磁盘空间和网络下载的时间。

写时复制策略

当 Docker 启动一个容器时,它会在镜像所有的只读层之上,添加一个薄薄的可写层(Container Layer)。容器在运行期间产生的所有数据更改、日志文件和新生成的文件,都会被写入到这个可写层中。

Docker 在处理文件读写时,采用 **Copy-on-Write(写时复制)**策略。具体流程如下:

  1. 读取文件:容器需要读取某个文件时,会从最顶层的可写层开始往下寻找。如果在可写层找到,直接读取;如果没有,继续向下在只读层中寻找并读取。
  2. 修改文件:容器需要修改某个底层只读层的文件时,UnionFS 不会直接修改下层的数据(因为它们是只读的)。而是将该文件从只读层复制一份到最顶层的可写层,然后在可写层对这个副本进行修改。以后容器再读取该文件时,会直接读取到可写层中被修改过的版本,底层原来的文件被隐藏。
  3. 删除文件:容器需要删除底层文件时,并不是真的在底层物理删除它,而是在顶部的可写层创建一个名为 "whiteout" 的特殊文件,标记该文件已被删除。容器内部将无法再看到该文件。

当容器被销毁时,这个顶部的可写层也会随之被删除。这就是为什么在不挂载外部卷的情况下,容器内部的数据无法持久化的原因。

容器数据的持久化存储

正如前文提到的 UnionFS 原理,容器启动后产生的数据写入都发生在顶部的临时可写层。一旦容器被停止并删除,这层数据就会彻底丢失。这对于运行无状态的服务(如 Web 前端应用、普通的业务逻辑接口)是没有问题的,因为它们本身不需要在本地保存状态。

但对于数据库(如 MySQL、PostgreSQL)、消息队列或需要保存用户上传文件的服务,数据的丢失是绝对不允许的。为了解决这个问题,Docker 提供了几种数据持久化的机制,核心思想是将宿主机的某个实际物理目录,映射并挂载到容器的指定目录上。

  1. Bind Mounts(绑定挂载):直接指定宿主机上的一个绝对路径的目录,挂载到容器内部。这种方式对宿主机的依赖较高,因为宿主机上必须存在指定的路径。
  2. Volumes(数据卷) :这是 Docker 官方推荐的数据持久化方式。Volumes 由 Docker 引擎统一创建和管理,存放在宿主机的一个特定区域(通常在 Linux 下是 /var/lib/docker/volumes/)。开发人员无需关心宿主机的具体路径。数据卷可以独立于容器的生命周期存在,即使容器被删除,数据卷中的数据依然完好保留,并且可以随时挂载给新启动的容器继续使用。

开发环境常用 bind mount(方便实时修改代码);生产环境优先 named volumes(移植性更好、安全性更高、Docker 可初始化卷内容)

Docker 的底层机制理解后,我们下一篇文章会详细对比它与传统虚拟机的差异:
Docker 与虚拟机到底有什么本质区别?场景选择与最佳实践

相关推荐
赵庆明老师2 小时前
Linux Docker打包
linux·运维·docker
Eloudy2 小时前
docker pull ubuntu:22.04 失败的解决记录
运维·docker·容器
Rsun045512 小时前
ScheduledExecutorService类作用
java
小钊(求职中)2 小时前
算法知识、常用方法总结
java·算法·排序算法·力扣
萧逸才2 小时前
【learn-claude-code】S07TaskSystem - 任务系统:大目标拆成小任务,持久化到磁盘
java·人工智能·ai
Rsun045512 小时前
MessageUtils.message(“user.jcaptcha.expire“)
java
zaim12 小时前
计算机的错误计算(二百二十六)
java·python·c#·c·错数·mpmath
EFCY1MJ902 小时前
ASP.NET MVC 1.0 (五) ViewEngine 深入解析与应用实例
后端·asp.net·mvc
小江的记录本3 小时前
【RabbitMQ】RabbitMQ核心知识体系全解(5大核心模块:Exchange类型、消息确认机制、死信队列、延迟队列、镜像队列)
java·前端·分布式·后端·spring·rabbitmq·mvc