跳到主要内容

Docker 核心概念与架构

为什么需要 Docker​

在没有 Docker 之前,部署一个应用通常是这样的:在服务器上装依赖、改配置、起服务,换一台机器再重复一遍。环境稍有差异(操作系统版本、依赖库版本、环境变量),应用就可能跑不起来,也就是经典的「在我电脑上明明是好的」。

Docker 解决的核心问题是:把应用和它依赖的环境一起打包成一个镜像,在任何装了 Docker 的机器上都能以同样的方式运行。

实际带来的好处:

  • 环境一致:开发、测试、生产用同一个镜像,排除「环境差异」这一类问题。
  • 隔离性好:多个应用跑在同一台机器上互不干扰,各自的依赖、端口、文件系统互不可见。
  • 启动快:容器是进程级的,启动通常在一秒以内,不像虚拟机要启动整个操作系统。
  • 资源利用率高:一台机器可以跑几十个容器,共享宿主机内核,没有虚拟机的额外开销。
  • 交付简单:交付物从「一堆文档 + 安装包」变成「一个镜像 + 一条启动命令」。
一句话理解

Docker 就是一个「打包应用及其运行环境,并以隔离进程的方式运行它」的工具。镜像负责打包,容器负责运行。

Docker 与虚拟机​

容器和虚拟机都能提供隔离的运行环境,但实现方式完全不同:

  • 虚拟机:通过 Hypervisor(如 KVM、VMware)虚拟出一套完整硬件,上面跑一个完整的 Guest OS,应用运行在 Guest OS 里。
  • 容器:不虚拟硬件,直接共享宿主机内核,通过 Namespace 做视图隔离、Cgroups 做资源限制,容器本质上就是宿主机上的一个普通进程。
对比项虚拟机Docker 容器
隔离层级硬件级虚拟化,独立内核进程级隔离,共享宿主机内核
启动速度分钟级(要引导整个 OS)毫秒到秒级(只是启动进程)
镜像体积GB 级(含完整操作系统)MB 级(只含应用和依赖)
性能损耗有明显虚拟化开销接近原生进程
单机密度几台到十几台几十个甚至上百个
隔离强度强(内核完全独立)较弱(共享内核,存在逃逸风险)
运行不同内核的系统可以(Linux 宿主机跑 Windows 虚拟机)不可以(容器只能用宿主机内核)
备注

容器不是「轻量级虚拟机」,两者是不同的东西。很多生产环境会把两者结合起来用:在云厂商的虚拟机里再跑 Docker 容器。

Docker 发展历史​

几个关键时间节点:

时间事件
2010dotCloud 公司成立,做 PaaS 平台,内部使用基于 LXC 的容器技术
2013.03dotCloud 将容器引擎开源,命名为 Docker,迅速流行
2013dotCloud 改名为 Docker Inc.,All in Docker
2014Docker 1.0 发布;Google 开源 Kubernetes,容器编排之战开始
2015Docker 联合多家公司成立 OCI(Open Container Initiative),制定容器标准;Docker 将底层运行时捐出为 runc
2016Docker 把 containerd 从 Docker Engine 中拆出并捐给 CNCF
2017Docker 版本号改为 YY.MM 格式(如 17.03);分出 CE(社区版)和 EE(企业版)
2019Docker 将企业服务业务卖给 Mirantis,回归开发者工具
2020 起Kubernetes 宣布弃用 dockershim,推荐使用 containerd 等 CRI 运行时,但这不影响我们日常用 Docker
提示

「K8s 弃用 Docker」当年引起过恐慌,实际上弃用的只是 K8s 里的 dockershim 适配层。Docker 构建的镜像完全符合 OCI 标准,依然可以在 K8s(containerd 运行时)上正常运行。

Docker Engine 架构​

Docker 采用 C/S(客户端-服务端)架构,整体分为三层:

Docker Engine 架构分层图

三个核心组件:

组件职责
Docker Client用户入口,即 docker 命令。把命令通过 REST API 发给 Daemon,本身不做实际工作
Docker Daemon(dockerd)常驻后台的服务进程,监听 socket,对外暴露 REST API,管理镜像、容器、网络、数据卷
containerd由 dockerd 通过 gRPC 调用,负责镜像存储、容器生命周期管理等底层工作

Client 和 Daemon 之间默认通过 Unix Socket(/var/run/docker.sock)通信,也可以配置成 TCP,这样就可以用本地 Client 管理远程机器上的 Docker。

Docker Client、Daemon​

日常敲的每一条 docker 命令,都是 Client 把请求发给 Daemon 执行的:

# 查看 Client 和 Server(Daemon)各自的版本信息
docker version
Client:
Version: 27.x.x
API version: 1.4x
...

Server: Docker Engine - Community
Engine:
Version: 27.x.x
API version: 1.4x (minimum version 1.24)
...

几个常用验证命令:

# 查看 Daemon 的整体信息(存储驱动、Cgroup 版本、镜像数等)
docker info

