
Docker 这个词这几年被提得太多了多到很多人一上来就背命令——docker run、docker build、docker ps背得滚瓜烂熟可一旦容器起不来、镜像拉不动、磁盘被撑爆就彻底抓瞎。我见过太多团队把 Docker 当成轻量级虚拟机来用结果网络不通、数据丢失、性能异常排查半天发现是对底层机制的理解从一开始就偏了。这篇东西不打算写成命令手册那种东西官方文档比我全。我想干的是另一件事把 Docker 从是什么到为什么是这样这条链路彻底捋一遍重点砸在它背后那几个真正撑起整个体系的关键技术上——命名空间、控制组、联合文件系统、镜像分层、网络模型。你把这几个东西吃透了再回头看那些命令会发现它们每一个都有明确的来处而不是凭空冒出来的语法。适合谁看如果你已经能跑起容器但说不清它和虚拟机的本质区别如果你被镜像体积、构建缓存、容器网络折腾过如果你想在团队里做技术选型或者给新人做体系化培训这篇应该能省你不少翻文档的时间。我会尽量用生活化的类比把抽象机制讲透同时该给参数、给配置、给排查思路的地方绝不含糊。所有涉及实操的部分都基于常见工程实践补充你照着复现基本不会跑偏。1. 先把容器这个词从虚拟机旁边挪开很多人理解 Docker 的第一步就走歪了把容器想象成更轻的虚拟机。这个类比在入门阶段有点用但它会埋下大量认知隐患后面网络、存储、隔离性上的困惑几乎都源于此。所以咱们先把这件事掰扯清楚再谈技术。1.1 虚拟机在虚拟什么容器又在隔离什么虚拟机的工作原理是硬件级虚拟化。宿主机上跑一个 Hypervisor比如 KVM、Xen、Hyper-V它模拟出一整套虚拟硬件——虚拟 CPU、虚拟内存、虚拟磁盘、虚拟网卡然后在这套假硬件上装一个完整的操作系统。也就是说每个虚拟机里都有一份独立的内核、独立的驱动、独立的系统库。你开三个虚拟机就有三份内核在跑每份都要占内存、都要启动、都要打补丁。容器完全不是这个路子。容器共享宿主机的内核它不虚拟硬件它做的是进程级隔离。一个容器本质上就是宿主机上的一组普通进程只不过通过内核提供的一系列机制让这组进程以为自己独占了一台机器——有自己的文件系统视图、自己的网络栈、自己的进程编号空间、自己的用户体系。但底层跑的还是同一个内核。这个区别带来的直接后果我用一张表说清楚维度虚拟机容器隔离级别硬件级内核独立进程级共享宿主内核启动速度数十秒到分钟级毫秒到秒级单机密度几个到几十个几十到上百个镜像/系统体积GB 级MB 级常见隔离强度强内核漏洞不跨虚机较弱内核漏洞可能影响宿主操作系统支持可跑不同内核的系统必须与宿主内核兼容看到最后一行没有这是容器最大的约束也是最多人踩坑的地方。你在 Linux 宿主机上跑不了 Windows 容器除非用特殊方案在 ARM 机器上跑不了 x86 镜像。因为容器里的进程最终是交给宿主内核执行的内核不认的东西它就是不认。这一点在跨平台部署时特别容易翻车后面讲镜像的时候还会提到。1.2 为什么共享内核反而是容器的优势刚接触的人往往觉得共享内核是缺点——隔离不彻底嘛。但在绝大多数业务场景里这恰恰是容器价值的来源。第一没有内核启动开销。虚拟机启动要先引导内核、初始化驱动、拉起 init 进程这一套下来几十秒很正常。容器启动只是 fork 一个进程然后套上隔离的外壳毫秒级就起来了。这个差异在弹性伸缩场景下是决定性的流量突增时你要在几秒内扩出几十个实例虚拟机根本来不及容器可以。第二没有重复的内存占用。三个虚拟机就是三份内核内存容器不管开多少个内核只有一份。单机能塞下的实例数量直接上一个量级硬件利用率大幅提升。第三镜像可以做得极小。因为不用打包整个操作系统一个只跑静态编译程序的容器镜像可以做到几 MB。这意味着拉取快、分发快、启动快整条链路的效率都被拉起来了。注意共享内核意味着内核版本兼容性是硬约束。宿主内核太老新镜像里用到的系统调用可能不支持宿主内核打了某个补丁容器里的行为也可能跟着变。生产环境升级内核前务必确认业务容器没有依赖被改动的行为。1.3 一个容易混淆的边界容器不是轻量虚拟机我见过有团队在容器里跑 systemd、跑 sshd、跑一堆后台服务把一个容器当一台小机器使。这种用法能跑起来但违背了容器的设计哲学会带来一堆麻烦日志散落各处、健康检查难做、进程管理混乱、优雅退出失效。容器的正确心智模型是一个容器一个主进程。容器里 PID 为 1 的那个进程就是它的主心骨主进程退出容器就结束。你不需要在容器里搞一套 init 系统来管理一堆服务需要多个服务就拆多个容器用编排工具组合。这个理念上的转变比记住多少命令都重要。2. 命名空间让进程看不见别人的世界容器隔离的第一根支柱是 Linux 命名空间Namespace。这个词听起来玄其实做的事很朴素给进程换个视角让它只能看到被允许看到的那部分资源。就像给每个人发了一副只能看到自己工位的眼镜别人桌上有什么、在干什么一概不知。2.1 六种命名空间各管一摊Linux 内核目前提供多种命名空间Docker 主要用到下面这几种每一种负责隔离一类资源命名空间隔离的资源直观效果PID进程编号容器内看到自己的进程从 1 开始编号看不到宿主进程Mount挂载点容器有独立的文件系统视图看不到宿主目录树Network网络设备、协议栈、端口容器有独立网卡、IP、端口空间UTS主机名与域名容器可以有自己的 hostnameIPC进程间通信资源信号量、消息队列相互隔离User用户与用户组 ID容器内 root 可映射为宿主普通用户拿 PID 命名空间举例最直观。宿主机上你ps aux能看到几百个进程但进入某个容器执行ps往往只有一两个进程而且 PID 是 1、2、3 这样的小数字。这不是容器里真的只有这么点进程而是 PID 命名空间给容器内的进程重新编了号并且把宿主上其他进程藏了起来。容器内那个 PID 1 的进程在宿主机上可能是 PID 两万多的一个普通进程。Mount 命名空间则是文件系统隔离的基础。容器启动时Docker 会给它准备一个独立的根文件系统也就是镜像解压出来的那套目录然后通过 Mount 命名空间让容器内的进程把这个目录当成/。这样容器里ls /看到的是镜像里的内容而不是宿主机的根目录。2.2 网络命名空间为什么是排查问题的重灾区六种命名空间里Network 是最容易出问题的。因为网络隔离之后容器默认是与世隔绝的——它有自己的网卡、自己的路由表、自己的 iptables 规则跟宿主机的网络栈完全是两套。这就解释了一个经典困惑为什么容器里ping不通外网因为容器刚创建时它的网络命名空间里只有一个 loopback 接口没有任何通往外部世界的路径。Docker 需要额外做一堆事——创建虚拟网卡对veth pair、把一端塞进容器命名空间、另一端接到宿主机的网桥上、配置 IP 和路由、设置 NAT 规则——容器才能上网。这一整套动作就是后面第 5 节要展开的容器网络模型。排查网络问题时理解命名空间的存在能帮你少走很多弯路。比如你想看容器内的网络配置直接在宿主机上ip addr是看不到容器网卡的因为那属于另一个网络命名空间。你得用nsenter或者docker exec进到那个命名空间里去看。同理容器内的端口监听在宿主机上默认是看不到的必须通过端口映射暴露出来。2.3 User 命名空间一个常被忽视的安全开关User 命名空间值得单独说一句因为它直接关系到安全。默认情况下容器内的 root 用户和宿主机的 root 是同一个 UID 0。这意味着如果容器被攻破、攻击者在容器内拿到 root再配合内核漏洞或者不当的挂载配置是有可能影响到宿主机的。开启 User 命名空间映射后容器内的 root 会被映射成宿主机上的一个普通用户比如 UID 100000。这样即使容器内是 root在宿主机看来也只是个没特权的小角色破坏半径被大幅压缩。这个特性在很多发行版上默认没开需要手动配置。对于多租户或者跑不可信负载的场景强烈建议打开。提示User 命名空间映射开启后挂载宿主目录时的权限行为会变化可能出现容器内读写宿主文件权限不足的情况。上线前要在测试环境验证一遍文件权限相关的逻辑。3. 控制组给进程套上资源的缰绳命名空间解决了看不见的问题但光看不见不够。一个容器里的进程如果疯狂吃 CPU、疯狂申请内存照样能把整台宿主机拖垮。所以还需要第二根支柱——控制组cgroup它管的是用多少。3.1 cgroup 到底限制了什么cgroup 把一组进程组织成一个层级结构然后对这个组整体施加资源约束。主要能管这几类资源CPU限制能用多少核、按权重分配、限制每周期配额内存限制最大用量、超过阈值触发 OOM、统计用量块设备 IO限制读写带宽和 IOPS进程数限制组内能创建的进程/线程总数设备访问控制能访问哪些设备节点Docker 启动容器时会在 cgroup 树里为这个容器建一个节点把容器内所有进程都塞进去然后根据你传的参数往对应文件里写限制值。你docker run --memory512m本质就是往内存 cgroup 的memory.max里写了个 512M。3.2 内存限制的坑OOM 不是慢慢变慢内存限制这块有个特别常见的误解。很多人以为给容器设了 512M 内存上限容器内存用超了会变慢或者报个错。实际行为是一旦容器内进程的内存申请超过限制内核的 OOM killer 会直接杀掉容器里的进程容器随之退出退出码通常是 137。这个行为很暴力但它是设计使然——cgroup 内存限制是硬限制不是软提醒。所以你在设置内存限制时必须给应用留足余量。一个 JVM 应用堆内存设了 1G容器内存限制也设 1G那基本必炸因为 JVM 除了堆还有元空间、线程栈、直接内存等开销。经验做法是容器内存限制至少比应用峰值内存高出 20% 到 30%。CPU 限制的行为则温和得多。--cpus2表示这个容器最多能用相当于 2 个核的算力超了会被调度器限流throttle表现为变慢而不是被杀。但要注意CPU 限流会导致延迟抖动对延迟敏感的服务要谨慎设置过低的 CPU 限制。3.3 从 cgroup v1 到 v2 的迁移现实老一点的系统用的是 cgroup v1它的结构比较散每种资源一棵独立的树管理起来别扭。新系统逐步迁移到 cgroup v2统一成一棵树行为更一致还引入了 PSI压力停滞信息这种能反映资源争抢程度的指标。这个迁移对使用者有实际影响。比如 cgroup v2 下内存限制的文件名从memory.limit_in_bytes变成了memory.max一些老的监控脚本、运维工具如果直接读 cgroup 文件迁移后会读不到数据。容器运行时本身会做适配但你自研的监控采集逻辑得跟着改。排查资源问题时先确认系统用的是哪版 cgroup能省不少困惑。4. 联合文件系统与镜像分层容器为什么能秒起前两根支柱解决了运行时的隔离和限制但容器还有个让人惊叹的特性——启动快、镜像小、还能一层层复用。这背后是联合文件系统UnionFS和镜像分层机制在撑腰。4.1 镜像不是一个大文件而是一摞只读层很多人以为镜像就是一个打包好的压缩包解压出来就是文件系统。这个理解漏掉了最关键的部分镜像是分层的。一个镜像由若干只读层叠加而成每一层代表一次文件系统变更。比如你基于某个基础镜像先apt install装了点东西这是一层再COPY进去自己的代码又是一层再改了个配置文件再一层。每一层只记录相对于上一层的增量变化——新增了哪些文件、修改了哪些文件、删除了哪些文件。这种设计的好处是层可以被多个镜像共享。你本地有十个镜像都基于同一个基础镜像那这个基础镜像的层在磁盘上只存一份十个镜像共用。拉取镜像时已经存在的层直接跳过只下载缺的那几层。这就是为什么第二次拉取相似镜像往往快得多。4.2 写时复制容器启动为什么这么快容器启动时Docker 在镜像的所有只读层之上再叠加一个可写层。容器内所有对文件系统的修改——写日志、改配置、生成临时文件——都发生在这一层下面的只读层纹丝不动。这里的关键机制叫写时复制Copy-on-Write。当容器要修改一个位于只读层的文件时系统不会去改原文件而是先把该文件复制到可写层然后在可写层里改。读操作则直接读只读层不产生复制。这个机制解释了容器启动为什么快不需要复制整个镜像的文件系统只需要挂一个空的可写层上去几乎是瞬间完成。也解释了为什么容器删了数据就没了可写层随容器生命周期存在容器一删可写层跟着消失你在容器里写的所有东西都没了。注意正因为可写层是临时的任何需要持久化的数据都必须挂载到容器外部数据卷或绑定挂载。把数据库数据目录放在容器可写层里容器一重建数据全丢这是新手最常犯的致命错误之一。4.3 分层对构建的影响缓存是把双刃剑镜像分层直接决定了构建行为。构建镜像时每条指令生成一层Docker 会检查这一层是否能复用缓存——如果指令内容和上一层的结果都没变就直接用缓存跳过执行。这个机制用好了能大幅加速构建用不好会让你怀疑人生。最典型的坑是你把COPY . .放在RUN npm install前面结果改了一行代码整个依赖安装层缓存失效每次构建都要重新装一遍依赖。正确做法是把依赖描述文件如package.json单独先 COPY 进去装依赖再 COPY 源代码这样改代码不会影响依赖层的缓存。另一个坑是层数过多导致镜像臃肿。每条RUN都生成一层层里如果有临时文件没清理这些文件会一直留在镜像里。常见做法是把相关的安装和清理放在同一条RUN里用串起来比如装完包立刻删掉包管理器的缓存目录避免缓存文件固化进镜像层。5. 容器网络从与世隔绝到四通八达前面提过容器默认网络是隔离的。这一节把 Docker 的网络模型拆开讲因为网络问题是容器落地过程中最高频的故障来源。5.1 四种网络模式各自的适用场景Docker 内置了几种网络模式理解它们的差异是排查网络问题的前提模式隔离程度典型用途bridge独立网络栈通过网桥与外部通信默认模式绝大多数单机场景host共享宿主网络栈对网络性能极致要求或需要直接使用宿主端口none只有 loopback完全离线计算任务container共享另一个容器的网络栈边车模式辅助容器共用主容器网络bridge 模式是默认的也是最常用的。Docker 在宿主机上创建一个虚拟网桥默认叫docker0每个容器通过一对虚拟网卡接到这个网桥上容器之间通过网桥二层转发互通出外网则通过宿主机的 NAT 规则。host 模式则完全放弃网络隔离容器直接用宿主机的网卡和端口。性能最好但端口冲突风险高且失去了网络隔离带来的好处。一般只在特殊场景用比如某些对网络延迟极其敏感的服务。5.2 端口映射背后的转发链路docker run -p 8080:80这条命令大家都会写但它背后发生了什么值得说清楚。这条命令的意思是把宿主机的 8080 端口流量转发到容器的 80 端口。实现上Docker 会在宿主机上配置 DNAT 规则通常在 nat 表的 DOCKER 链里把目标为宿主 8080 的连接改写成目标为容器 IP 的 80 端口。同时还要配置 FORWARD 链的放行规则否则内核的包过滤会把这些转发包丢掉。理解了这条链路很多问题就好解释了。比如端口映射了但访问不通可能的原因就包括容器内服务没监听在 0.0.0.0 而是只监听了 127.0.0.1这样容器外部根本连不进来、宿主机防火墙拦了 FORWARD 链、容器 IP 变了但规则没更新等。排查时按宿主机端口监听 → DNAT 规则 → 容器内服务监听地址 → 容器内防火墙这个顺序逐段验证基本能定位。5.3 自定义网络与容器间通信默认 bridge 网络有个限制容器之间只能通过 IP 通信没有自动的服务发现。你起两个容器一个 web 一个 dbweb 要连 db得先查到 db 的 IP 再配进去db 重建 IP 变了就断了。解决办法是创建自定义 bridge 网络。在自定义网络里Docker 内置了 DNS 解析容器可以直接用容器名互相访问。你把 web 和 db 都接到同一个自定义网络web 里连db:5432就能通db 重建换 IP 也不影响。这是单机多容器协作的标准做法也是理解更上层编排工具服务发现机制的基础。提示容器间通信默认是不加密的且同一网络内所有容器互相可达。对安全敏感的场景要结合网络策略做访问控制不要假设在内网就安全。6. 镜像构建的工程化从能跑到跑得好把底层机制讲完落到最实际的构建环节。镜像构建这件事能跑起来和跑得好之间差距巨大前者十分钟能学会后者要靠项目经验堆。6.1 基础镜像怎么选小不是唯一标准选基础镜像时新手容易走两个极端要么无脑用最新的ubuntu镜像动辄几百 MB要么一味追求最小用scratch或者alpine结果踩一堆兼容性坑。合理的选型逻辑是看运行时依赖。静态编译的 Go 程序用scratch或distroless完全没问题镜像能压到几 MB。需要 glibc 的 C 程序alpine用的是 musl libc可能跑不起来得换debian-slim之类。Python、Node 这类解释型语言用官方的 slim 变体通常是比较平衡的选择。还有一个常被忽视的点固定版本标签。用latest标签看似省事实则埋雷——今天构建和明天构建可能拉到不同版本的基础镜像行为不一致排查问题时根本对不上。生产镜像必须锁定具体版本最好连摘要digest一起锁。6.2 多阶段构建把编译工具链挡在最终镜像之外多阶段构建是压缩镜像体积最有效的手段之一尤其对编译型语言。思路很简单用一个构建阶段的镜像装好编译器、依赖、源码编译出产物再用一个干净的运行阶段镜像只把产物拷进去。这样最终镜像里没有编译器、没有源码、没有构建缓存体积能小一个数量级攻击面也小得多。一个典型的 Go 服务单阶段构建可能几百 MB多阶段构建后能压到十几 MB。多阶段构建还有个附带好处构建环境和运行环境解耦。构建阶段可以用带完整工具链的大镜像运行阶段用极简镜像两边各取所需不用为了运行时的精简牺牲构建时的便利。6.3 构建缓存失效的排查思路构建慢的时候第一件事是看缓存有没有被正确利用。Docker 构建时会输出每一步是否命中缓存CACHED标记。如果发现某一步频繁失效往上找它的前一步——缓存是链式的前面一层变了后面全失效。常见的缓存失效原因包括COPY 了会变的文件比如带时间戳的构建产物、RUN 里执行了会拉取最新内容的命令比如没锁版本的包安装、指令顺序不合理导致易变操作排在稳定操作前面。调整指令顺序、锁定依赖版本、用.dockerignore排除无关文件是提升缓存命中率的三个基本动作。.dockerignore这个文件很多人不写结果把.git目录、本地依赖目录、日志文件全 COPY 进构建上下文既拖慢构建又污染缓存。养成写.dockerignore的习惯收益立竿见影。7. 落地时那些文档里不会写的经验最后这部分是我在实际项目里踩出来的、官方文档基本不会提的东西。每一条背后都有过真实的排查过程。7.1 容器时区与字符集小问题大麻烦容器默认时区往往是 UTC日志时间戳和业务时间对不上排查问题时能把人逼疯。解决办法是在构建时设置TZ环境变量并装好时区数据或者在运行时挂载宿主机的时区文件。字符集同理基础镜像如果没配 UTF-8中文会乱码。这两个问题都很小但一旦出问题排查成本不低建议在基础镜像里一次性配好。7.2 日志别往容器里堆容器可写层是有容量上限的受宿主磁盘限制应用如果把日志一直往容器内文件写迟早把磁盘撑爆而且容器一删日志全没。正确做法是把日志输出到标准输出由容器运行时统一收集转发。这样日志和容器生命周期解耦也方便集中管理。如果应用非要写文件就把日志目录挂载到外部存储。7.3 优雅退出别让容器被一刀切容器停止时运行时先给主进程发 SIGTERM等一段时间默认 10 秒还没退出就发 SIGKILL 强杀。如果你的应用没处理 SIGTERM正在处理的请求会被直接掐断表现为偶发的请求失败。所以应用必须注册信号处理收到 SIGTERM 后停止接收新请求、处理完手头的再退出。这个细节在压测和滚动发布时特别容易暴露。7.4 磁盘空间镜像和容器是隐形大户跑一段时间后宿主机磁盘告警八成是镜像和停止的容器堆的。docker system df能看清各类占用docker system prune能清理无用资源。但要注意prune 默认不删有容器在用的镜像和有名字的卷清理前确认清楚别把还在用的数据卷误删了。生产环境建议配置定期清理策略而不是等磁盘满了再救火。7.5 别在容器里跑需要特权的操作有些应用需要访问宿主设备、修改内核参数、挂载文件系统这些操作在普通容器里做不了于是有人图省事加--privileged。这个参数等于把容器的隔离几乎全部拆掉容器内的进程基本等同于宿主 root安全风险极高。真需要特定能力时用--cap-add精确添加所需的能力而不是一把梭全开。我个人在实际项目里的体会是Docker 的学习曲线前陡后平。命令和基本用法一两天就能上手但真正决定你能不能把它用稳的是对命名空间、cgroup、联合文件系统这几样底层机制的理解深度。每次遇到诡异问题往回追到这几样东西上基本都能找到答案。与其背一百条命令不如把这几个机制在脑子里建个模型剩下的都是在这个模型上的推演。