几个月前整理家里那台吃灰的旧笔记本时,我冒出来一个念头:能不能把它变成一台“专用设备”,开机就能干活,而不是每次都要装系统、敲命令、改配置折腾半天?那段时间我一直在用 Cloudflare 的零信任平台管理远程设备,于是就有了 cloudflare-os 这个项目。
先说明一下,这名字听起来像 Cloudflare 官方发布的系统,其实并不是。它是我自己打包的一个定制 Linux 镜像,针对“把设备接入 Cloudflare、对外安全发布服务”这一个场景做了极致精简。镜像里几乎没有多余组件,开机后自动完成网络协商、自动接入云端安全通道、自动拉起应用容器,全程不需要接显示器手动配置。一旦某个硬件坏了,换一张盘刷上镜像、填一个注册令牌,十几分钟就能恢复出一台一模一样的新设备。
这篇文章会把整个项目的设计思路、组件选型、构建流程和踩坑记录完整写出来。如果你也玩 homelab、管理多台分散设备,或者要给门店、展会这种场景铺一批一次性设备,这套做法可以直接抄作业。
1. 先说清楚:cloudflare-os 到底是什么,解决什么问题
1.1 不是再造一个发行版,而是做一台“专用设备”
很多朋友一听到“自制 OS”,第一反应是你要从零写内核、搞包管理器。其实完全不是。cloudflare-os 的载体是 Alpine Linux,我不碰内核和系统底层,只做一件事:把系统裁剪成只能干一件事的设备。
打个比方,这就好比“游戏主机”和“组装电脑”的区别。组装电脑什么都能干,但你要装驱动、调设置、应付各种兼容问题;游戏主机开机就能玩,因为厂商把所有东西都按固定配置打包好了。cloudflare-os 走的就是游戏主机路线:系统层、运行时、应用编排、监控脚本全部固化在镜像里,设备只需要通电、插网线、完成一次令牌注册。
这个定位意味着我要主动放弃一些东西。它没有桌面环境,没有图形安装向导,甚至没有交互式配置菜单。需要改配置?直接改构建脚本或者改挂载的数据分区,然后重新生成镜像。这种“舍弃全能、换取可预测”的思路,才是这个项目真正的核心。
1.2 它解决的真实痛点:配置漂移与重建成本
自己手动装过几台 Linux 服务器的人应该都有这种经历:半年后回看那台机器,已经想不起来当时改了哪个配置文件、装过哪个依赖包。系统还活着,但你已经不敢动它,生怕一升级就挂。这就是典型的“配置漂移”。
cloudflare-os 想消灭的正是这个问题。所有设备都从同一张镜像开始,系统状态由镜像决定,镜像由构建脚本决定,脚本放在 Git 仓库里。任何一台设备坏了、中毒了、硬盘报废了,都不需要“修复”,直接擦掉重刷。这种思路在运维圈有个说法叫“凤凰服务器”——服务器不是宠物,不能靠细心呵护活着;它是牲口,坏了就换一头,反正长一样。
配合 Cloudflare 的托管 Tunnel,设备本身甚至不需要有公网 IP,也不需要路由器上开任何入站端口。它自己主动向外建立加密连接,注册到你的账号下。这样设备可以随便丢在门店角落、展会现场、亲戚家,只要网络能正常出网,就能接入统一管理面。
1.3 适合谁,不适合谁
如果你是下面这几类人,cloudflare-os 的思路会很有参考价值:
- 家里设备多,想统一入口访问 NAS、Home Assistant、相册服务的自托管玩家。
- 小团队运维,需要给客户现场部署一两台边缘设备,又不想每次上门都敲一天命令。
- 喜欢“基础设施即代码”概念的极简主义者,哪怕只有一台服务器也想把环境固化下来。
反过来,如果你需要在这台机器上跑复杂数据库、需要随时装各种实验性软件包、或者没有固定域名,那这个方案就不合适。它强调的是可重复、可丢弃、低维护,自由度和通用性必然让位。
2. 核心组件选型:为什么是 Alpine + cloudflared + Docker
2.1 底子选 Alpine,而不是 Debian 或 Ubuntu
我最早考虑的是 Debian mini,毕竟资料多、遇到问题好搜。但实际做下来发现 Alpine 优势太明显。
首先是体积。Alpine 的 minirootfs 只有不到 5MB,装完内核、Docker、cloudflared、时区数据、基础工具,整张镜像也就 1.5GB 左右。相比之下,Debian 光基础系统干净装一遍就得奔着 1GB 去了。镜像小带来的好处不只是省硬盘,而是刷机速度快、内存占用低、攻击面小。旧笔记本、工控机、树莓派这种资源紧张的环境,Alpine 跑起来非常舒服。
其次是包管理。apk比apt简单直接得多,没有复杂的依赖解析和交互式配置,非常适合在 chroot 和脚本里自动化。我可以在 Docker 容器里干净地构建 rootfs,一条命令一个包地往里面加东西,每一步都可复现。Alpine 的/etc/apk/repositories就是一个纯文本文件,加一行 Cloudflare 的软件源就能直接装 cloudflared,省去手动下载二进制、处理依赖的麻烦。
内核方面我用linux-lts而不是linux-edge。LTS 版本更新频率低、兼容性保守,适合这种长期通电不重启的设备。如果你明确知道设备只有有线网卡且是虚拟机,甚至可以连linux-firmware都不装,还能再省几百 MB。选型逻辑很简单:跑在边缘的设备,内核的“新”远不如“稳”重要。
2.2 出入通道:cloudflared 要选对形态
cloudflared 是 Cloudflare Tunnel 的客户端组件,用它官方提供的 Alpine 软件源安装即可。它有两种典型形态,差别很关键。
第一种是“命名 Tunnel”。你在本机执行cloudflared tunnel login,登录后生成证书文件,再创建一条命名通道,配置好关联域名。这种方式适合一台长期固定的服务器,配置灵活,但首次注册需要人坐在机器前面完成交互式登录,不满足我“开机自动接入”的需求。
第二种是“远程托管 Tunnel”。这种通道在 Cloudflare Zero Trust 面板上创建,创建完成后会生成一个 Installation Token,一串很长的字符串。在任何一台新设备上只要执行cloudflared service install <token>,cloudflared 就会变成系统服务,自动向 Cloudflare 边缘建立连接,随后你在面板里看到的配置(比如关联哪些域名、路由到哪些本地端口)会统一下发到这台设备。
cloudflare-os 用的就是第二种形态。令牌可以预置在镜像的/etc/cloudflared/token.txt里,首次开机由脚本自动注册。你不需要知道设备在内网的 IP,不需要 SSH 进去折腾,设备自己会“报到”。
还需要注意一个通信细节:cloudflared 默认通过 QUIC 协议连接 Cloudflare 边缘,需要放行出方向的 UDP 7844 端口。大多数家庭宽带和企业网络都允许出向连接,但如果你所在的网络只给 80/443 做了白名单,就会连不通。这种场景下需要在 cloudflared 配置里强制回退到 HTTP/2 443 端口,我会在第 5 章的排错表里给出处理方式。
2.3 应用层:用 Docker Compose 编排一套“边缘栈”
设备接入 Cloudflare 只是第一步,真正对外发布的是“服务”。这些服务我统一用 Docker Compose 编排,镜像内置了一份默认的 compose 文件,放在数据分区/data/apps/docker-compose.yml。
默认栈长这样:
| 容器 | 作用 | 备注 |
|---|---|---|
| caddy | 7 层统一入口,按域名路由 | 负责把请求分发给不同应用容器 |
| homepage | 状态首页 | 展示各服务健康状态,方便现场人员扫码查看 |
| watchtower | 定时拉取新镜像 | 可选,保证应用漏洞修复能及时跟上 |
为什么应用要容器化而不是直接装在系统里?因为一旦应用直接装进系统层,它就会污染镜像的纯净度,升级应用等于升级系统,出问题就要整体重刷。用容器隔离之后,系统层可以保持几个月不更新,而应用每天都可以安全升级。这也是“凤凰服务器”思想的一部分:应用是随时可替换的,系统是不可变的。
统一入口我选 Caddy 而不是手写 Nginx 配置,原因是它自动申请和续期 HTTPS 证书。当然这里真正的终端用户流量其实先到 Cloudflare 边缘,再到本地 Caddy,边缘已经处理过证书了。Caddy 在内网主要做域名路由和简单的安全头处理,配置极简。
2.4 系统分区与数据分区分离
cloudflare-os 的镜像分两个分区:一个很小的 FAT 启动分区和一个 ext4 根分区。构建时把数据目录、Docker 数据目录、Compose 工程全部放到/data下。这么设计就一个目的:重刷系统不丢数据。
举个例子,你在设备上跑了监控录像服务,录像文件存在/data/recordings。某天系统被自己搞坏了,你只需要重刷分区一和分区二,/data的独立分区保持不动,数据完好无损。如果数据盘和系统盘物理分离,甚至可以做到“拔掉系统盘,插上新的,数据盘继续用”。
很多人在这一步会纠结要不要把根分区挂成只读。我的建议是先别急。第一版先把系统可复现做好,也就是“系统分区虽然可写,但你永远不需要手动写它”。所有系统配置都在构建脚本里固化,数据都在/data。以后熟悉了 OpenRC 和 overlayfs,再把根切只读也不迟,思路是一致的。
3. 完整构建流程:从零到一张可启动的镜像
下面是我实际用的构建流程,全部在 Docker 容器里完成,保证每一张刷出去的镜像都一样。
3.1 准备构建环境
你需要一台能上网的 Linux 机器,安装了 Docker。构建过程中会用到wget、tar、losetup、parted、mkfs.ext4、mkfs.vfat,这些工具在大部分发行版上一条apt install或dnf install就能装齐。
先拉取 Alpine minirootfs 并解压到rootfs目录:
mkdir -p /data/os-build && cd /data/os-build wget -O minirootfs.tar.gz https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.3-x86_64.tar.gz mkdir -p rootfs tar -xzf minirootfs.tar.gz -C rootfs注意架构要和目标机器一致。我这边示例用的是 x86_64,如果你要做树莓派,把 URL 里换成aarch64即可。
3.2 在 chroot 里安装系统组件
把宿主机的/dev、/proc、/sys挂载进 chroot 环境,然后进入系统安装软件包:
mount --bind /dev rootfs/dev mount --bind /proc rootfs/proc mount --bind /sys rootfs/sys chroot rootfs /bin/ash -l进入 chroot 后,先配置软件源。Alpine 默认只开 main 源,再加 community 和 Cloudflare 官方源:
cat > /etc/apk/repositories <<'EOF' https://dl-cdn.alpinelinux.org/alpine/v3.20/main https://dl-cdn.alpinelinux.org/alpine/v3.20/community EOF wget -qO /etc/apk/keys/cloudflare.gpg https://pkg.cloudflare.com/cloudflare-main.pub echo 'https://pkg.cloudflare.com/alpine/v3.20/main' >> /etc/apk/repositories apk update apk add --no-cache \ linux-lts syslinux eudev openrc \ cloudflared docker docker-cli-compose \ tzdata curl htop这里我解释一下几个包的用途。syslinux是 BIOS 模式下给镜像装引导记录用的;eudev提供设备热插拔管理;openrc是 Alpine 的服务管理器;cloudflared和docker就是上面说的两大核心。tzdata是为了统一时区,我习惯把设备的/etc/timezone设置成上海时区。
安装完顺手关闭不必要的默认服务,只保留开机必需项:
rc-update add devfs sysinit rc-update add dmesg sysinit rc-update add mdev sysinit rc-update add docker default rc-update add cloudflared default rc-update add local defaultlocal服务是 Alpine 用来开机执行/etc/local.d/目录下脚本的,我后面要用来做首次注册。
3.3 编写首次开机自动注册脚本
在 chroot 环境里创建/etc/local.d/cloudflare-os.start:
#!/bin/sh LOG=/var/log/firstboot.log CF=/etc/cloudflared if [ -s "$CF/token.txt" ] && [ ! -e /var/lib/misc/cfos.bootstrap ]; then echo "[$(date)] waiting for network..." >> $LOG i=0 while ! ping -c1 -W1 one.one.one.one >/dev/null 2>&1; do i=$((i+1)) [ $i -ge 60 ] && break sleep 5 done cloudflared service install "$(cat $CF/token.txt)" >> $LOG 2>&1 touch /var/lib/misc/cfos.bootstrap echo "[$(date)] token registered" >> $LOG fi if [ -f "$CF/config.yml" ]; then rc-service cloudflared restart 2>/dev/null fi脚本逻辑很直白:先等待网络通,再读取预置的令牌完成注册,然后用一个标记文件保证后面重启不会重复注册。这个标记文件相当于“第一次启动初始化”的锁。
退出 chroot 前,记得把token.txt放到指定位置:
# 在构建机上执行,但内容要落在 rootfs 里 echo '粘贴你的Cloudflare Tunnel安装令牌' > /data/os-build/rootfs/etc/cloudflared/token.txt chmod 600 /data/os-build/rootfs/etc/cloudflared/token.txt如果你不想把令牌固化在镜像里,也可以用“通电等待输入”的方式,但那需要接键盘,违背了无人值守的初衷。我的经验是:令牌本身不是高价值秘密,需要配合 Cloudflare 账号权限才有意义,风险可控。
3.4 打包成镜像并写入引导
这部分最容易翻车,建议一步步来。先创建一块 1.5GB 的空白镜像文件,分区为“64MB 的 vfat 启动分区 + 剩余空间给根分区”:
cd /data/os-build dd if=/dev/zero of=cloudflare-os.img bs=1M count=1536 parted -s cloudflare-os.img mklabel msdos \ mkpart primary 1M 65M \ mkpart primary 65M 100%然后用 loop 设备挂载,格式化并拷贝 rootfs:
loop=$(losetup -fP --show cloudflare-os.img) mkfs.vfat -n CFOSBOOT "${loop}p1" mkfs.ext4 -L CFOSROOT "${loop}p2" mount "${loop}p2" /mnt tar -C rootfs -c . | tar -xC /mnt mount "${loop}p1" /mnt/boot # 写 fstab,让设备知道自己该挂载什么 cat > /mnt/etc/fstab <<'EOF' LABEL=CFOSBOOT /boot vfat defaults,noatime 0 2 LABEL=CFOSROOT / ext4 rw,noatime 0 1 EOF # 安装 BIOS 引导 chroot /mnt /bin/sh -c 'extlinux --install /boot' dd if=rootfs/usr/share/syslinux/mbr.bin of="${loop}" bs=440 count=1 conv=notrunc sync umount /mnt/boot umount /mnt losetup -d "$loop"如果你是给 UEFI 机器做镜像,上面这套要换成 grub 的 EFI 引导。思路一模一样,只是引导文件要放到/boot/EFI目录,并用efibootmgr或 grub-install 写 NVRAM 条目。新手建议先拿 BIOS 模式练手,老笔记本和虚拟机都能跑。
3.5 刷机与验证
镜像做好了,写入 U 盘或 SD 卡:
dd if=cloudflare-os.img of=/dev/sdX bs=4M conv=fsync status=progress然后把卡插进目标设备,接上电源、插好网线。等一两分钟,打开 Cloudflare Zero Trust 面板的 Tunnel 页面,正常情况下能看到这台设备已经在线。
本地验证的话,cloudflared 默认会暴露一个本地 metrics 端口。你可以先看一下设备的内网 IP,然后执行:
curl -s http://<设备IP>:2024/healthcheck返回正常就说明连接是通的。这一步也是最让人舒服的时刻:一台从未插过屏幕的设备,就这么自己入网了。
4. 落地场景与配置骨架
4.1 家用:把 NAS 和智能家居面板收进统一入口
在我家里,cloudflare-os 跑在一台旧笔记本上,后面接的是两块移动硬盘。全屋智能依赖的 Home Assistant、照片备份的 Nextcloud、偶尔开的离线下载器,全被我收在/data/apps的 Compose 栈里。
接入方式很统一。在 Tunnel 管理页添加 Public Hostname,例如:
ha.example.com->http://homeassistant:8123cloud.example.com->http://nextcloud:80
这里有个新手特别容易踩的坑:public hostname 里填的地址,是相对于运行 cloudflared 这台设备而言的。如果你写http://localhost:8123,它指的是这台边缘设备本机的 8123 端口,不是你的笔记本电脑。如果应用跑在 Docker 网络里,最好直接写容器名,比如http://homeassistant:8123。
域名解析在 Cloudflare DNS 里用 CNAME 指向通道域名即可,全程不需要路由器做端口转发。
4.2 小团队:用 Access 策略控制管理入口
设备接入了统一入口,接下来要管的就是“谁能登录”。cloudflare-os 默认不开放 SSH 的公网暴露,管理入口我习惯走 Cloudflare Access 的 SSH 短时证书方案。
大致链路是:团队成员在本地电脑安装了 cloudflared,然后用它连接ssh.example.com;这条连接先经过 Cloudflare 边缘,边缘再通过 Tunnel 把流量送进设备。Access 会校验登录人的邮箱域名、设备状态,甚至二次验证,验证通过才颁发短时证书,设备端只信任 Cloudflare 提供的 CA。
对设备本身的管理,我根本不设置 root 密码,而是把团队的公钥固化进镜像。这样每一台新设备刷出来,所有人都能直接凭公钥登录,但任何人的私钥都可以单独吊销,不用一台台机器改,体验非常接近云厂商的密钥对管理。
4.3 门店与展会:一次性边缘设备
这种场景最能体现 cloudflare-os 的价值。给门店部署设备时,我们不再带笔记本、显示器、键鼠,而是带几张刷好系统的 SD 卡。到现场插电、插网线,手机扫码打开状态页,确认设备在线、打印服务正常,收工。
因为设备在系统层没有需要维护的状态,所以甚至可以做到“设备不可用就换卡”。临时展会的设备用完了,拿回来清空/data数据分区重新上电,又是一台干净的机器。
还有一个细节:这些设备的自动更新能力很重要。边缘设备没人天天盯着,容器镜像的漏洞都得靠 watchtower 定时拉取。我在 Compose 配置里给 watchtower 加了--schedule参数,每周日凌晨自动更新一次,更新失败会自动回滚到上一版容器。
5. 踩坑记录:三个最糟心的问题
这章才是真正的干货。以下问题都是我在实际操作中踩过、并且花了不少时间去定位的。
5.1 令牌失效与无人值守重启
有一次一台门店设备离线了,面板上显示“已断开”。我到现场才发现,前一天在面板里重新生成过 Tunnel 令牌,旧令牌对设备已经失效,但设备的 firstboot 脚本判断cfos.bootstrap文件存在,就不再重新注册了。
根因是我把“首次初始化”和“持续正确性”混为了一谈。修复方式是在系统里加一个简单的校验逻辑:开机脚本比较/etc/cloudflared/token.txt与已安装令牌的哈希,如果不一致就重新执行cloudflared service install。这样即使令牌在面板里被轮换,设备下次重启后也会主动对齐。
如果你也想做类似的自动化,核心思路是“定时对齐声明状态”,而不是“只在首次跑一次”。
5.2 Docker 日志写满数据盘
Docker 默认的日志驱动是 json-file,且不限制日志文件大小。如果一个应用容器疯狂打日志,几天就能把数据分区写满。我第一台设备就死在 Contour 容器每天几个 GB 的日志上。
解决分两步。第一步在构建镜像时写死 Docker 守护进程配置:
cat > /mnt/etc/docker/daemon.json <<'EOF' { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } } EOF这样每个容器最多保留 30MB 日志,老日志自动滚动删除。第二步是在边缘设备上挂一个每周清理的 cron,执行:
docker system prune -f docker image prune -a --filter "until=168h" -f前者清退出容器和悬空镜像,后者清一周前的旧镜像。这套组合拳之后我再没遇到过“磁盘被日志塞满”的事故。
5.3 DNS 回环导致的通道反复断开
这个问题非常隐蔽,我花了一个晚上才定位。设备装好后的第一周,Tunnel 每隔几分钟断开重连一次,面板里看着像“抽搐”。排查到最后发现,我在同一台设备上加了 AdGuard Home 容器来做内网 DNS 过滤,而 cloudflared 解析自身连接目标时走的是系统/etc/resolv.conf,里面恰好指向了这台设备自己的内网地址。
结果就形成了死锁:cloudflared 要查 DNS 才能连边缘,而 DNS 服务本身又要依赖网络连通才能工作。破局的方法很朴素:宿主机的/etc/resolv.conf不要写 127.0.0.1,而是直接写外部公共 DNS,内网 DNS 服务则通过容器网络提供给局域网其他设备。这样系统组件依赖外部解析,应用层才用本地过滤。
这个案例给我的教训是:边缘设备上,基础通道和高层应用不要相互依赖对方的解析能力,分层必须干净。
5.4 常见问题速查表
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 设备不出现在 Tunnel 列表 | 出向 UDP 7844 被拦截 | 给 cloudflared 配置强制 HTTP/2 回退到 443 端口 |
| 首次开机后令牌没有生效 | token.txt 权限或路径不对 | 确认文件在/etc/cloudflared/token.txt且权限 600 |
| 面板在线但站点 502 | Public Hostname 里写的服务名不对 | 检查是否用了容器名而不是 localhost,确认容器实际端口 |
| Docker 容器频繁退出 | 数据盘写满 | 清理/data下的日志与悬空镜像,检查 daemon.json 日志上限 |
| 刷机后旧配置还在 | 没有区分系统分区与数据分区 | 重刷前先备份或清空/data,确保 fstab 里数据盘挂载正确 |
这些问题的共同点在于:设备是无人值守的,出问题的第一时间没人坐在前面看屏幕。所以排查时一定要留好日志出口。我的做法是把/var/log/firstboot.log和 cloudflared 日志同时写到/data/logs,配合接入渠道,保证设备离线前有迹可循。
最后说点个人体会
cloudflare-os 这个项目做下来,最大体会是“约束产生自由”。表面上看,我把一台通用 Linux 机器变成了一张固定镜像,失去了很多灵活性;但换来的是每一台设备的行为都可预期、可重复、可放弃。我不用再担心某台机器被谁临时改过配置,因为任何改动都不会持久,重启即复原。
如果让我给后来者一个建议,就是别一上来就追求完美。第一版哪怕只是“Alpine + 一条命令装 cloudflared + 手工跑 docker compose”都够了,先让一台设备能够自动注册、自动恢复,再去考虑只读根目录、自动更新、公钥管理这些进阶项。这个项目最后真正值钱的不是那 1.5GB 镜像,而是你把“设备如何被定义”写成了代码这件事本身。
另外提醒一句:镜像里尽量不要放持久化的密钥和凭据,令牌可以放,因为它是“介绍信”而非“钥匙”;真正的密钥交给 Cloudflare 边缘和你的身份体系去管理,设备本身只负责“被替换”。