忘掉Docker,用Linux内核命令亲手搭建一个极简容器

发布时间:2026/9/9 3:17:06
忘掉Docker,用Linux内核命令亲手搭建一个极简容器 你是不是也看过那种让人头皮发麻的技术文章满屏的术语、复杂的架构图最后配一句“底层原理极其深奥”我当年刚开始折腾容器技术的时候也被 Docker 那一套东西唬得不轻。什么镜像分层、运行时、网络模型听起来每一层都高不可攀。直到后来我在一台干净的 Linux 虚拟机上亲手把 Docker 那一层外壳剥掉用最原始的内核命令“拼”出了一个能跑ps、能改主机名、能限制 CPU 的微型容器才恍然大悟这项被无数人追捧的“高深技术”核心思想简单到令人发指——就两个词隔离和限额。这篇文章我不打算讲 Docker 怎么用那些文档到处都是。我想带你做一件更有意思的事忘掉 Docker回到 Linux 内核本身用几条命令手动搭建一个极简容器。这个过程会彻底拆掉你对容器技术的神秘感。当你亲眼看到“容器”只是几个内核特性拼装出来的产品时以后再遇到任何“高深”的新技术你都会多一个心眼它的底子是不是也是个简单的想法1. 先别急着敲命令容器的本质是两个朴素需求在动手之前我觉得有必要先聊聊“为什么”。很多人学技术有个习惯上来就复制粘贴命令跑通了就觉得自己会了。结果过两周全忘了换个环境又抓瞎。原因很简单你只记住了“怎么做”没理解“为什么这么做”。1.1 从“搬家和合租”理解容器到底在解决什么问题想象一个场景你在一栋写字楼里租了一间办公室。你需要的其实不是整栋楼而是楼里的一个独立空间。这个空间有墙别人看不见你在干什么有门锁别人进不来有独立的电表你用多少电自己付钱。至于楼里的电梯、消防通道、水电主管道你是和整栋楼共享的。容器就是干这件事的。一台 Linux 服务器就像那栋写字楼上面跑着很多应用这些应用就像一个个租户。问题是如果没有墙和门锁租户 A 不小心把走廊堆满垃圾租户 B 在房间里放了个大功率电器导致整栋楼跳闸租户 C 甚至能跑到别人房间里翻东西——这在服务器上就叫进程互相干扰和安全问题。容器技术要解决的就是给每个“租户”提供两样东西隔离让每个应用以为自己独占了一台机器看不到也碰不到别人的“房间”。限额就算某个应用发疯了也只能吃掉自己那份资源不能拖垮整栋楼。而这两个需求Linux 内核早就准备好了对应的原生产品namespace命名空间负责隔离cgroup控制组负责限额。Docker 从头到尾没有发明任何新的内核机制它只是把这些老掉牙的机制包装成了一个好用的工具。1.2 为什么“简单思想”会显得高深三层包装的迷障既然核心思想这么简单为什么我们平时看到的容器技术那么复杂因为 Docker 在“简单思想”外面包了三层糖衣。第一层是客户端工具。你敲的docker run、docker build、docker compose这些都是给人类用的交互界面它们本身不创建容器。第二层是守护进程和运行时。Docker 的 daemon 接收你的指令再调用containerd、runc这类底层的容器运行时去真正操作内核。第三层是镜像和仓库体系。为了让你能方便地分发和复用应用环境它又搞出了镜像分层、联合文件系统、仓库 Registry 这一大套东西。如果你从第一层开始学当然会觉得高深莫测因为你在和一大堆“包装纸”打交道。但如果你直接从最里层开始先搞懂 namespace 和 cgroup你再看那三层包装就会觉得它们只是基于这两个内核特性做的工程化扩展。这也是我写这篇文章的初衷帮你直接扒到最里层建立对容器技术的“第一性原理”认知。2. 第一个核心机制namespace给进程一场“单机幻觉”namespace 这个词听起来学术但它干的事特别直白让一个进程及其子进程只能看到某个维度上的“世界一角”。Linux 里有很多种 namespace每一种都负责隔离一个维度比如主机名、进程号、挂载点、网络栈等等。为了让你有体感我们直接上手操作。下面所有命令都在一台普通的 Linux 虚拟机里执行就行建议你用普通用户 sudo别直接用 root 在物理机上折腾。2.1 先动手用 unshare 体验“换了个新机器”unshare是 util-linux 包里自带的一个命令它的作用就是创建一个新的 namespace让指定命令在那个新世界里运行。我们先从最简单的UTS namespace开始它隔离的是主机名。打开终端先看一下当前主机名hostname然后执行sudo unshare --uts /bin/bash这条命令会创建一个新的 UTS namespace并在里面启动一个 bash。现在你在新的 bash 里试着改一下主机名hostname my-container hostname神奇的事情发生了在这个 bash 里主机名变成了my-container。这时候你再开一个终端窗口在宿主机的普通 shell 里执行hostname会发现宿主机的主机名完全没变。这就是 UTS namespace 的作用它把“主机名”这个系统全局属性变成了 namespace 内部的局部属性。你在里面怎么改都影响不到外面。是不是特别像你在自己房间里重新贴了个门牌号外面走廊的指示牌纹丝不动这个思想贯穿所有 namespace 类型把全局资源包装成局部视图让进程产生“我独占了一台机器”的幻觉。2.2 PID namespace为什么进程号会从 1 开始UTS namespace 只是热身真正让人觉得“有点东西”的是PID namespace它隔离的是进程号。退出刚才的 bash重新执行sudo unshare --pid --fork --mount-proc /bin/bash注意我加了两个参数--fork是因为有些程序需要 fork 之后才能正确显示新的 PID namespace--mount-proc会自动挂载一个新的/proc文件系统让ps命令只显示当前 namespace 里的进程。现在在这个新 bash 里执行ps aux你会看到进程列表里出现了两个进程一个是PID 1的bash当前 shell另一个是PID 2的ps命令。那 PID 1 去哪了系统初始化进程去哪了你之前开着的那些 ssh、nginx 进程去哪了它们还在宿主机上正常运行但在这个新的 namespace 里进程号被重新编号了。当前 shell 变成了 PID 1就好像它是这台“新机器”的初始化进程一样。宿主机上的其他进程在这个 namespace 里是不可见的就像你在一栋楼里打开监控系统只能看到自己这个房间的画面其他房间的监控你是无权访问的。这里有一个很关键的细节我们使用了--mount-proc否则你进入新的 PID namespace 后执行ps看到的仍然是宿主机的进程列表。因为/proc是一个动态生成的虚拟文件系统它会反映它所属 mount namespace 的视图。如果不重新挂载它就会暴露宿主机的信息。这一点在排查容器问题时特别容易踩坑后面我会再提到。2.3 mount namespace让“挂载”成为私事既然提到了/proc我们就顺理成章地聊到mount namespace。它隔离的是文件系统的挂载点。正常情况下你在宿主机上mount一个 U 盘或者磁盘分区所有进程都能看到这个挂载。但有了 mount namespace每个 namespace 可以拥有自己的挂载点列表。你在里面挂载东西外面的世界完全无感。这个能力是容器文件系统隔离的基础。一个容器可以把宿主机的某个目录替换成自己的根文件系统然后在这个“新根”里面挂载各种东西而不会影响宿主机。我举个最常见的例子执行sudo unshare --mount /bin/bash mount -t tmpfs none /mnt df -h /mnt这里我们把一个临时文件系统tmpfs挂载到了/mnt。在这个新 bash 里/mnt是一个全新的内存盘。但在宿主机的终端里执行df -h /mnt你会发现宿主机上的/mnt还是老样子什么都没变。这就是为什么容器里可以有自己独立的/etc/hosts、/etc/resolv.conf文件而不污染宿主机。容器启动时运行时系统会在 mount namespace 里做各种挂载操作这些操作被 namespace 这堵墙挡在了里面。2.4 六类 namespace 速查表它们各管哪一摊事我们刚才实际接触了 UTS、PID、mount 三种 namespace。Linux 里还有另外几种为了让你在交流时不露怯我把它们整理成一张速查表。namespace 类型隔离的内容一句话理解UTS主机名、域名让进程以为自己在另一台机器上PID进程号让进程列表从1重新编号看不到其他进程Mount文件系统挂载点让挂载操作变成 namespace 私有Network网络栈网卡、IP、路由、防火墙规则让进程以为有自己独立的网卡和网络配置IPC进程间通信资源消息队列、共享内存隔离 System V IPC 和 POSIX 消息队列User用户 ID 和用户组 ID让进程以为自己有独立的用户体系甚至能在里面当 root这里我想特别强调一个容易误解的点namespace 提供的是视角隔离不是安全隔离。它好比是给你一个只能看到单间内部的摄像头但不代表这个房间就是一堵密不透风的墙。在现代容器实践中安全隔离还需要配合其他内核机制比如 seccomp、AppArmor、SELinux 等。所以如果你在面试里被问到“容器安全吗”正确的回答思路应该是容器默认有隔离性但它不是一台真正的虚拟机安全边界要弱得多。这一点我在后面的“常见问题”部分还会展开讲。3. 第二个核心机制cgroup给进程一套“资源配额”namespace 解决了“看不见”的问题但光“看不见”还不够。试想一个场景一栋写字楼里每个房间都有墙互相看不见但所有房间共用一个总电闸。某个房间用了个超大功率设备整栋楼跳闸了其他房间全黑。在服务器上这就对应着一个进程吃满 CPU、占光内存导致其他服务卡死甚至宕机。cgroupControl Groups控制组就是用来解决这个问题的。它的思想更朴素给进程分组然后限制每个组的资源用量。3.1 没有配额时的“吵闹邻居”问题在云服务时代“吵闹邻居”是个很经典的运维头痛问题。想象你在一个多租户的物理机上跑业务隔壁租户的业务突然流量暴涨疯狂消耗 CPU。如果没有任何配额机制你这边业务的响应时间就会急剧恶化明明自己什么都没做错却要跟着遭殃。cgroup 就是为了避免这种“互相拖累”而生的。它允许管理员为每个进程组设置 CPU、内存、磁盘 I/O、网络带宽等资源的上限。设置之后就算某个应用写了个死循环疯狂吃 CPU它最多也只能吃到分配给你的那部分配额无法越雷池一步。3.2 手动创建 cgroup 并限制 CPU我们先来看看 cgroup 在 Linux 里长什么样。在较新的 Linux 系统几乎所有主流的发行版上cgroup v2 是默认的管理方式。它的入口在/sys/fs/cgroup目录。我们先看一下已有的控制组ls /sys/fs/cgroup你会看到一堆目录这就是系统自动创建的各种控制组。现在我们自己创建一个新的控制组给它起名叫demosudo mkdir /sys/fs/cgroup/demo创建完成后你再看一下demo目录里面会自动生成一堆接口文件其中最关键的是cpu.max、memory.max、pids.max这几个。它们就是用来设置配额的地方。我们先把demo组内的 CPU 配额限制为 0.5 个核心也就是每 100 毫秒周期内最多使用 50 毫秒echo 50000 100000 | sudo tee /sys/fs/cgroup/demo/cpu.maxcpu.max文件里第一个数字是配额quota第二个数字是周期period单位是微秒。50000 100000就代表在 100 毫秒的周期里这个组的进程最多只能跑 50 毫秒的 CPU 时间也就是半核。接着我们来测试一下。先启动一个烧 CPU 的进程放到后台sha256sum /dev/zero 这个命令会疯狂计算占满一个 CPU 核心。用echo $!记下它的 PID然后把这个 PID 写入demo组的cgroup.procs文件把这个进程“扔进”我们刚建的控制组echo PID | sudo tee /sys/fs/cgroup/demo/cgroup.procs如果你想直观看到效果可以用top命令观察。你会发现刚才还疯狂占用 CPU 的sha256sum进程CPU 使用率被死死压在 50% 左右。这就是 cgroup 的威力不需要修改你的代码不需要重启进程只需要在系统层面做一个“配额”设置资源就能被精准控制。不同系统的 cgroup 版本不一样。ip 老一点的操作系统还在用 cgroup v1它的 CPU 限制接口是cpu.cfs_quota_us和cpu.cfs_period_us用法类似都是“配额/周期”两个值。如果你用的是 CentOS 7 或者 Ubuntu 16 这类老系统记得去对应路径找这两个文件。3.3 限制内存和进程数除了 CPU 还要管住别的CPU 配额只是第一步一个不守规矩的进程还会吃内存、疯狂创建子进程。cgroup 对这两种情况也有天然的应对方案。限制内存很简单写入memory.max文件即可。我们限制demo组最多使用 256MB 内存echo 268435456 | sudo tee /sys/fs/cgroup/demo/memory.max这个文件的单位是字节所以 256MB 换算过来就是268435456。当组内所有进程使用的内存加起来超过这个数时内核就会触发 OOMOut Of Memory机制优先杀掉组里最耗内存的进程。限制进程数量也很直接写入pids.maxecho 64 | sudo tee /sys/fs/cgroup/demo/pids.max这个限制指的是组内同时存在的进程/线程总数。如果某个应用因为 bug 陷入 fork 炸弹式的疯狂创建进程当超过 64 个时后续的创建操作直接失败。这也是很多容器平台设置“最大进程数”的底层原理。3.4 namespace 和 cgroup 是两码事但它们天生一对到这里你已经掌握了两套核心机制。我把它们放在一起做个对比机制解决什么问题生活类比namespace进程能看到什么房间的墙和门锁决定视野和访问权限cgroup进程能用多少资源房间的电表和分水表决定资源配额和上限两者是正交的可以独立使用也可以组合使用。实际创建容器的过程中Docker 会先创建一堆 namespace让进程以为自己在“新机器”上再把这个进程放进一个专属的 cgroup确保它的资源使用尽在掌控。这个组合思路想通了你就能理解为什么容器那么轻量了。它不像虚拟机那样需要模拟一个完整的硬件设备、运行一个独立的内核它只是把宿主机的内核资源做了一层“逻辑切分”。容器里的进程本质上还是宿主机内核管理的进程只是它的视野变小了、资源额度被限制了而已。4. 把两个思想合起来手搓一个“极简容器”现在我们把前面讲的两块积木拼到一起动手搓一个能跑命令的微型容器。这个实验没有用到一行 Docker 命令但做完之后你会发现你对“容器”的理解会比很多只会docker run的人都深。4.1 准备一个极简根文件系统容器一个重要特性是“根文件系统独立”。也就是说容器里面的/目录下的文件和宿主机的/不共享。这一步靠 mount namespace 实现但我们还需要一个“新根”总不能凭空变出一个操作系统吧。这里我们引入一个非常常用的工具BusyBox。它是一个精简版的 UNIX 工具集把上百个常用命令ls、cat、sh、ps等压缩到一两兆字节里特别适合用来搭建临时根文件系统。先创建一个目录然后下载 BusyBox 并解压出最小根文件系统骨架mkdir -p /tmp/mini-root cd /tmp/mini-root # 这里你需要先下载 busybox 静态编译版本放到当前目录 mkdir -p bin sbin etc proc sys dev cp /path/to/busybox ./bin/busybox ./bin/busybox --install ./bin这一步执行完之后/tmp/mini-root/bin下就会出现sh、ls、ps等命令的软链接它们都指向同一个busybox二进制文件。这个“单文件集成”的设计就是 BusyBox 体积小到离谱的秘诀。4.2 用 unshare 组合隔离、限额和换根接着我们把 namespace、cgroup、chroot 三件事一次性做完sudo unshare --uts --pid --mount --fork --mount-proc \ --root/tmp/mini-root \ /bin/sh这条命令做了四件大事--uts新主机名隔离。--pid --fork --mount-proc新进程号隔离并挂载新的/proc。--mount新挂载点隔离后续挂载不会影响宿主机。--root/tmp/mini-root把根目录切换到 BusyBox 的根文件系统这就是容器的“换根”。执行完你会进入一个 Shell 提示符看起来已经和当前 Linux 完全不一样了。你可以试试/pwd /bin/hostname my-container ps aux这个时候你看到的是一个 PID 从 1 开始、主机名随便改、根目录只剩 BusyBox 工具的“新系统”。在这个环境里如果你不小心删了什么文件放心那只会影响/tmp/mini-root下面的文件对宿主机的根目录毫发无损。再配合 cgroup 给这个 shell 加资源限制# 先另开一个终端找到上面 shell 的 PID echo PID | sudo tee /sys/fs/cgroup/demo/cgroup.procs现在你手里这个 shell就是一个拥有独立视图、独立根目录、受限资源的“容器”了。整个过程只用了内核提供的机制没有任何魔法。严格来讲这个实验和 Docker 还有差距生产级容器会用到pivot_root换根、更精细的 namespace 配置、网络桥接等。但核心骨架你已经亲手搭出来了隔离视图 限制资源 切换根目录。这三句话就是容器的本质。4.3 为什么 Docker 镜像能分层复用简单的“增量账”聊完容器的运行原理我们顺手把镜像的谜底也揭开它同样藏着一个极其简单的思想。Docker 镜像之所以看起来“高级”是因为它支持分层。你写一个 DockerfileFROM ubuntu:22.04然后RUN apt install xxx最后生成一个镜像。这个镜像不是一个大而全的实体文件而是一系列“叠加层”。每一层记录的是“相对于上一层的差异”而不是完整的操作系统文件。这就好比整理房间你不需要每次搬家都把所有东西打包一份你只要保持一个“基础家具清单”然后在上面追加每次新增的物品记录。同一台宿主机上如果 100 个容器都基于ubuntu:22.04那么这 100 个容器共享同一个底层 ubuntu层只有各自新增的那一层是独立存储的。这就是为什么你拉一个 600MB 的镜像经常瞬间完成——因为公共层早就被其他镜像下载过了而且磁盘上只需要存一份。这个设计的思想基础其实就是“公共部分只存一次私有部分增量叠加”和备份软件里的增量备份逻辑如出一辙。一旦你看穿它再看到表格里的“镜像体积”时你就知道那只是“新增层”的大小不是完整操作系统的体积。5. 实操过程中容易踩的坑与排查思路按我的经验你自己动手敲命令做实验大概率会遇到下面这些问题。我把它们集中列出来省得你到处翻资料。5.1 unshare: unshare failed: Operation not permitted这是最常遇到的一个报错。原因也很简单创建某些 namespace尤其是 PID、Network、User namespace需要特权。如果你没有用sudo或者你所在的容器环境本身没有开启相应权限内核就会拒绝你的请求。排查思路如下先用sudo执行。确认内核支持执行grep -E namespaces|pid_ns|user_ns /boot/config-$(uname -r)看有没有CONFIG_PID_NSy之类的配置项。如果你本身就在一个 Docker 容器里做这个实验还需要给容器加--privileged参数否则容器内无法再创建新的 namespace。5.2 cgroup v1 和 v2 参数不一样不同版本的 Linux 发行版cgroup 接口差异很大。Ubuntu 22.04 之后基本全面使用 v2文件叫cpu.max、memory.max。CentOS 7、老版本的 Ubuntu 还是 v1文件叫cpu.cfs_quota_us、cpu.cfs_period_us、memory.limit_in_bytes。快速判断当前系统用的是 v1 还是 v2stat -fc %T /sys/fs/cgroup输出如果是cgroup2fs那就是 v2如果是tmpfs大概率是 v1。知道了版本再去查对应的接口文件就能少走很多弯路。5.3 容器里执行 ps 还是看到宿主机进程这个坑几乎每个新手都会踩。你进入新的 PID namespace 后执行ps aux结果发现进程列表和宿主机一模一样。原因我已经在前面提到过ps命令读取的是/proc文件系统而/proc是动态生成的内容取决于它所在的 mount namespace 视图。你如果没有为新的 PID namespace 挂载新的/proc那么它看到的就是宿主机的/proc。解决办法在执行unshare时加上--mount-proc或者手动执行mount -t proc proc /proc。记住一个原则新的 PID namespace 必须搭配新的 proc 挂载否则隔离视图就是假的。5.4 容器里执行 ping 或 ifconfig 找不到命令BusyBox 的极简根文件系统里默认可能没有网络相关的工具或者你没有创建 Network namespace所以网络视图还是和宿主机共享的。如果你只是想快速验证“网络隔离”可以在unshare里加上--net。你会看到容器里没有网卡、没有 IP因为新的 Network namespace 默认只有一块虚拟的回环设备lo。真正的容器网络是由 Docker 这样的运行时帮你创建虚拟网桥、veth 对等设备来打通的。5.5 所有实验都要在安全可控的环境下进行这一点我必须强调。namespace 虽然能隔离视图但它不是万能的“保险箱”。没有配置额外安全机制的情况下容器内如果存在内核漏洞是有可能影响宿主机的。所以强烈建议你所有的实验都在虚拟机、或临时云主机上进行不要在没有任何隔离的物理机上乱试。顺便说一句如果你想用这个思路在真实项目里做点轻量级的“环境隔离”工具利用unshare cgroup 是一个性价比很高的方案。但请务必读透内核文档把安全边界搞清楚再上生产。6. 我在实际折腾中的一点体会踩过这么多坑之后我最大的体会是技术圈里所谓“高深”的东西大部分是工程化包装带来的表象。如果你能扒开包装找到它要解决的那个最原始的问题技术本身往往简单得惊人。容器技术就是最典型的例子。它的核心不是 Docker、不是 Kubernetes、不是那些花哨的编排概念而是 Linux 内核里两个朴素的机制一个管“你能看到什么”一个管“你能用多少”。Docker 做的事情充其量是给这两个机制穿了一件更合身的衣服然后设计了完整的分发体系。所以我建议你以后遇到任何新技术先别急着抄文档。试着问自己三个问题它解决的核心问题是什么它用了哪些操作系统或硬件层面的基础能力这些东西和我已知的哪些概念是相通的想通这三个问题你再看那些被包装得高深莫测的架构文章就会有一种“不过如此”的通透感。最后分享一个小技巧遇到一个复杂系统去找它的 Minimal Reproduction也就是最小复现路径。像我们这次用一条unshare命令复现了容器的核心机制这种“最小复现”的思维方式对理解任何复杂技术都比看十篇架构分析管用。