要验证一份 DHCP 服务器的配置对不对,教科书式的做法是准备两台机器:一台装 dhcpd,一台当客户端。而我现在做这类验证,第一步是 docker run。过去一年多,一批服务器端的实验陆续搬进了容器:DHCP 按 MAC 绑定固定 IP 的四步协商、ZooKeeper 三节点选举、NFS 双机互挂、Nginx 跨版本热升级、OpenResty 源码编译排错、在 ARM 机器上模拟 amd64 安装老版本的 Elasticsearch。每个实验的过程与结论各有一篇专文(文末附链接),这篇文章只回答一个问题:容器当实验田,怎么玩才顺手、才可信、才收拾得干净。

先把边界划清楚:以下全部是一手实测,不是构想。每个实验的环境(镜像、版本、架构)都写在对应专文的开头,报错都来自实跑。本文是方法论提炼,不重复实验细节。

为什么把容器当实验田

实验需求全部来自真实工作,不是为测而测。CentOS 7 EOL 之后,老环境的迁移路径要有人先趟一遍;Rocky 9 上 dhcpd 的行为和 CentOS 7 不一样(不再需要手动指定监听网卡),这种版本差异光读文档不放心,要在新系统上亲手验过;Nginx 热升级的信号流要进生产手册,得先把成功和失败的两种形态都演练一遍。这些验证的共同点是:需要一个干净、可描述、可丢弃的环境。

虚拟机能给,但太贵。起一台虚拟机是分钟级的事,占的是 GB 级的盘,实验完还舍不得删——环境一旦贵重,人就倾向于在里面凑合,凑合着凑合着环境就脏了。容器把环境的单价打到几十秒和几百兆,实验的本质是”拿一个问题问一个环境”,环境越便宜,你越舍得问,也越舍得在问完之后把它扔掉。

还有一层不那么显眼的好处:容器逼你把环境写成文字。镜像 tag、软件版本、机器架构,这三样必须写进实验记录——半年后回看,能复现实验的不是那个还活着的容器,而是记录里的环境描述。我的每篇实测文章开头都是一段环境声明(比如 rockylinux:9 容器、dhcp-server 4.4.2、aarch64),这个格式就是被容器实验田逼出来的习惯。

玩法清单

同一块实验田,不同类型的实验有不同的玩法。下面六种,按我实际做过的顺序展开。

单容器闭环:服务端和客户端在同一个容器里对话

最省拓扑的玩法是让两种角色跑在同一个容器里。DHCP 验证就是这么做:dhcpd 和 dhclient 同处一个 Rocky 9 容器,客户端发起四步协商(DHCPDISCOVER → DHCPOFFER → DHCPREQUEST → DHCPACK),两端各自通过内核收包路径看到对方报文,服务端的 OFFER/ACK 日志和客户端的 bound to 192.168.254.150 能一一对上,MAC 绑定精确命中。

单容器闭环的价值不止省机器,它连负向验证都能闭环。绑定地址必须避开动态池 range,这条纪律我就是靠反例跑出来的:把 range 写成 150-200(包含了绑定地址 150),一台 MAC 不匹配 host 声明的客户端上来就拿走了 150——动态分配只看 range 里的空闲位,不查 host 表。等真正绑定的设备再连进来,冲突就埋下了。这个反例在同一个容器里几分钟复现完毕,比任何文字论证都有说服力。

多容器拓扑:自定义网络加容器名当主机名

需要真实多机形态的实验,用自定义网络。命令大体是先 docker network create --subnet 192.168.254.0/24 lab 造一张网,再把容器挨个挂上去,容器名即主机名。ZooKeeper 三节点集群就是这么搭的:三个 Rocky 9.3 容器挂同一张网,主机名 zk1/zk2/zk3,zoo.cfg 里的 server.1=zk1:2888:3888 原样可写,选举、跨节点读写(zk2 写入 zk1 读出)、把 follower 停掉再重启看它自动归队,全部实测通过。

拓扑类实验还附赠一种见识:分布式系统的启动脚本会骗人。三个节点首次启动,start 命令输出 FAILED TO START,但进程其实已经起来了——start 脚本只等几秒就去探测端口,而新集群第一次选举还没完成。以 status 为准,别信 start 的结束语。这类”工具的话不能全信”的经验,只有真把三个节点拉起来才会撞见。

