部署配方 · DNS 与过滤
树莓派上的 Pi-hole:别把全家网络一起拦掉
Pi-hole 是新机架上第一个上线的容器,也是最容易出事的那个,因为全屋设备都要靠它解析。这套配置从 2024 年一直跑到现在:53 端口只在局域网监听,后台在 8081,上游指向本机的 Unbound 而不是公共解析器,容器重启策略也不会和开机时的网络栈打架。
| 容器镜像 | pihole/pihole:2026.05.2 |
|---|---|
| 宿主端口 | :53/tcp+udp, :8081/tcp |
| 数据目录 | /srv/homelab/pihole/etc-pihole |
| 内存预留 | 240 MB |
| CPU 上限 | 0.35 vCPU (cpus: "0.35") |
| 更新节奏 | quarterly, pinned tag; read the release notes first — v6 moved every setting into FTLCONF_* env names |
| ARM 兼容 | arm64 native image; the 32-bit tag still exists but the FTL binary is faster on arm64 |
| 宿主 | 容器 | proto | 暴露 | 用途 |
|---|---|---|---|---|
| :53 | 53 | tcp+udp | 仅局域网 | DNS for the whole house, served by dnsmasq inside the container |
| :8081 | 80 | tcp | 仅局域网 | admin web UI and the /admin API the other services query |
部署步骤
先建目录和密钥
在第一次启动前把挂载目录建好。让 Docker 替你建的话目录会属于 root,之后 FTL 在某次升级后就写不了自己的数据库。
run sudo mkdir -p /srv/homelab/pihole/etc-pihole sudo chown -R 1000:1000 /srv/homelab/pihole echo "PIHOLE_PASSWORD=$(openssl rand -base64 18)" >> /srv/homelab/.env chmod 600 /srv/homelab/.env一次性建好共用的机架网络
固定 IP 依赖具名桥接网络,它能让上游地址在重启之间保持不变。子网只分配一次,之后所有容器复用。
run docker network create rack --subnet 172.20.0.0/24 --gateway 172.20.0.1 docker network inspect rack -f '{{range .IPAM.Config}}{{.Subnet}}{{end}}'先起 Unbound,再起 Pi-hole
上游必须在第一个查询到达前就在监听,否则每次重启后的头一分钟日志里全是 SERVFAIL。
run cd /srv/homelab/unbound && docker compose up -d docker exec unbound drill @127.0.0.1 -p 5335 example.com >/dev/null && echo "resolver ok" cd /srv/homelab/pihole && docker compose up -d从客户端验证,别在宿主机上验
宿主机会走自己的 stub 解析器,在树莓派上测试几乎什么都说明不了。把一台笔记本指向树莓派,在那里看响应时间和拦截行为。
run dig @192.168.10.20 ads.doubleclick.net +short dig @192.168.10.20 github.com +short # 被拦的域名返回 0.0.0.0,正常域名 20 毫秒内返回地址顺手把 DHCP 也接过来
Pi-hole 也能当 DHCP。接过来之后查询日志里显示的是主机名而不是裸 IP,这是「看得懂的日志」和「一屏数字」的区别。
run docker exec pihole pihole-FTL --config dhcp.active true docker exec pihole pihole-FTL --config dhcp.start 192.168.10.100 docker exec pihole pihole-FTL --config dhcp.end 192.168.10.240
真正要紧的只有 53 端口
Pi-hole 本质上就是一个带拦截列表的 DNS 服务器。仪表盘、查询日志、客户端分组,全都是在给这一个功能做报表。所以真正需要决定的只有两件事:它在哪里应答,以及它去问谁。
它只在局域网应答。容器把 53 发布在树莓派的局域网地址上,路由器上不做任何转发,并且显式设置了 FTLCONF_dns_listeningMode 而不是用默认值——一个谁问都答的解析器就是开放中继。
它的上游是容器网络里 172.20.0.3 上的 Unbound,而不是 8.8.8.8。这样查询日志留在本地,省掉每次查询绕公网一圈的延迟,外网断掉时家里也照样能解析。
端口分段要在第二个容器之前定好
在第二个容器出现之前就定好端口,别等撞了再改。这台机架用 53xx 给 DNS 和过滤、80xx 给网页前端,应用自带知名默认端口的就保留默认值。Pi-hole 的 DNS 只能占 53,网页后台退到 8081,把 80 留给反向代理。
| 分段 | 用途 | 本机架占用 |
|---|---|---|
| 53 / 5335 | DNS | Pi-hole 53、Unbound 5335 |
| 80xx | 网页前端 | 8081 Pi-hole、8082 Nextcloud、8096 Jellyfin |
| 90xx | 指标 | 9090 Prometheus、9091 Authelia |
| 上游默认 | 有知名端口的应用 | 3000 Grafana、3001 Uptime Kuma、2283 Immich |
v6 的环境变量改动
Pi-hole v6 用 FTLCONF_* 系列变量取代了旧的 setupVars.conf 和 WEBPASSWORD,改由 FTL 自己读取。照抄 v5 时代的 compose 文件,容器会正常启动、正常解析,却悄悄忽略你设的密码——界面于是在首次访问时让你重设一个,下一个容器重建后它又忘了。
这里真正用得上的三个是 FTLCONF_webserver_api_password、FTLCONF_dns_upstreams 和 FTLCONF_dns_listeningMode。文件里还叫 WEBPASSWORD 的,都是 v5 的遗留。
docker exec pihole pihole-FTL --config webserver.api.password
# v6:打印 FTL 实际在用的哈希。输出为空,说明你的环境变量没被读进去。开机时和网络抢跑
大家遇到的不是崩溃,而是抢跑:容器在树莓派拿到局域网地址之前就起来了,dnsmasq 绑了个空,全屋设备解析失败,直到你手动重启容器才恢复。
restart: unless-stopped 能兜住重启,兜不住顺序。真正解决它的是在宿主机上给 Docker 加一个 systemd drop-in,等网络就绪再启动 Docker——这一个改动同时也让其它容器不用在开机时抢地址。
sudo systemctl edit docker.service
# [Unit]
# After=network-online.target
# Wants=network-online.target
sudo systemctl daemon-reload && sudo systemctl restart docker客户端分组比拦截列表更管用
默认分组对全网设备一视同仁地拦截,大多数时候是对的,偶尔是错的:一台被拦了遥测就罢工的智能电视,一台需要访问某个被列表当成广告域名的办公笔记本。
这里用两个分组解决。一个「kids」分组套更严格的列表,只作用于平板;一个「no-block」分组什么都不启用,只绑定两个 MAC 地址。把设备加进第二个分组只要十秒,能省掉一次上门排障。
compose 文件
整份文件放在 /srv/homelab/pihole/compose.yaml。标签固定版本号,不用 latest——树莓派上回滚比升级麻烦得多。
services:
pihole:
image: pihole/pihole:2026.05.2
container_name: pihole
hostname: pihole
restart: unless-stopped
ports:
- "53:53/tcp"
- "53:53/udp"
- "8081:80/tcp"
environment:
TZ: Asia/Shanghai
FTLCONF_webserver_api_password: ${PIHOLE_PASSWORD}
FTLCONF_dns_upstreams: "172.20.0.3#5335"
FTLCONF_dns_listeningMode: "all"
FTLCONF_dns_domain: "lan"
volumes:
- /srv/homelab/pihole/etc-pihole:/etc/pihole
cap_add:
- NET_ADMIN
- SYS_NICE
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
networks:
rack:
ipv4_address: 172.20.0.2
networks:
rack:
name: rack
ipam:
config:
- subnet: 172.20.0.0/24加固清单
- 网页后台只在局域网 VLAN 内可达,路由器上从不转发 8081 端口
- FTLCONF_webserver_api_password 取自 /srv/homelab/.env(权限 600),不写在 compose 文件里
- DNS 只绑定内网网卡,避免服务器应答来自互联网的递归查询
- 反向代理上的管理接口只开放给两个真正需要的服务,且密码单独放在一个 Caddy 片段里
备份方案
每晚 03:10 用 rclone 把整个 /etc/pihole 目录同步到 NAS 的 SMB 共享,保留 14 份每日快照;每次重建广告列表后再单独留一份 gravity.db。树莓派坏掉时,把该目录恢复到新卡上,拦截列表、本地 DNS 记录和客户端分组都会跟着回来,不必重跑安装脚本。
上线后验证
- 对树莓派 dig,广告域名返回 0.0.0.0,普通域名返回真实地址
- 冷启动后容器日志里没有 'no upstream servers' 字样
- 跑满 24 小时后 docker stats 显示占用低于 300MB,此时 dnsmasq 的缓存已经稳定
- 后台在局域网内能打开,访客网络上的手机打不开
踩过的坑
- 连续写一年查询日志的 SD 卡会毫无征兆地坏掉。把 /etc/pihole 挪到 USB 固态盘上,至少也要给 json-file 日志驱动设 max-file: "3",别让 FTL 自己的日志把卡写满。
- 既在网页上设过 Pi-hole 密码、又留着 FTLCONF_webserver_api_password,下一次容器重建会静默退回环境变量里的那个值。保持单一来源:以 .env 为准。
- 拦掉智能电视的遥测域名,可能让它每次重启都丢失配对。这正是那个 no-block 分组存在的理由。
- 2026.05 的镜像以 1000 用户运行。挂载目录属于 root 时,容器能起来,但第一次跑 gravity 就会在日志里抛出权限错误。
硬件与选型问答
- 在 2GB 的板子上 Pi-hole 到底要多少内存?
- 默认列表加一万条缓存,常驻大约 250MB。网上常说的 100MB 来自 v5 时代跑在安静网络上的容器。2GB 的 4B 完全够用,但如果同一块板子上还要跑 Immich 或 Jellyfin,请按 240MB 来算 Pi-hole 的份额。
- 能不能把 Pi-hole 和 Unbound 放进同一个容器?
- 可以,有些镜像就是这么做的。分开更值那多出来的 90MB:重建拦截列表出问题时你可以只重启 Pi-hole 而不动解析器;将来想加第二个 Pi-hole 做冗余,也可以共用一个 Unbound。
- 做 DNS 用 Pi 5 还是 Pi 4?
- 用 Pi 4B,最好是 2GB 那款。DNS 是延迟问题不是吞吐问题——4B 解析一条缓存命中大约 1 毫秒,5 更快的核心带来的是浏览器上量不出来的差别。省下的钱去买固态盘。