Skip to content
twinkling.top服务Pi-holeEN
板型Pi 4B 2GB and up
内核6.6.51+rpt-rpi-v8
空闲温度48 °C
内核架构arm64
参考机:Pi 4B 8GB / Bookworm 64-bit

部署配方 · DNS 与过滤

树莓派上的 Pi-hole:别把全家网络一起拦掉

Pi-hole 是新机架上第一个上线的容器,也是最容易出事的那个,因为全屋设备都要靠它解析。这套配置从 2024 年一直跑到现在:53 端口只在局域网监听,后台在 8081,上游指向本机的 Unbound 而不是公共解析器,容器重启策略也不会和开机时的网络栈打架。

pihole/pihole:2026.05.2 · 2026-08-14

Pi-hole rack plate: board stack and host ports
服务参数
容器镜像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暴露用途
:5353tcp+udp仅局域网DNS for the whole house, served by dnsmasq inside the container
:808180tcp仅局域网admin web UI and the /admin API the other services query

部署步骤

  1. 先建目录和密钥

    在第一次启动前把挂载目录建好。让 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
  2. 一次性建好共用的机架网络

    固定 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}}'
  3. 先起 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
  4. 从客户端验证,别在宿主机上验

    宿主机会走自己的 stub 解析器,在树莓派上测试几乎什么都说明不了。把一台笔记本指向树莓派,在那里看响应时间和拦截行为。

    run
    dig @192.168.10.20 ads.doubleclick.net +short
    dig @192.168.10.20 github.com +short
    # 被拦的域名返回 0.0.0.0,正常域名 20 毫秒内返回地址
  5. 顺手把 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 / 5335DNSPi-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 的遗留。

shell
docker exec pihole pihole-FTL --config webserver.api.password
# v6:打印 FTL 实际在用的哈希。输出为空,说明你的环境变量没被读进去。

开机时和网络抢跑

大家遇到的不是崩溃,而是抢跑:容器在树莓派拿到局域网地址之前就起来了,dnsmasq 绑了个空,全屋设备解析失败,直到你手动重启容器才恢复。

restart: unless-stopped 能兜住重启,兜不住顺序。真正解决它的是在宿主机上给 Docker 加一个 systemd drop-in,等网络就绪再启动 Docker——这一个改动同时也让其它容器不用在开机时抢地址。

shell
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——树莓派上回滚比升级麻烦得多。

compose.yaml
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 更快的核心带来的是浏览器上量不出来的差别。省下的钱去买固态盘。