双容器对拍:故意做错也是实验

NFS 验证用了两个容器:服务端 192.168.254.2,客户端 192.168.254.3,网段刻意规划成和常见内网一致,流程验证就是标准的”导出—查清单—挂载—跨机读写”。但这次实验真正的产出是三条报错——全部是故意做错复现出来的:把导出网段改错让客户端去挂,稳定复现 access denied by server;在最小化容器里不复位 rpcbind,复现 rpc.statd is not running;把导出参数退回默认 root_squash,复现客户端 root 写入 Permission denied。

这是容器实验田最被低估的玩法。真机上谁舍得故意把权限配错、把网段写歪?容器里做错的成本归零,而生产事故的报错原文只有亲手做过错才有印象。顺带还有一条环境发现:导出目录要落在支持 NFS 导出的文件系统上,容器里我是用 tmpfs 解决的——这种细节文章里不写,下一个人就要再撞一遍。

跨版本升级彩排:生产动作先在容器里走一遍

热升级这类”做错要出事故”的操作,最适合在容器里彩排。我在 nginx:1.28-alpine 容器里跑着旧版(1.28.3),把源码编译出来的新版二进制(1.30.4,--with-http_ssl_module)顶到原路径,USR2 拉起新 master、WINCH 让旧 worker 退场、QUIT 送走旧 master,整条信号流走完,再反向走一遍验证回退。这是真实的跨版本二进制替换,不是同版本复制,和生产升级一字不差。

更有价值的是错误形态的复现:master 若以相对路径启动,USR2 时它 exec 新二进制也用相对路径,找不到文件直接失败——表现为新旧 master 根本不并存、.oldbin pid 文件不出现,服务还在正常响应,不翻 error.log 根本看不出升级失败了。规避办法是永远用绝对路径启动、发 USR2 前确认 .oldbin 如期出现。”错误长什么样”这种见识,书上学不来,只能亲手做错一次。

源码编译链验证:编译污染交给容器

编译类实验天然适合容器——装一堆 -dev 依赖污染的正是实验田,用完连盆扔掉。OpenResty 那次排错就是在 debian:12 容器里做的:OpenResty 1.27.1.1 配 OpenSSL 3.3.1,--with-openssl 传了已安装目录导致 make 阶段报 ./config: not found,根因(该选项要的是源码树不是安装目录,构建规则写在 objs/Makefile 里)和两种解法的编译验证,全部在容器内完成。连”只加 -L 不加 rpath 时 openresty -V 会打出 built with X (running with Y) 的版本错位”这种细节,也是在容器里反复编译才咬实的。

跨架构模拟:补官方构建的缺口

最后一种玩法很多人没用过:架构模拟。手头是 ARM 机器,要装 Elasticsearch 7.6.1,而官方这个版本只发了 linux-x86_64 构建包——aarch64 的下载链接实测 404,官方 ARM 构建从 7.8 才开始有。Docker 的跨架构能力正好补上这个缺口:拉 linux/amd64 的 rockylinux:9 镜像,把整条安装流程原样跑通。

这次模拟还顺带收获了一批容器环境特有的报错:最小镜像里没有 ps 命令,ES 的后台启动直接报 command not found,补 procps-ng 解决;ES 拒绝 root 启动,老规矩建专用用户;安装目录放在 /root 下时 es 用户根本进不去(700 权限),挪到 /opt 才对。这些坑和 ES 本身无关,全是”精简容器环境”的属性——跑过一遍,它们就进了你的弹药库。

特权容器与 loop 盘:需要内核设施的时候

以上玩法普通容器都能胜任,但有一类实验要动内核设施:挂载文件系统、造 loop 设备当块盘用(比如给 NFS 导出造一块”真盘”)。这类需求就得给容器额外权限——--privileged 一把梭最省事,或者按需 --cap-add 加 --device 精确放行。这里的态度要明确:实验田不等于无禁区,权限能少给就少给,跑什么心里要有数。

环境坑与对策

玩法之外,有四个环境坑反复出现,值得单独记一笔。

