敲下docker run之后看着终端吐出一串容器 ID,几乎所有人都经历过那个"能用但心里没底"的阶段。平时跑得好好的,一旦碰上容器起不来、端口映射不生效、镜像把磁盘吃到报警,就只能靠搜索引擎和反复重试硬扛。问题的根源往往不在命令记不熟,而在于对 Docker 基本架构没有建立起一张清晰的图:谁在调用谁、请求从客户端发出去之后经过哪几个进程、镜像的分层是怎么落盘的、容器和宿主机内核之间到底是什么关系。这套架构本身并不玄乎,它本质上把 Linux 内核里几项平时很少被直接摆弄的能力——namespace 负责隔离、cgroups 负责资源约束、联合文件系统负责分层——包装成了一套顺手好用的接口,外面再套一个常驻的守护进程和一个命令行客户端。这篇是专题第一篇,只讲架构本身:组件怎么分层、请求怎么流转、一次运行之后宿主机上到底多了哪些东西。既适合已经会敲命令、想把黑盒拆开看一眼的人,也适合刚接触容器、想从第一天就把概念理顺的人。
1. 先把这张架构图读懂:Docker 由哪些部件拼起来
1.1 为什么很多人学 Docker 会卡在"会用但不懂"
我见过太多人的学习路径是这样的:装好环境,照着教程敲docker run、docker ps、docker exec,一周之内常用命令就都熟了,然后学习就停在这里。这套流程在顺利的时候毫无破绽,可一旦出问题,麻烦就来了。容器启动后立刻退出,你会去猜是不是命令写错了;宿主机磁盘突然少了二十个 G,你不知道该删哪个目录;两个容器之间网络不通,你只能一个个参数往回试。这种"参数试错法"效率极低,因为你面对的是一个由四五个进程串起来的调用链,问题可能出现在链条上的任何一环,而你在链条外面盲猜。
懂架构带来的第一个直接好处是:你知道了排查的入口在哪。docker ps显示容器 Exited,你要看的是容器主进程的退出码和日志;docker info报存储驱动异常,你要看的是overlay2目录和挂载情况;容器里ping不通外部,你要区分是容器网络、宿主机转发还是上游的问题。第二个好处是资源估算有依据:知道镜像是分层共享的、容器的可写层是独立的,你就能理解为什么拉十个基于同一个基础镜像的容器,磁盘占用远小于十个完整副本。第三个好处是选型判断:什么时候该用容器、什么时候老老实实上虚拟机,答案藏在内核共享这一条里,而这条正是架构的根基。
所以我不建议一上来就背命令。先把这张图在脑子里画出来,后面所有命令都只是在这张图上的某个节点做操作,学起来会快得多,也扎实得多。
1.2 从客户端到内核:四层调用链拆解
把 Docker 拆开看,它是一条从用户终端一直延伸到内核特性的链路,中间隔着好几个进程。很多人以为docker命令就是 Docker,其实它只是一个瘦客户端,真正的活儿是后面几个进程干的。
| 层级 | 组件 | 主要职责 | 是否常驻 |
|---|---|---|---|
| 第一层 | 命令行客户端 | 解析参数、把操作翻译成接口请求发给守护进程 | 否,命令结束即退出 |
| 第二层 | 守护进程 | 接收请求、管理镜像与容器元数据、分配网络和存储、对外暴露接口 | 是,随服务启动 |
| 第三层 | 容器运行时管理器 | 拉取镜像层、管理容器生命周期、把运行任务派发给底层运行时 | 是 |
| 第四层 | 底层运行时与内核 | 创建命名空间、配置资源限制、挂载根文件系统、执行容器主进程 | 否,按需拉起 |
这张表是整篇文章的骨架,后面所有内容都是往里填细节。这里先强调一个经常被误解的点:命令行客户端和守护进程之间不是简单的本地调用,而是通过一套接口协议通信。这个设计意味着客户端可以跑在完全不同的机器上,通过配置环境变量把请求指向远程守护进程,这也是很多人在本地调试远程环境时用到的能力。同样因为这条链路是网络通信,所以守护进程的监听方式和权限配置会直接影响你能不能连上,这一点在后面讲排查的时候还会提到。
另一件值得先说清楚的事是第三层和第四层的关系。早期版本的守护进程里直接内置了容器执行逻辑,后来这部分被剥离成一个独立的运行时管理器,守护进程通过接口把活儿派给它,它再去调用更低层的运行时。这么拆的目的是让"管理"和"执行"解耦:上层可以专注镜像分发、容器编排、资源调度这些偏业务的事,下层专注把进程真正跑起来这件事。这套分层也带来了一个很实际的好处——下层运行时遵循统一的容器运行时规范,理论上换一个符合规范的实现也能跑,不必绑死在某一个上面。
1.3 容器和虚拟机的边界到底在哪
理解架构绕不开这个对比,因为它决定了容器的能力上限。虚拟机是通过虚拟化技术模拟出一整套硬件,然后在上面装一个完整的操作系统内核,应用跑在这个独立内核之上。容器没有自己的内核,容器里的进程和宿主机上的普通进程共用同一个内核,只是被命名空间隔在了不同的视图里、被资源控制限制在了不同的配额里。
这个差别带来的后果是很具体的。启动速度上,虚拟机要经历引导加载、内核初始化、系统服务启动这一长串流程,容器只需要创建几个命名空间、设置好配额、切换根目录然后执行进程,量级差得很明显。资源开销上,虚拟机每个实例都有一份完整的内核内存占用和系统服务开销,容器几乎没有额外的基础开销,因为内核是共享的。但代价也在这里:隔离强度不一样。容器内的进程在内核眼里依然是普通进程,一旦出现内核层面的漏洞,边界就可能被突破;而且容器不能跑跟宿主机内核差异很大的系统,比如在一种内核上直接跑另一套内核体系的容器是做不到的。
我自己总结的一句话是:容器解决的是"应用交付和运行环境一致性"的问题,虚拟机解决的是"强隔离和多系统并存"的问题。这两个目标不一样,所以不存在谁替代谁,很多生产环境里实际上是两种技术叠着用的。理解了这条边界,你就明白为什么有些场景一上手就感觉不对劲——比如想在一个容器里跑需要加载特定内核模块的东西,或者想跑一套完全不同体系的系统,那本来就不是容器擅长的事。
2. 镜像、容器、仓库:三个词撑起整套模型
2.1 镜像是只读的层叠积木
镜像这个词容易让人联想到一个完整的、不可分割的文件包,实际恰恰相反:镜像是分层的,而且每一层都是只读的。构建过程中每执行一步写操作,就会产生一个新层,新层记录的是相对上一层的变化量,而不是完整快照。底下的基础层可能在几十个镜像之间共享,上层只存放你这一步新增或修改的东西。
这种设计的价值在磁盘和传输上体现得最明显。假设你本地已经有一份基础镜像,再拉一个基于同一个基础的其他镜像时,公共的底层已经存在,只需要下载差异部分。这也是为什么很多镜像看起来体积庞大,但实际拉取速度并不慢——公共层被复用掉了。用docker history可以直观看到每个镜像由哪些层构成、每层大致多大,我经常用它来判断一个镜像是不是被"喂"得太胖。
理解分层的另一个价值在于理解构建缓存。构建时如果某一层的内容没有变化,这一层以及它下面的所有层都可以直接复用缓存,不用重新执行。这一条直接决定了写构建文件的正确姿势:变动频率低的内容放前面,变动频繁的放后面。很多人抱怨构建慢,其实是把拷贝源代码这种每次都变动的操作放在了安装依赖之前,导致依赖安装缓存永远失效。
注意:镜像层一旦生成就不可修改。所谓"修改镜像里的文件"其实是在上层新增记录,底层始终保持原样,这个机制就是后面讲写时复制的根基。
2.2 容器是加了可写层的活进程
容器不是镜像的"解压副本",它的真实结构是:若干只读镜像层 + 一个薄薄的可写层,再加上一组运行时配置(命名空间、资源配额、网络设置、挂载点)。这个可写层在容器启动时才创建,容器被删除时一起销毁。
这就解释了一堆日常困惑。为什么容器里装了个软件、重启容器之后还在?因为重启不等于删除,可写层还在。为什么删掉容器重建之后装的东西全没了?因为可写层跟着容器一起被销毁了,新容器拿到的是一个全新的空可写层。所以在容器里手动改配置、手动装依赖,本质上都是在临时层上做实验,一旦容器生命周期结束,这些改动就烟消云散了。真要持久化,只有两条路:把改动固化到镜像里(改构建文件重新构建),或者把需要保留的数据放到挂载的卷上。
这里还有个反直觉的细节:可写层不是"整份拷贝"。当容器要修改一个位于只读层的文件时,系统并不会把整个层复制一份,而是只把这一个文件复制到可写层,然后对副本做修改。这个机制叫写时复制。它的好处是容器启动几乎瞬间完成、磁盘占用极小,代价是频繁大量写小文件时会有额外的复制开销。
提示:写密集型应用(数据库、日志量大的服务)不要把数据目录留在容器可写层,一是性能有损耗,二是数据会随容器删除一起丢。
2.3 仓库是层片的物流中心
仓库负责存储和分发镜像,但它的组织方式跟普通文件服务器不一样:它按层来存。一次拉取的实际过程大致是,先拿到描述这个镜像的清单,清单里列出了它包含哪些层以及每层的唯一摘要值,然后再按需逐层下载,下载完成后用摘要值做校验,确认内容完整。
这个摘要校验机制很关键。镜像标识除了可读的名字和标签,还有一个基于内容算出来的摘要。标签是可以被重新指向的,同一个标签在不同时间可能对应不同内容,而摘要一旦确定就对应唯一内容。所以在需要严格保证"每次部署的镜像完全一致"的场景里,用摘要而不是标签来指定镜像是更稳妥的做法。这个细节在生产环境里很重要,我见过因为标签被覆盖导致不同机器拉到的镜像不一致、排查了半天的案例。
拉取时还有一点值得注意:多个层是可以并行下载的,所以你会在终端看到好几条进度条同时推进。层是按需下载的,如果你本地已经有某一层,就不会重复下载。这也意味着清缓存要谨慎——docker system prune这类命令会删掉未被使用的镜像和层,删完下次再拉就得重新走网络。
3. 一次 docker run 的完整旅程
3.1 客户端先做了什么
从你按下回车那一刻开始算。客户端要做的第一件事是解析参数,把-p、-v、-e、--memory这些选项整理成结构化的请求体,同时判断要连的是本地守护进程还是远程地址。判断依据来自环境变量和默认配置,如果指向的是本地,通常走的是本地套接字文件。
接下来客户端把请求发给守护进程,然后就开始等回包。这里有个容易被忽略的点:默认情况下,运行命令加不加后台参数,行为差别很大。不加后台参数时,客户端会把容器的标准输入输出接管过来,你的终端实际上被挂到了容器主进程的输出管道上,所以你才能实时看到日志。加了后台参数之后,客户端拿到容器标识就退出了,容器继续在后台跑。
这也解释了另一个常见现象:前台运行容器时按组合键退出,容器也跟着停了。因为终端连接断开,容器的标准输入被关闭,主进程读到结束信号就退出了。想让容器在后台长期跑,除了加后台参数,还要保证容器里有一个持续运行的前台进程,这是很多人写构建文件时踩的坑——把服务配成后台守护模式启动,结果容器一启动就退出,因为主进程认为没事可做就结束了。
3.2 守护进程的调度与元数据落地
守护进程收到请求后,第一步是确认镜像在不在本地。不在就先触发拉取流程,这是一个可能耗时较长的阶段,很多人以为命令卡住了,其实是在下载。镜像就位之后,守护进程开始准备运行环境,这一步做的事情相当多而且顺序有讲究。
先分配容器标识并写入元数据,这些信息落在守护进程的数据目录下,包括容器的配置文件、主机的解析文件、以及后面会用到的各种状态记录。然后创建可写层,也就是在存储驱动里为这个容器准备一个独立的上层目录。接着处理网络,如果用了默认的桥接模式,就要在虚拟网桥上分配地址、配置容器内部的网络接口、写入转发规则。如果指定了端口映射,还要在宿主机上登记映射关系。最后处理挂载,把指定的卷或者宿主机目录挂到容器的目标路径上。
这些准备工作全部完成后,守护进程才把"启动这个容器"的任务交给下一层。这里有个设计上很聪明的地方:容器的运行状态和守护进程本身是解耦的。也就是说守护进程重启,正在运行的容器不会跟着死掉。这一点在升级守护进程或者调整配置的时候非常重要,也是很多人第一次重启服务时惊讶地发现容器还活着的原因。
3.3 底层运行时与内核能力真正上场
任务传到底层运行时之后,画风就从"管理"切换到"执行"了。底层运行时的职责非常聚焦:按照规范把进程创建出来。具体动作包括准备容器的根文件系统视图(也就是把若干只读层和一个可写层组合成一个完整的目录树)、设置挂载点、创建各个维度的命名空间、写入资源配额、切换根目录、降权到指定用户,最后执行容器的主进程。
命名空间是隔离的核心。不同维度的命名空间负责不同的隔离面:进程维度让容器内看不到宿主机上的其他进程,网络维度给容器一套独立的网卡和地址,挂载维度让容器有一套独立的目录树,主机名与域维度让容器有自己的主机名,进程间通信维度隔离共享内存和消息队列,用户维度做身份映射。这几个维度组合起来,容器内看出去就像一台独立的小机器,但实际上跑的还是宿主机内核上的普通进程。
资源限制靠的是另一套内核机制,负责给进程组设定配额。能限制的维度包括内存上限、处理器时间片权重、磁盘读写速率、进程数量上限等。这里有个实操细节值得强调:内存上限设置之后,容器内进程超出限制会被直接终止,而不是像有些系统那样先触发回收再慢慢处理。所以给容器设内存限制时要留出余量,尤其是带缓存的中间件,设得太紧会出现莫名其妙的进程退出。
3.4 启动之后的常驻角色与退出回收
容器主进程跑起来之后,链路上还有一个不起眼但很关键的角色:中间层进程。它的作用是承上启下,把容器进程和上层管理器隔开。这个设计解决了一个实际问题——上层管理器重启或者升级的时候,容器进程不会受到牵连。你可以把中间层理解成给每个容器配的一个"看护人",它负责转发输入输出、上报退出状态、在容器主进程退出后做收尾。
容器退出时会经历一轮回收。主进程退出,中间层捕获退出码并上报,守护进程更新容器状态并记录下来。这时容器进入已停止状态,但可写层、网络配置、元数据都还在,所以还能查看日志、还能重启。只有执行删除操作,可写层和网络资源才会被真正释放。所以磁盘被容器占满时,光停容器是没用的,得把不再需要的容器删掉。
提示:容器退出码是最重要的排查线索之一。进程被强制终止通常对应一个特定的退出码,正常退出是零,主动终止是另一个值,被内存限制干掉又是另一种表现。先看退出码再看日志,顺序别搞反。
4. 动手验证:把架构从概念变成看得见的东西
4.1 环境准备与版本自查
理论讲完,接下来最有效的学习方式是自己动手把这条链路摸一遍。准备一台能装容器的机器,云主机、本地虚拟机都行,注意内核版本别太老,很多新特性依赖较新的内核。装好之后第一件事不是跑容器,而是看版本。
docker version这个命令的输出信息量很大,值得逐段看。它会分成客户端和服务端两大块,如果只看到客户端部分、服务端部分报连接错误,说明客户端连不上守护进程,这时候要检查服务是否在跑、当前用户有没有权限。服务端部分会列出一串版本信息,包括引擎版本、运行时管理器版本、底层运行时版本,还有初始化进程的版本。这一串恰好就是前面讲的分层结构在版本号上的体现——引擎、管理器、运行时各是各的,可以分别升级。
docker info这个命令输出的是运行环境的全貌,我重点关注几项。存储驱动那一行告诉你用的是哪种分层文件系统,主流是overlay2。资源控制驱动那一行告诉你配额是怎么接管的,现在多数是交给系统服务管理器。资源控制版本那行区分新旧两套机制,目录结构完全不同。内核版本和操作系统那几行则决定了你能用哪些特性。这些信息在排查问题时是必看的,尤其换了一台机器之后,第一件事就是先看这几行。
4.2 用进程视角看容器:它真的只是一个进程
前面反复强调容器就是普通进程,这一点可以用最朴素的方式验证。先跑一个长期运行的容器:
docker run -d --name demo-test busybox sleep 3600然后在宿主机上看进程树:
ps -ef | grep -E "dockerd|containerd|shim|sleep 3600" | grep -v grep你会看到一条清晰的父子链条:最上层是守护进程,下面是运行时管理器,再下面是容器的中间层进程,最下面那个sleep 3600就是容器的主进程,它的父进程正是中间层进程。这一刻,"容器就是被特殊配置过的进程"这句话从概念变成了看得到的事实。
再往下挖一层,看这个进程在各个隔离维度上的归属:
PID=$(docker inspect -f '{{.State.Pid}}' demo-test) ls -l /proc/$PID/ns/ ls -l /proc/1/ns/对比两组输出里的 inode 编号,你会发现容器进程在进程、网络、挂载、主机名等多个维度上都和宿主机的初始进程不同,说明它处在一套独立的视图里。而在用户维度上可能相同也可能不同,取决于启动时有没有开启用户映射。这一步做完,你就把抽象的"隔离"变成了可以眼睛看的具体证明。
还有一个可以用来观察的细节:在容器里执行ps,你会发现自己就是编号为 1 的进程,看不到宿主机上其他任何东西。这不是因为它真的占了开机第一号进程的位置,而是因为在那个独立的进程视图里,它就是第一个。这个错觉恰恰说明了命名空间的作用——它给进程提供了一套自洽的、只属于自己的编号体系。
4.3 验证资源约束:配额不是说说而已
跑一个带内存限制的容器:
docker run -d --name demo-mem --memory=256m busybox sleep 3600先确认容器里的感知:
docker exec demo-mem cat /sys/fs/cgroup/memory.max再看宿主机上对应的控制文件,路径通常形如/sys/fs/cgroup/system.slice/docker-<容器标识>.scope/,里面的各项文件对应不同维度的限制。老版本内核用的是另一套目录结构,路径和文件名都不一样,所以看之前先用docker info确认版本。
用户在容器里看到的限制值和宿主机上控制文件里的值是一致的,这点很关键——它不是靠容器内的工具模拟出来的,而是内核真的在按这个数值执行。可以再进一步:给容器写一个大内存程序,观察它被强制性终止时容器状态的变化和退出码。这种"亲手触发一次"的体验,比看十篇文档都记得牢。
注意:验证资源限制会产生真实的资源消耗和进程终止,别在生产环境上做。另外有些系统的内存控制参数还有更细的维度,比如交换空间的上限,默认值可能会影响结果判断。
4.4 验证分层与写时复制
这一节是我认为最有价值的一次验证,因为它能彻底改变你对镜像的理解方式。先启动一个容器:
docker run -d --name demo-layer busybox sleep 3600 docker inspect -f '{{.GraphDriver.Data.MergedDir}}' demo-layer docker inspect -f '{{.GraphDriver.Data.UpperDir}}' demo-layer第一个路径是容器最终看到的合并视图,第二个路径是那层可写层的真实位置。现在做两件事:一是在容器内新建一个文件,二是在容器内修改一个原本就存在的文件:
docker exec demo-layer sh -c 'echo hello > /newfile.txt' docker exec demo-layer sh -c 'echo changed >> /etc/hostname'然后去可写层目录里看:
sudo ls -la $UPPERDIR sudo find $UPPERDIR -name "*.txt" -o -name "hostname"你会发现新文件直接出现在可写层里,而那个被修改的旧文件也出现在了可写层里——这就是写时复制的现场证据:修改一个只读层中的文件时,系统先把这个文件复制到可写层,然后对副本动手,底层始终保持原样。
再确认一下挂载关系:
mount | grep overlay输出里能看到一个挂载项的选项部分列出了若干下层目录、一个上层目录、一个工作目录。那一串下层目录就是镜像的各层,从下往上叠,最上面那层是容器的可写层。这个输出基本就是"镜像分层 + 容器可写层"这句话在系统层面的完整呈现,我第一次看到的时候确实有种豁然开朗的感觉。
最后一步,删掉容器,再去看那个路径:
docker rm -f demo-layer可写层连同容器里做的一切改动一起消失了。这个演示把"容器是一次性的"这句话落实到了文件系统层面,从此以后在容器里手动改配置这件事,你会做得非常谨慎。
5. 存储与网络:架构里最容易踩坑的两块
5.1 分层存储与数据卷的本质区别
存储这块的困惑大多来自一个误解:把容器的可写层当成持久化的磁盘用。前面对比过,可写层跟着容器生命周期走,删了容器就没了。真正的持久化方案是数据卷和宿主机目录挂载,它们的实现路径跟可写层完全不是一回事。
数据卷由守护进程统一管理,通常落在数据目录下的一块独立区域,它被直接挂到容器的目标路径上,不参与分层,也不受写时复制影响。宿主机目录挂载更直接,把宿主机上的一个路径映射进容器,容器里的写入直接落到宿主机磁盘上。两种方式各有取舍:卷的管理更省心、跨平台一致性更好,宿主机目录挂载对文件权限和路径的理解更直观、便于和宿主机上的其他工具协作。
| 存储方式 | 数据位置 | 生命周期 | 适用场景 |
|---|---|---|---|
| 容器可写层 | 随容器创建的上层目录 | 跟容器同生共死 | 临时文件、可丢弃的中间产物 |
| 数据卷 | 由守护进程管理的独立区域 | 独立于容器,需显式清理 | 数据库数据、需要跨容器共享的状态 |
| 宿主机目录挂载 | 宿主机指定路径 | 完全由宿主机管理 | 配置文件、日志落盘、与其他工具协作 |
还有一个常见的坑:在宿主机目录挂载时,如果容器内进程的运行用户对宿主机路径没有写权限,就会出现"容器里明明能读、一写就报错"的情况。根因不在容器,而在于两边身份映射没有对齐。我的做法是提前把目标目录的归属和权限设置好,或者在启动时显式指定运行用户,别等到出了问题再猜。
5.2 网络模型与端口映射的真实路径
默认情况下,容器接入的是守护进程维护的一个虚拟网络,通过一个虚拟网桥与宿主机连通,容器在这个网段里分到一个地址。容器之间在同一条虚拟网络上可以直接用地址互访,宿主机也能通过这个地址访问到容器。这套结构落地下来,是一组虚拟网卡、一个网桥设备和一批转发规则。
端口映射是问得最多的部分。指定映射之后,宿主机上的某个端口收到的流量会被转到容器的目标端口。这条路径实际上有两套机制同时存在:一套是内核层面的地址转换规则,负责把发往宿主机端口的包改写到容器地址;另一套是宿主机上的一个代理进程,它监听那个端口并把流量转发进去。多数情况下走的是前一套,代理进程作为补充存在。理解这一点有助于排查:映射不生效时,先确认规则有没有被写进去,再确认是否有其他程序占了同一个端口,最后才怀疑容器内部服务。
docker run -d --name demo-net -p 18080:80 busybox sleep 3600 sudo iptables -t nat -L -n | grep 18080 ps -ef | grep docker-proxy | grep -v grep另外还有几个容易忽略的点。容器内部的服务必须监听在所有地址上,而不是只监听本地回环,否则外部转发进来的流量会被拒绝;这在配置中间件和应用服务器的时候特别常见。容器之间的互访如果依赖容器名解析,需要它们处在同一条自定义网络上,默认网络里默认的解析行为不同。还有一点是关于端口冲突的:宿主机端口被占用时,容器会直接启动失败,报错信息里会明确指出是绑定失败,别去容器日志里找原因,那里什么都没有。
6. 常见问题与排查实录
6.1 一张问题速查表
架构清楚了,排查就有了方向。下面这张表是我自己整理的高频问题清单,按"先看哪里"排序,能省掉大量盲目尝试。
| 现象 | 优先检查的位置 | 常见根因 | 处理方向 |
|---|---|---|---|
| 命令报连接失败 | 客户端与服务端的连接配置 | 服务未启动、权限不足、地址配置错误 | 检查服务状态、当前用户权限、连接地址 |
| 容器启动后立刻退出 | 容器主进程与退出码 | 主进程被配成后台模式、缺少前台进程、启动参数错误 | 查看日志与退出码,改为前台运行主进程 |
| 端口映射不生效 | 宿主机转发规则与端口占用 | 服务只监听回环、端口冲突、规则未写入 | 改监听地址、换端口、核查规则 |
| 磁盘占用持续增长 | 镜像层、容器可写层、日志文件 | 未清理的停止容器、悬空镜像、日志未轮转 | 清理无用容器与镜像、配置日志轮转 |
| 容器内改的文件重启后消失 | 容器生命周期是否包含删除 | 改动落在可写层,容器被重新创建 | 改为构建进镜像或用卷持久化 |
| 内存限制下进程莫名被杀 | 资源配额与退出码 | 配额设得过紧、缓存占用被计入 | 放宽配额、区分缓存与真实用量 |
| 构建很慢、缓存不命中 | 构建文件里的指令顺序 | 高变动操作放在了前面 | 调整顺序,变动少的放前面 |
这张表可以直接当检查清单用。实际排查的时候我一般遵循一个原则:先确认问题发生在链路的哪一层,再深入那一层看细节。比如容器起不来,先看容器状态和退出码,这是最上层的信息;退出码指向资源问题,再去看配额;指向文件缺失,再去看挂载。这样一条线索往下走,比同时检查五六个地方高效得多。
6.2 几条从踩坑里换来的心得
第一条心得:不要用标签来判断镜像是否一致。标签是可以被覆盖的,同一个标签今天和明天可能指向不同内容。需要严格控制版本的时候,用内容摘要来固定镜像,部署脚本里也不要图省事只写标签。这个坑我吃过一次,两台机器拉同名标签的镜像,跑出来的行为不一样,查了很久才发现是镜像不是同一份。
第二条心得:容器里不要做"手工维护"。今天进去改个配置,明天进去装个工具,看着方便,实际上是在给未来埋雷。所有改动要么写进构建文件重新构建,要么通过挂载把配置从外部注入,要么用启动参数覆盖。这样容器才真正是可丢弃、可重建的,出问题时直接把旧的删掉起一个新的,而不是进去手忙脚乱地修。
第三条心得:先分清是容器问题还是应用问题。很多"容器起不来"最后发现是应用自己的配置错了,容器只是忠实地把错误暴露出来。判断方法很简单:把同样的启动命令在宿主机上直接跑一遍,如果也失败,那就不是容器的问题。这个动作只需要一分钟,却能省掉大量在容器层面瞎折腾的时间。
第四条心得:关注资源限制和实际用量的差距。设了内存上限之后,一定要在真实负载下观察一段时间的内存曲线,看看峰值离上限还有多少余量。有些应用启动时占用很低,跑一段时间之后缓存涨上来,就被限制干掉了,表现出来就是"跑了几小时突然没了",非常难查。
第五条心得:清理要养成习惯但也要谨慎。停止的容器、悬空镜像、构建缓存都会占用磁盘,定期清理是必要的。但清理命令的杀伤力不小,特别是带全部清理参数的那种,一不小心把还要用的镜像删了,重新拉取得等半天。我的做法是先列出要清理的对象看一眼,确认没问题再执行删除,而不是无脑上强度最高的那一条。
专题一到这里,架构的骨架基本立起来了:客户端负责表达意图,守护进程负责组织资源,运行时负责把进程真正跑起来,内核用隔离和配额把这些进程关进各自的笼子里。存储和网络是这套架构落地时接触面最广的两块,也是最容易出问题的地方,所以单独拎出来讲了一节。如果后面还要往下走,我建议的路径是先去看构建过程,把分层和缓存吃透,再去看多容器之间的网络关系,那时候你会发现所有新的概念其实都挂在这张架构图上,只是长出了新的枝叶。
我个人的习惯是,每换一台新机器部署环境,第一件事不是急着跑业务容器,而是先用docker version和docker info把这几行信息看一遍,确认存储驱动、资源控制驱动、内核版本都符合预期。这个动作看起来多余,但正是它帮我提前发现了不止一次的环境差异问题——有的机器存储驱动不是默认的那一种,有的资源控制还是旧版本,这些问题在业务跑起来之后才暴露,排查成本会高得多。花两分钟做这件事,比事后花两个小时定位要划算。