# 查看 dockerd 进程是否在运行(Linux)
systemctl status docker

# Client 和 Daemon 之间的通信 socket
ls -l /var/run/docker.sock
注意

/var/run/docker.sock 权限很大:谁拿到了这个 socket,谁就等同于拥有宿主机的 root 权限。所以把 docker.sock 挂载进容器(/var/run/docker.sock:/var/run/docker.sock)要非常谨慎。

既然 Client 和 Daemon 之间走 REST API,我们也可以直接用 curl 调 API:

# 等价于 docker version 中 Server 部分的信息
curl --unix-socket /var/run/docker.sock http://localhost/version

containerd、runc、OCI​

这三个是 Docker 底层的真正干活的部分,理解它们的调用链很重要:

docker CLI → dockerd → containerd → containerd-shim → runc → 容器进程
组件职责
OCI不是软件,是一套标准规范:镜像规范(image-spec)定义镜像格式,运行时规范(runtime-spec)定义容器如何启动、配置长什么样
containerd容器运行时管理器,负责拉取/存储镜像、管理容器生命周期、网络接口等,由 dockerd 通过 gRPC 调用
containerd-shim每个容器对应一个 shim 进程,作为容器的「父进程」垫片。它让容器脱离 containerd 独立存在——containerd 或 dockerd 升级重启时,容器不会被杀死
runc一个命令行工具,按照 OCI 规范真正去创建容器:配置 Namespace、Cgroups,然后启动容器内的进程。容器建好后 runc 就退出,把进程交给 shim 看管

为什么要拆这么多层?因为 Docker 早期把所有功能都塞在 dockerd 里,任何升级、崩溃都会波及所有容器。拆分后每层职责单一,runc 和 shim 遵循 OCI 标准,还可以被其他工具(比如 Kubernetes 的 cri-o、nerdctl)直接复用。

在宿主机上可以验证这个进程链:

# 先启动一个常驻容器
docker run -d --name demo nginx

# 查看 containerd 和 shim 进程
ps aux | grep -E 'containerd|shim'
root      1234  ... /usr/bin/containerd
root 2345 ... /usr/bin/containerd-shim-runc-v2 -namespace moby -id <容器ID> ...

Linux Namespace​

容器「看起来是一个独立系统」,靠的是 Linux 内核的 Namespace(命名空间) 机制——把全局系统资源做视图隔离,容器内的进程只能看到自己命名空间里的那份资源。

Docker 用到的 6 种 Namespace:

Namespace隔离内容隔离后的效果
pid进程 ID容器内有独立的进程树,容器内 PID 1 是自己的 init 进程,看不到宿主机进程
net网络容器有独立的网卡、IP、端口、路由表,不同容器的 80 端口互不冲突
mnt文件系统挂载点容器有自己独立的文件系统视图,挂载/卸载互不影响
uts主机名和域名容器可以有自己的 hostname,默认是容器 ID 的前 12 位
ipc进程间通信信号量、共享内存、消息队列在容器间隔离
user用户和用户组容器内的 root 可以映射为宿主机上的普通用户,降低逃逸风险

验证一下:容器内进程的「命名空间」其实就是 /proc 下的一组符号链接。

# 查看宿主机上某个进程的 namespace(把 <PID> 换成容器内进程的宿主机 PID)
docker inspect -f '{{.State.Pid}}' demo
ls -l /proc/$(docker inspect -f '{{.State.Pid}}' demo)/ns
lrwxrwxrwx 1 root root 0 ... ipc -> ipc:[4026532678]
lrwxrwxrwx 1 root root 0 ... mnt -> mnt:[4026532676]
lrwxrwxrwx 1 root root 0 ... net -> net:[4026532681]
lrwxrwxrwx 1 root root 0 ... pid -> pid:[4026532679]
lrwxrwxrwx 1 root root 0 ... user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 ... uts -> uts:[4026532677]

每个容器这套编号都和宿主机不同,这就是隔离的证据。反过来,容器内 PID 1 的进程,在宿主机上也能用 ps 看到一个普通 PID——再次说明容器就是进程。

Linux Cgroups​

Namespace 解决的是「看不到别人」,Cgroups(Control Groups) 解决的是「别用太多」——限制、统计、隔离一组进程的资源用量(CPU、内存、磁盘 IO、网络等)。

Cgroups 把限制规则写在 /sys/fs/cgroup 下的文件里,Docker 创建容器时会为它建一个 cgroup 目录,写入限制值。

限制内存的例子:

# 最多使用 256MB 内存
docker run -d --name mem-demo -m 256m nginx

对应的 cgroup 文件:

cat /sys/fs/cgroup/system.slice/docker-<容器ID>.scope/memory.max
268435456

268435456 就是 256MB 换算成字节的结果。如果容器内进程超出这个限制,会被内核 OOM Killer 直接杀掉。

限制 CPU 的例子:

# 最多使用 0.5 个 CPU 核
docker run -d --name cpu-demo --cpus=0.5 nginx

这对应 cgroup 里的 cpu.max:配额 50000 微秒 / 周期 100000 微秒,即最多占半个核。

cat /sys/fs/cgroup/system.slice/docker-<容器ID>.scope/cpu.max
50000 100000
提示

用 docker stats 可以实时查看每个容器的 CPU、内存、IO 用量,排查「哪个容器吃资源」时很好用。

镜像(Image)、容器(Container)、仓库(Registry)​

这三个是 Docker 里最常打交道的概念:

概念类比说明
镜像 Image安装光盘 / 类(class)只读的应用打包模板,包含代码、依赖、配置。由多层只读层(layer)叠加而成
容器 Container装好的系统 / 实例(object)镜像的运行实例,在镜像只读层之上加了一层可写层。可以创建、启动、停止、删除
仓库 Registry应用商店集中存放和分发镜像的服务,默认是 Docker Hub,国内可自建 Harbor 或用云厂商镜像仓库

三者的关系用一条命令就能串起来:

# 从 Registry 拉取 Image 到本地
docker pull nginx:1.27

# 用 Image 创建并运行一个 Container
docker run -d --name web nginx:1.27

# 查看本地 Image 和运行中的 Container
docker images
docker ps

镜像和容器的存储结构也值得知道:

  • 镜像由多个只读层组成,多个镜像可以共享相同的基础层,节省磁盘。
  • 启动容器时在镜像之上加一个可写层(容器层),容器内的一切写操作都发生在这一层,镜像本身永远不变。
  • 删除容器时,可写层随之删除;镜像还在,可以随时再启动新容器。这就是「容器是临时的、镜像是不变的」。

Docker 生命周期​

容器的完整生命周期状态流转:

容器生命周期状态机

对应命令速查:

命令作用
docker create根据镜像创建容器(不启动)
docker start启动已创建/已停止的容器
docker run相当于 create + start,一步完成
docker stop先发 SIGTERM,超时后 SIGKILL,优雅停止
docker kill直接发 SIGKILL,立即杀死
docker pause / docker unpause用 cgroup freezer 挂起/恢复容器内所有进程
docker restart停止后重新启动
docker rm删除容器(运行中的需加 -f)
docker rm -f强制删除运行中的容器

实际操作一遍:

docker create --name life-demo nginx        # 创建,状态 created
docker ps -a # 能看到它,但没在运行
docker start life-demo # 启动,状态 running
docker pause life-demo # 暂停
docker unpause life-demo # 恢复
docker stop life-demo # 停止,状态 exited
docker rm life-demo # 删除
备注

docker stop 默认给容器 10 秒钟优雅退出(可用 -t 参数调整),超过时间才强杀。编写应用时处理好 SIGTERM 信号,能做到无损下线。

Docker 工作流程(Pull → Create → Start)​

docker run hello-world 看起来只有一条命令,背后其实经历了「拉取镜像 → 创建容器 → 启动容器」三个阶段。以这条命令为例,完整过程如下:

docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
...
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
...

第一步:Pull(拉取镜像)

  1. Client 把 docker run hello-world 请求发给 Daemon。
  2. Daemon 先在本地查找 hello-world 镜像,找不到就输出 Unable to find image ... locally。
  3. Daemon 通过 containerd 去配置的 Registry(默认 Docker Hub)按层下载镜像,分层校验、解压,存入本地镜像存储。

第二步:Create(创建容器)

  1. 镜像就绪后,Daemon 让 containerd 基于该镜像创建容器:准备读写文件系统视图(在镜像只读层上叠加可写层)、分配网络、生成容器 ID 和初始配置(按 OCI 规范生成 config.json)。
  2. 此时容器处于 created 状态,进程还没有跑起来。

第三步:Start(启动容器)

  1. containerd 为该容器拉起一个 containerd-shim,shim 再调用 runc。
  2. runc 读取 OCI 配置,创建新的 Namespace(pid/net/mnt/uts/ipc),配置 Cgroups 资源限制,然后执行镜像里定义的启动命令。
  3. runc 完成任务后退出,容器进程交给 shim 看管。容器进入 running 状态。
  4. hello-world 的启动命令只是打印那段 Hello from Docker! 说明文字,打印完进程退出,容器随即变成 exited——所以它跑完就停,用 docker ps -a 才能看到它。

用拆分的命令手动复现这三个阶段,更容易看清每一步:

docker pull hello-world          # 只做 Pull
docker create hello-world # 只做 Create,返回容器ID
docker start -a $(docker ps -lq) # Start 并附着输出,能看到 Hello from Docker!
排障思路

理解了这三步,遇到问题就知道去哪层查:拉不下来查网络和 Registry 配置(Pull 阶段);起不来查镜像入口命令和挂载配置(Create/Start 阶段);起起来立刻退出查容器内进程是不是前台运行的(容器内 PID 1 退出 = 容器退出)。