其一,EOL 软件源 404。 CentOS 7 停服后官方源下线,yum 装包直接失败。对策是把 repo 文件里的 mirrorlist 注释掉,baseurl 指到 http://vault.centos.org,yum clean all 再装。vault 对 altarch(ARM)路径偶尔限流报 403,稍后重试或换 x86_64 机器。

其二,VPN fake-ip 污染容器 DNS。 宿主机挂着走 fake-ip 模式的代理时,容器里解析外部域名拿到的不是真实地址,拉镜像、装包都会莫名失败。对策是 docker run 时用 --add-host=域名:真实IP 把关键域名直接钉进 /etc/hosts,绕开被污染的解析链路。这一招对付的其实是同一类病:容器内的 DNS 依赖宿主机环境,宿主机的网络软件在容器眼里全是”天灾”。

其三,日志文件是个符号链接。 官方 nginx 镜像里的 access.log 和 error.log 是指向 stdout/stderr 的符号链接——在容器里找文件翻日志会扑空,日志统一走 docker logs 看。第一次撞上时会怀疑日志丢了,其实是镜像设计如此:容器世界的日志哲学是”打向标准流”,不是”落在文件里”。

其四,容器里没有 systemd。 大多数服务文档默认 systemctl,容器里这套不存在。实测里的等价手法:dhcpd 的监听验证用 dhcpd -d -f 前台调试模式完成(等价验证在文章里注明了,不冒充 systemctl 实跑);firewalld 依赖 systemd,干脆不跑,对应步骤如实标”未实跑”;遇到依赖 D-Bus 的组件,先手工 dbus-daemon --system 把总线拉起来再谈服务。这条还有个广义版:容器里”启动脚本说的话”要打折——前面 ZK 的 FAILED TO START 误报就是例证。

用完即弃的卫生纪律

实验田能长期高产,靠的不是技术是纪律。四条,都是踩过坑后固化下来的。

环境先写下来再动手。 镜像、版本、架构三件套写进实验记录的第一段。版本锚定不是洁癖:CentOS 7 装 DHCP 和 Rocky 9 装 DHCP 是两篇文章,jump 过版本写教程等于埋雷。

每步带验证点。 每执行一步,写清”执行后应看到什么”——exportfs -v 应列出导出规则、zkServer.sh status 应报出 Mode、nginx.pid.oldbin 应出现。没有验证点的步骤不算完成,这个习惯让每个实验天然带着可核对的证据链。

诚实标注等价与未跑。 容器里做不了的,直说。dhcpd 的 systemctl 验证是前台模式等价做的,文章里加了注;firewalld 没在容器里跑,来源写明是常规用法与包内服务定义。等价就是等价,未跑就是未跑——实验记录的价值全在”可信”二字上,一次注水,整篇作废。

容器不存状态。 实验结束,结论落在文章里,容器本身可删。凡是”这个环境我还要用”的念头出现,就要警惕:环境一旦变成资产,它就开始腐化。复现靠的是记录里的环境描述,不是那个容器的尸骸。

边界:什么不该进实验田

如实说,容器覆盖不了所有实验。三类场景它不行或只是近似:一是真实多机拓扑——跨机延迟、网络分区、机器级防火墙,容器网络模拟不了,得虚拟机或真机;二是物理硬件相关的事,容器里没有真实硬件;三是需要完整 systemd 会话的服务栈,容器里缺 init 系统的短板前面已经写过。我的原则是:**容器里得出的结论,迁移到真机前把关键步骤在目标环境再验一遍。**ZooKeeper 那篇里写明”物理机部署步骤与此完全一致,只是节点 IP 换成真实地址”——这句话本身就是迁移验证的承诺,而不是免责声明。

还没解决的问题

留两个。其一,多宿主机的分布式实验(真实网络分区、跨机房拓扑)实验田目前无解,容器网络再灵活也是一台宿主机里的局域网。其二,systemd 进容器当 init 的完整方案还没验证——真把 systemd 跑起来,firewalld 这类组件能否原样工作,值得单独做一轮实验再写一篇。

回扣开头的问题:怎么玩才顺手、才可信、才收拾得干净?顺手靠玩法清单按需取用,可信靠验证点和诚实标注,干净靠用完即弃。容器把”验证一个想法”的成本压到随手可做,这才是实验田真正的产出——不是那六篇文章,而是”下次遇到新问题,先开个容器试试”的条件反射。

系列实验专文