Docker 核心概念与架构
为什么需要 Docker
在没有 Docker 之前,部署一个应用通常是这样的:在服务器上装依赖、改配置、起服务,换一台机器再重复一遍。环境稍有差异(操作系统版本、依赖库版本、环境变量),应用就可能跑不起来,也就是经典的「在我电脑上明明是好的」。
Docker 解决的核心问题是:把应用和它依赖的环境一起打包成一个镜像,在任何装了 Docker 的机器上都能以同样的方式运行。
实际带来的好处:
- 环境一致:开发、测试、生产用同一个镜像,排除「环境差异」这一类问题。
- 隔离性好:多个应用跑在同一台机器上互不干扰,各自的依赖、端口、文件系统互不可见。
- 启动快:容器是进程级的,启动通常在一秒以内,不像虚拟机要启动整个操作系统。
- 资源利用率高:一台机器可以跑几十个容器,共享宿主机内核,没有虚拟机的额外开销。
- 交付简单:交付物从「一堆文档 + 安装包」变成「一个镜像 + 一条启动命令」。
Docker 就是一个「打包应用及其运行环境,并以隔离进程的方式运行它」的工具。镜像负责打包,容器负责运行。
Docker 与虚拟机
容器和虚拟机都能提供隔离的运行环境,但实现方式完全不同:
- 虚拟机:通过 Hypervisor(如 KVM、VMware)虚拟出一套完整硬件,上面跑一个完整的 Guest OS,应用运行在 Guest OS 里。
- 容器:不虚拟硬件,直接共享宿主机内核,通过 Namespace 做视图隔离、Cgroups 做资源限制,容器本质上就是宿主机上的一个普通进程。
| 对比项 | 虚拟机 | Docker 容器 |
|---|---|---|
| 隔离层级 | 硬件级虚拟化,独立内核 | 进程级隔离,共享宿主机内核 |
| 启动速度 | 分钟级(要引导整个 OS) | 毫秒到秒级(只是启动进程) |
| 镜像体积 | GB 级(含完整操作系统) | MB 级(只含应用和依赖) |
| 性能损耗 | 有明显虚拟化开销 | 接近原生进程 |
| 单机密度 | 几台到十几台 | 几十个甚至上百个 |
| 隔离强度 | 强(内核完全独立) | 较弱(共享内核,存在逃逸风险) |
| 运行不同内核的系统 | 可以(Linux 宿主机跑 Windows 虚拟机) | 不可以(容器只能用宿主机内核) |
容器不是「轻量级虚拟机」,两者是不同的东西。很多生产环境会把两者结合起来用:在云厂商的虚拟机里再跑 Docker 容器。
Docker 发展历史
几个关键时间节点:
| 时间 | 事件 |
|---|---|
| 2010 | dotCloud 公司成立,做 PaaS 平台,内部使用基于 LXC 的容器技术 |
| 2013.03 | dotCloud 将容器引擎开源,命名为 Docker,迅速流行 |
| 2013 | dotCloud 改名为 Docker Inc.,All in Docker |
| 2014 | Docker 1.0 发布;Google 开源 Kubernetes,容器编排之战开始 |
| 2015 | Docker 联合多家公司成立 OCI(Open Container Initiative),制定容器标准;Docker 将底层运行时捐出为 runc |
| 2016 | Docker 把 containerd 从 Docker Engine 中拆出并捐给 CNCF |
| 2017 | Docker 版本号改为 YY.MM 格式(如 17.03);分出 CE(社区版)和 EE(企业版) |
| 2019 | Docker 将企业服务业务卖给 Mirantis,回归开发者工具 |
| 2020 起 | Kubernetes 宣布弃用 dockershim,推荐使用 containerd 等 CRI 运行时,但这不影响我们日常用 Docker |
「K8s 弃用 Docker」当年引起过恐慌,实际上弃用的只是 K8s 里的 dockershim 适配层。Docker 构建的镜像完全符合 OCI 标准,依然可以在 K8s(containerd 运行时)上正常运行。
Docker Engine 架构
Docker 采用 C/S(客户端-服务端)架构,整体分为三层:

三个核心组件:
| 组件 | 职责 |
|---|---|
| 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(拉取镜像)
- Client 把
docker run hello-world请求发给 Daemon。 - Daemon 先在本地查找
hello-world镜像,找不到就输出Unable to find image ... locally。 - Daemon 通过 containerd 去配置的 Registry(默认 Docker Hub)按层下载镜像,分层校验、解压,存入本地镜像存储。
第二步:Create(创建容器)
- 镜像就绪后,Daemon 让 containerd 基于该镜像创建容器:准备读写文件系统视图(在镜像只读层上叠加可写层)、分配网络、生成容器 ID 和初始配置(按 OCI 规范生成
config.json)。 - 此时容器处于
created状态,进程还没有跑起来。
第三步:Start(启动容器)
- containerd 为该容器拉起一个 containerd-shim,shim 再调用 runc。
- runc 读取 OCI 配置,创建新的 Namespace(pid/net/mnt/uts/ipc),配置 Cgroups 资源限制,然后执行镜像里定义的启动命令。
- runc 完成任务后退出,容器进程交给 shim 看管。容器进入
running状态。 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 退出 = 容器退出)。