Skip to content

初识 Docker

Docker 做的事可以概括为一句话:把应用和它需要的所有东西打包在一起,让它在任何安装了 Docker 的机器上都能跑起来。

"需要的所有东西"包括了操作系统用户空间、运行时库、系统工具、配置文件、依赖包——不是只把二进制文件复制过去。一台机器上装的 libssl 版本和另一台不同、glibc 版本差了一代、Python 环境变量没设对——这些在传统部署里经常出现的问题,Docker 的思路是把这些差异封装进镜像,运行时不再依赖宿主机上装了什么。

容器和虚拟机——共享内核还是独立系统

虚拟机用 hypervisor 在物理硬件上虚拟出一整套硬件,每台虚拟机有自己的操作系统内核。容器没有自己的内核,它和宿主机共享同一个内核,靠 Linux 的 namespace 和 cgroups 做隔离和资源限制。

因为共享内核,容器镜像不需要打包一个完整的操作系统——镜像可以很小(几十 MB),启动可以很快(秒级),一台物理机上可以跑的容器密度远超虚拟机。代价是隔离性不如虚拟机。内核漏洞可能被用来跨容器攻击,Linux namespace 隔离的是视角(进程看到什么、网络看到什么),不是把所有资源都物理隔开。对安全隔离有严格要求的场景(多租户、不受信代码执行),虚拟机仍然是更稳妥的选择。

三个核心对象——镜像、容器、仓库

Docker 里反复出现三个对象,后面所有操作都在它们之间来回:

镜像是只读的模板。它不是一个大文件,而是一叠文件系统层——基础系统一层,装软件一层,复制应用文件一层。每层只记录和上一层的差异,相同数据不重复存。镜像可以复制、分发,但不能修改。

容器是镜像跑起来的实例。Docker 在镜像的只读层上面加了一层可写层,容器里所有的写操作(新建文件、修改配置、写日志)都发生在这层。删除容器时,这层可写层也一起消失——容器自己产生的数据,除非主动做了持久化,否则随着容器没了。

仓库是存放和分发镜像的地方。Docker Hub 是最常见的公共仓库,公司内部通常部署 Harbor 或其他私有仓库。"拉镜像"就是从仓库把镜像下载到本地 Docker。

Docker 技术栈的分层

平时敲的 docker run 背后并不只有 Docker 自己在干活:

组件位置职责
Docker CLI命令行接收 docker rundocker pull 等命令,发给 daemon
dockerd宿主机守护进程接收 API 请求,管理镜像、容器、网络、存储
containerddockerd 和 runc 之间管理容器生命周期——拉镜像、创建容器、管理存储和网络
runc最底层按 OCI 规范实际创建和运行容器,容器启动后退出

一条 docker run 背后的大致流程:CLI 发给 dockerd → dockerd 发给 containerd → containerd 启动 runc 创建容器 → 容器进程跑起来后 runc 退出。排查更底层的问题时才会直接接触 containerd 和 runc,日常运维主要面向 docker 命令。

隔离依赖什么

Docker 的隔离不是自己发明的,是对 Linux 内核能力的组合使用:

namespace 让进程看到不同的系统视图。PID namespace 让容器里的进程看到自己是 PID 1,在宿主机上它只是一个普通进程号;Network namespace 让容器有自己的网卡、IP、路由表;Mount namespace 让容器有自己的文件系统挂载点;UTS namespace 让容器有自己的主机名。一台宿主机跑 50 个容器,每个都觉得自己是独立的小 Linux 系统——其实是 50 组 namespace 各自圈定了一块视野。

cgroups 控制容器能用多少资源。CPU、内存、磁盘 IO 都可以限制。一台机器上跑了多个容器,其中一个 Java 应用吃了 8G 内存,如果不设限制,其他容器可能被挤出内存。cgroups 把"这个容器最多用 2 核 CPU、1G 内存"变成内核强制执行的硬上限。

联合文件系统让镜像的分层成为可能。overlay2 是当前最常用的存储驱动,它把多个只读镜像层叠在一起,容器看到的是一个完整文件系统,实际物理存储上相同的文件只存一份。修改文件时会复制到可写层再改(copy-on-write),不影响镜像和其他共享同一层的容器。

capabilities 把 root 的超级权限拆成细粒度能力。容器默认启动时,即使容器内显示 root,也只被授予了一部分 capabilities——不能加载内核模块、不能修改系统时间、不能直接访问硬件。需要特定能力时用 --cap-add 显式授予,而不是直接 --privileged 一把全开。

容器的生命周期

docker run 实际上是 create + start 的组合:

  • created:分配了网络、存储、namespace,但进程还没启动
  • running:进程正在执行
  • paused:进程被冻结(SIGSTOP),内存保留,CPU 不再分配
  • stopped:进程退出,文件系统和网络配置还在,可以重新启动
  • deleted:彻底清理,可写层没了,分配的资源回收

排查容器反复重启时,先判断是在 running 和 stopped 之间跳(启动命令马上退出),还是卡在 created 起不来(资源分配失败)——方向完全不同。

什么适合放容器

容器擅长的是无状态或状态可以外挂的服务。Web 应用、API 网关、消息消费者、定时任务、CI 构建——这些服务本身不存数据,数据在数据库、对象存储、消息队列里。更新时起新容器、停旧容器,数据不受影响。

不适合的是强依赖特定内核版本、特定硬件、或有状态但不方便外挂的场景。需要加载内核模块的安全工具、直接操作裸盘的存储系统、绑定特定网卡硬件的网络设备——不是技术上完全做不到,而是强绑宿主机的做法和容器"随处运行"的出发点矛盾。

数据库可以放容器里跑,但持久化和性能要单独处理:数据目录必须放 volume 或 bind mount,网络模式可能需要 host 减少 NAT 开销,资源限制要对数据库的内存使用模式有预期。MySQL 放进容器不是不行,前提是知道容器重启后数据在哪、宿主机挂了怎么迁移、连接数怎么跟容器 IP 对应。

运维视角里的边界

Docker 落地后,"容器外面"的东西往往比"容器里面"更容易出问题:

方向需要确认的点
镜像基础镜像来源、版本标签、漏洞扫描、构建记录
配置环境变量、配置文件挂载、敏感信息来源
网络端口映射、容器网段、防火墙、服务发现
存储volume、bind mount、备份路径、权限
日志标准输出、日志驱动、采集路径、保留周期
资源CPU、内存、进程数、文件句柄限制
安全root 用户、特权容器、宿主机目录挂载

容器让应用交付更标准,但宿主机仍然要管。磁盘满、镜像堆积、日志无限增长、容器没有资源限制、Docker socket 挂进容器——都是常见问题,解决点在宿主机上,不在容器里。