树莓派 4B 搭建 Minecraft 基岩版服务器:从 Docker、Box64、MCSManager、FRP 到 iPad HTTPS 兼容与证书自动续签
环境:Raspberry Pi 4B(8 GB RAM)/ARM64 Linux/Docker/1Panel/MCSManager/Box64/Minecraft Bedrock Dedicated Server(BDS)/FRP
记录时间:2026 年 10 月
最终结果(2026-10-10):Minecraft BDS 1.26.52.3 在树莓派上稳定运行;公网mc.test.com:19134同时支持 HTTP 和 HTTPS 信令;iPad 已实际成功加入,原有 Android 连接路径保留;证书使用 Docker Certbot + cron 自动续签。文章末尾附最终完整网络拓扑图。阅读说明:本文合并了最初搭建服务器的完整踩坑记录和之后的 iPad 公网连接排障。原文早期的端口、目录示例属于当时的阶段性配置,并不等于 10 月 10 日的最终线上状态;后文明确标出已发生的调整。
一、需求与最终架构
这台树莓派 4B 原本就承担着多个常驻服务,包括 Docker、1Panel 和数据库等。此前已经有一个独立的 Minecraft Bedrock 容器 mc-bedrock。这次想再搭建一个由 MCSManager 网页管理的独立 Minecraft 基岩版服务器,便于以后启动、停止和查看控制台,并且不影响旧世界。
最初先解决 ARM64/Box64 运行、MCSManager 实例目录、Docker TCP/UDP 映射以及 FRP 公网连通。后来 iPad 出现「局域网能进、公网不能进」的问题,最终又在 Google Cloud VPS 上加入 OpenResty 的 TLS 兼容入口。
当前最终公网入口为 mc.test.com:19134(A 记录指向 公网VPS服务器IP)。与最初方案相比,最关键的变化是:
| 环节 | 初期阶段 | 2026-10-10 最终状态 |
|---|---|---|
公网 TCP 19134 | FRP frps 直接监听 | OpenResty stream 监听,识别 TLS ClientHello |
| FRP 公网 TCP | 19134 | 19135(仅 Minecraft 这一条 TCP 代理变更) |
| 树莓派 TCP | 127.0.0.1:19134 | 保持 127.0.0.1:19134,Docker 转发至 BDS 19132/TCP |
| NetherNet UDP | 初期试过 19134/UDP | 19140–19155/UDP,FRP 双端按范围一一转发 |
| 证书 | 无 | Let's Encrypt,Docker Certbot + HTTP-01 + cron |
| 客户端 | Android 已成功 | iPad 公网实际成功;HTTP/HTTPS 接口均通过验证 |
BDS 的 server.properties 最终相关配置:
server-port=19132
server-portv6=19133
transport=nethernet
server-udp-ports=公网VPS服务器IP:19140-19155:19140-19155以上为本次部署的真实配置要点。UDP 游戏数据不走 OpenResty,OpenResty 只负责 TCP 信令入口。完整数据流、端口及容器层次放在文章最后的 Mermaid 拓扑图中。
二、先保护旧服务器和世界数据
旧实例的情况:
容器名:mc-bedrock
镜像:itzg/minecraft-bedrock-server
数据目录:/home/mcadmin/mc_data
端口:宿主机 UDP 19132 → 容器 UDP 19132新实例使用另一套数据目录和端口(下面是最初搭建时的记录;最终实际持久化实例目录以第十四节为准):
容器名:mc-bedrock-mcsm
镜像:mc-bedrock-mcsm:local
数据目录:/home/mcadmin/docker-vols/mc_data
端口:初期宿主机 TCP/UDP 19134 → 容器 TCP/UDP 19132;后期 UDP 改为 19140–19155不要把新实例的 /data 指向旧实例的数据目录,否则可能造成世界文件同时写入、版本升级或存档损坏。正式调整之前最好先停止服务器并备份世界目录。
这次还遇到了一个现实限制:树莓派虽然有 8 GB 内存,但上面还有 MySQL 等服务,不能只根据总内存判断可以同时运行多少个 Minecraft 实例。
三、ARM64 上运行 Bedrock:Box64 不是可选细节
树莓派的系统架构是:
uname -m
# aarch64这次使用的镜像架构是:
docker image inspect mc-bedrock-mcsm:local \
--format 'Architecture={{.Architecture}} OS={{.Os}}'
# Architecture=arm64 OS=linux但镜像是 ARM64,不等于 BDS 可执行文件本身是原生 ARM64。 本次运行链路实际上是:
mc-server-runner box64 ./bedrock_server-1.26.52.3检查得到的 Box64 版本为:
Box64 arm64 v0.4.5 9f285eab0
with Dynarec built on Sep 27 2026因此要区分三个层次:
- 树莓派操作系统是 ARM64;
- Docker 镜像也是 ARM64;
- Minecraft 服务端程序通过 Box64 兼容层运行。
本次使用的是 itzg/minecraft-bedrock-server 系列镜像,并基于它制作本地派生镜像 mc-bedrock-mcsm:local,设置 EULA=TRUE,避免首次启动因未接受许可协议而退出。
一个最小化的派生 Dockerfile 思路如下(仅作示意,基础镜像应选择自己已验证可在 ARM64 上工作的版本):
FROM itzg/minecraft-bedrock-server
ENV EULA=TRUEdocker build -t mc-bedrock-mcsm:local .注意:不同标签的基础镜像对 ARM64、Box64 和 BDS 版本的支持可能变化。上面的示例不保证任意时间拉取的 latest 都能在树莓派上运行;以实际验证过的镜像为准。四、MCSManager 创建 Docker 容器时的第一个大坑:路径不一致
4.1 现象
MCSManager 是通过 1Panel 安装的,Daemon 本身也运行在 Docker 中。创建 Minecraft Docker 实例后,启动时曾出现:
mkdir: cannot create directory '/workspace/.tmp': Permission denied开始看像是普通的文件权限问题,实际上还牵涉容器内路径和 Docker 宿主机路径不一致。
4.2 原因
MCSManager Daemon 容器里看到的实例目录前缀是:
/opt/mcsmanager/daemon/data但 1Panel 实际将 Daemon 数据放在宿主机:
/opt/1panel/apps/mcsmanager-daemon/mcsmanager-daemon/dataMCSManager Daemon 通过宿主机的 /var/run/docker.sock 创建子容器。当它要求 Docker 挂载:
/opt/mcsmanager/daemon/data/InstanceData/<实例UUID> → /workspaceDocker Engine 会把左边的路径当成宿主机路径解释,不会自动将其转换成 Daemon 容器内部对应的真实目录。
于是,MCSManager 和 Docker Engine 眼中的“同一个目录”其实可能不是同一个目录;加上目录所有权不匹配,最终触发 /workspace/.tmp 的权限错误。
4.3 临时验证与修复
先在宿主机建立预期目录,并把真实数据目录 bind mount 到 MCSManager 期待的宿主机路径:
sudo mkdir -p /opt/mcsmanager/daemon/data
sudo mount --bind \
/opt/1panel/apps/mcsmanager-daemon/mcsmanager-daemon/data \
/opt/mcsmanager/daemon/data再检查对应实例目录的属主。示例环境中的运行用户为 UID 1000、GID 1003;我们将实际需要写入的实例目录调整到相应权限。注意不要不加区分地对整个 1Panel 或 MCSManager 数据树执行递归 chown。
确认 bind mount 正确后,实例可以正常创建临时目录并启动。
4.4 持久化:不能只执行一次 mount
手动 mount --bind 重启后会丢失,所以建立 systemd mount unit:
/etc/systemd/system/opt-mcsmanager-daemon-data.mount
[Unit]
Description=MCSManager Daemon Docker Host Path Bind Mount
RequiresMountsFor=/opt/1panel/apps/mcsmanager-daemon/mcsmanager-daemon/data
Before=docker.service
[Mount]
What=/opt/1panel/apps/mcsmanager-daemon/mcsmanager-daemon/data
Where=/opt/mcsmanager/daemon/data
Type=none
Options=bind
[Install]
WantedBy=local-fs.target并给 Docker 添加依赖:
/etc/systemd/system/docker.service.d/override.conf
[Unit]
Requires=opt-mcsmanager-daemon-data.mount
After=opt-mcsmanager-daemon-data.mount应用并检查:
sudo systemctl daemon-reload
sudo systemctl enable opt-mcsmanager-daemon-data.mount
sudo systemctl is-enabled opt-mcsmanager-daemon-data.mount
sudo systemctl is-active opt-mcsmanager-daemon-data.mount
systemctl show docker.service -p Requires -p After此次检查中,mount 已经 enabled 且 active,Docker 的依赖关系也正确。当时没有实际进行整机重启验收,因此部署到其他机器时仍建议安排一次可控的重启验证。
操作时建议保留类似 /opt/mcsmanager/daemon/data.before-bind 的备份,确认长期稳定前不要急于删除。
通用经验:只要“容器里的管理程序”通过宿主机 Docker socket 创建其他容器,就要特别注意 bind mount 的源路径是由宿主机 Docker Engine解析的。
五、第二个坑:MCSManager 的容器可能自动消失
排查过程中曾经在宿主机看不到新 Minecraft 容器,后来发现是服务器已经停止。
MCSManager 创建的 Docker 容器使用过类似:
AutoRemove: true这意味着容器退出后可能被 Docker 自动删除。下次启动由 MCSManager 重新创建,容器名称、ID 和映射配置也可能重新应用。
常用检查命令:
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
docker logs --tail 100 mc-bedrock-mcsm因此,看不到容器不一定代表 MCSManager 丢失了实例;要同时检查 MCSManager 网页里的实例状态,以及实例数据是否仍保存在持久化目录。
六、第三个坑:树莓派 8 GB 内存也会被压满
有一次新旧 Minecraft 服务器同时运行时,树莓派响应明显变慢,甚至几乎无法操作。停止旧 mc-bedrock 后恢复。
恢复后检查到的大致情况:
| 进程/指标 | 当时观察值 |
|---|---|
| 物理内存总量 | 约 7.6 GiB |
| 已使用内存 | 约 6.2 GiB |
| 可用内存 | 约 1 GiB |
| Swap | 0 |
MySQL mysqld RSS | 约 2.72 GiB |
| 新 BDS 进程 RSS | 约 1.38 GiB |
| 其他 Java 服务 | 合计约 0.7 GiB |
MySQL 的 InnoDB buffer pool 配置约为 2 GiB。我们还对其缓存命中情况做了短时间观察,没有证据说明必须为了 Minecraft 立刻缩小 MySQL 缓存。
这次采取的策略是:旧服务器停止,新服务器单独运行,先保证整台树莓派的稳定性。
如果旧容器不需要自动启动,可以检查并视情况设置:
docker update --restart=no mc-bedrock这是一个可选的后续操作,并非本次记录中已确认执行的步骤。将来如需同时运行多个世界,应该重新做内存预算,并考虑 zram/swap、实例人数限制及其他服务负载。
七、第四个坑:绿色在线小点不等于能进入世界
服务器启动后,手机 Minecraft 能看到服务器旁边的绿色在线状态,但点击加入世界始终失败。日志没有新的玩家连接记录。
当时服务器日志包含:
Version: 1.26.52.3
Accepting clients on [::]:19132
Signed in to signaling service successfully
Server started.Docker 初始只映射了:
19132/udp -> 0.0.0.0:19134绿色小点说明某种状态探测有响应,但不能证明完整游戏连接流程已经成功。
7.1 用 tcpdump 证明数据包到达了哪里
先在树莓派宿主机抓包:
sudo tcpdump -ni any 'udp port 19134'实际观察到手机 192.168.10.100 向树莓派 192.168.10.20:19134 连续发送 UDP 数据:
eth0 In IP 192.168.10.100.48116 > 192.168.10.20.19134: UDP, length 1464随后扩大过滤范围:
sudo tcpdump -ni any 'udp port 19134 or udp port 19132'观察到:
eth0 In 192.168.10.100:48116 > 192.168.10.20:19134
docker0 Out 192.168.10.100:48116 > 172.17.0.2:19132
veth... Out 192.168.10.100:48116 > 172.17.0.2:19132这证明了:
- 手机使用的 IP 和端口正确;
- 手机的 UDP 数据包能到达树莓派;
- Docker 的 UDP DNAT 转发已工作;
- 但当时没有观察到对应的 UDP 回复。
这个结论把排查重点从 Wi-Fi、FRP、路由器缩小到了容器和服务器协议层。
7.2 最容易误判的一幕:UDP 7551
在容器内检查 /proc/net/udp6,以及通过宿主机 nsenter ... ss -lunp,都只看到了 Minecraft 进程持有的 UDP 7551,没有 UDP 19132。
起初怀疑 Box64 没有正确创建游戏监听端口。但后来进一步检查发现:
容器内部 TCP 19132 正在 LISTEN同时配置文件明确写着:
transport=nethernet
server-port=19132关键教训:不能看到 UDP 19132 未监听就直接断言服务器坏了,更不能直接把 Docker 映射改成 7551。7551 可能属于 NetherNet 内部通信;它不是这次客户端应连接的公开游戏端口。
八、第五个坑:新版 NetherNet 需要补上 TCP 映射
最终发现,Minecraft 容器的 19132/TCP 确实在监听,而 Docker 当时只发布了 UDP。
在 MCSManager 的 Docker 端口映射界面增加第二条(当时的阶段性配置,后期 UDP 已调整):
| 协议 | 宿主机端口 | 容器端口 |
|---|---|---|
| UDP | 19134 | 19132 |
| TCP | 19134 | 19132 |
保存后通过 MCSManager 重新启动/重新创建实例容器。检查:
docker port mc-bedrock-mcsm实际验证得到:
19132/tcp -> 0.0.0.0:19134
19132/tcp -> [::]:19134
19132/udp -> 0.0.0.0:19134
19132/udp -> [::]:19134随后从树莓派宿主机连接 TCP 19134 也成功。
重要区分:增加 TCP 映射修复了一个明确的网络配置缺口,但当时手机仍受到白名单限制;因此不能把最终成功全部归因于 TCP 映射。对于本次 transport=nethernet 的环境,两条映射都应保留。
九、第六个坑:白名单命令成功,但重启后又恢复
服务器日志多次出现:
ALLOW LIST WARNING
Allow list is enabled but contains no entries.最初在 MCSManager 控制台执行:
allowlist off返回:
Turned off the allowlist但重启后检查配置文件仍然出现过:
allow-list=true后来直接修改持久化文件:
# 停止服务器后执行
sed -i 's/^allow-list=true$/allow-list=false/' \
/home/mcadmin/docker-vols/mc_data/server.properties再次启动后,宿主机与容器中的配置文件都显示 allow-list=false,但日志一度仍提示白名单启用。这说明配置文件、运行时状态和启动日志之间存在需要进一步核对的差异,不能只看其中一个就下结论。
最终,在确认关闭白名单限制之后,手机成功进入服务器。这是本次实际验证的结果。
为公网开放时,建议重新启用白名单并添加允许加入的玩家,例如:
allowlist add "你的Minecraft玩家名"
allowlist on再确认白名单文件、运行时状态和重启后的持久化配置一致,并实际测试自己的账号能否进入。也建议启用必要的备份与访问控制。
十、第七个坑:FRP 不仅要转发 UDP,也要转发 TCP
局域网测试成功后,接下来是公网访问。树莓派上的 frpc 使用 /etc/frp/frpc.toml,并且原先就有一条 Minecraft UDP 映射(历史记录;最终已改为 19140–19155):
[[proxies]]
name = "minecraft-bedrock-19134"
type = "udp"
localIP = "127.0.0.1"
localPort = 19134
remotePort = 19134由于 frpc 就运行在树莓派上,所以 127.0.0.1 是正确的。当时先添加 TCP 映射(历史记录;最终公网 TCP 端口改为 19135):
[[proxies]]
name = "minecraft-bedrock-19134-tcp"
type = "tcp"
localIP = "127.0.0.1"
localPort = 19134
remotePort = 19134注意:
frpc运行在树莓派本机:localIP = "127.0.0.1";frpc运行在另一台局域网设备:改为树莓派局域网 IP192.168.10.20;- 公网服务器防火墙/云安全组需允许相应 TCP/UDP 端口;最终为
19134/TCP与19140–19155/UDP,而19135/TCP是 FRP 后端入口,宜限制来源; - 修改后应使用当前 FRP 的实际启动/管理方式安全重载或重启
frpc,不要想当然地运行systemctl restart frpc; frps的allowPorts等限制(如果有)也要允许目标端口。
此处记载的是 Android 公网连通的早期阶段。后来出现 iPad 无法通过公网加入的问题,并通过 HTTPS 兼容入口解决。最终公网 TCP 不再由 FRP 直接监听 19134。
十一、以后再建一台 Minecraft Bedrock 服务器:推荐标准流程
以下是把这次踩坑整理成的可复用清单。
第 1 步:规划资源与隔离
- 确认树莓派 ARM64 系统、可用内存、CPU 和现有容器负载;
- 确认是否真的需要多个 BDS 实例同时运行;
- 为每个世界分配独立的数据目录;
- 为每个实例分配不同的宿主机端口;
- 先备份旧世界,避免误用已有
/data。
第 2 步:准备能在 ARM64 工作的镜像
- 使用经过验证的 BDS + Box64 方案;
- 接受 EULA;
- 尽量固定镜像和游戏版本,避免
LATEST自动升级引发兼容性变化; - 确认镜像能在树莓派上启动,并观察日志中的
Server started.。
第 3 步:确认 MCSManager Daemon 的路径映射
- 检查 MCSManager Daemon 是宿主机运行还是 Docker 运行;
- 如果通过
/var/run/docker.sock创建实例,确保 Docker Engine 能找到 MCSManager 提交的宿主机源路径; - 必要时使用 bind mount 对齐路径,并通过 systemd 持久化;
- 检查实例目录的 UID/GID 写入权限;
- 不要忽略
/workspace/.tmp: Permission denied这样的错误。
第 4 步:在 MCSManager 中创建 Docker 实例
本次最初部署阶段的配置示例(最终 UDP 范围另见第十四节):
| 配置项 | 示例值 |
|---|---|
| 镜像 | mc-bedrock-mcsm:local |
| 数据卷 | /home/mcadmin/docker-vols/mc_data:/data |
| 容器游戏端口 | 19132 |
| 宿主机游戏端口 | 19134 |
| TCP 映射 | 19134:19132/tcp |
| UDP 映射 | 初期 19134:19132/udp;最终采用 19140–19155/udp 对应映射 |
| EULA | TRUE |
MCSManager 具体表单和数据卷挂载方式可能随版本不同;重点是最终创建出来的容器确实具有这些映射,而不只是网页里填过。
第 5 步:检查 BDS 配置
server-port=19132
server-portv6=19133
transport=nethernet
online-mode=true
allow-list=false上面是初次排障用的示例配置,不是所有 BDS 版本都必须使用 transport=nethernet,也不代表公网部署应该永久关闭白名单。
先以最少限制完成局域网连通性验证,成功后再配置白名单及访问控制。
第 6 步:局域网验收
docker ps --filter name=mc-bedrock-mcsm
docker port mc-bedrock-mcsm
docker logs --tail 100 mc-bedrock-mcsm确认:
- 容器运行并且 Minecraft 日志出现
Server started.; - TCP、UDP 两种映射都存在;
- 手机连接树莓派当前 LAN IP(本次最终为
192.168.0.40)的 TCP 信令入口:19134能实际进入世界; - 不要只看“绿色在线小点”。
若失败,抓包定位:
sudo tcpdump -ni any 'udp port 19134 or udp port 19132'若怀疑 TCP:
# 在宿主机检查映射是否存在
docker port mc-bedrock-mcsm
# 检查容器网络命名空间中的监听情况(需要相应权限)
PID=$(docker inspect -f '{{.State.Pid}}' mc-bedrock-mcsm)
sudo nsenter -t "$PID" -n ss -lntup抓包能帮助区分“没到宿主机”“Docker 没转发”“服务器没有回复”,不要一上来就改世界数据或重装镜像。
第 7 步:配置公网 FRP
最终公网 FRP TCP 代理应为 remotePort = 19135、localPort = 19134,UDP 使用 19140–19155 范围代理。先局域网成功,再进行公网测试;若 iPad 在局域网能进入而公网不能进入,继续检查 TLS 信令入口(第十四节)。
第 8 步:安全与运维收尾
- 添加自己的玩家到白名单,重新启用白名单并测试;
- 备份世界存档和关键配置;
- 记录当前 BDS/Box64/镜像版本;
- 确认实例重启后端口映射和配置仍然生效;
- 检查旧服务器是否会自动启动、与新服务器争抢内存;
- 有维护窗口时做一次整机重启验收,确认 systemd mount 和 Docker 启动顺序。
十二、最有用的排障命令速查
# 宿主机架构
uname -m
# 容器状态
docker ps --filter name=mc-bedrock-mcsm
# 端口映射(TCP/UDP 都应出现)
docker port mc-bedrock-mcsm
# 启动日志
docker logs --tail 100 mc-bedrock-mcsm
# 容器进程(即使容器里没有 ps 也能用)
docker top mc-bedrock-mcsm
# 镜像架构
docker image inspect mc-bedrock-mcsm:local \
--format 'Architecture={{.Architecture}} OS={{.Os}}'
# Box64 版本
docker exec mc-bedrock-mcsm box64 -v
# 查看游戏配置
grep -E '^(server-port|server-portv6|transport|online-mode|allow-list)=' \
/home/mcadmin/docker-vols/mc_data/server.properties
# 容器网络命名空间的监听端口
PID=$(docker inspect -f '{{.State.Pid}}' mc-bedrock-mcsm)
sudo nsenter -t "$PID" -n ss -lntup
# 手机加入世界时抓包
sudo tcpdump -ni any 'udp port 19134 or udp port 19132'
# MCSManager bind mount 状态
systemctl is-active opt-mcsmanager-daemon-data.mount
systemctl show docker.service -p Requires -p After
# 树莓派内存
free -h十三、总结:这次真正学到的东西
这次部署不是简单地“拉一个 Minecraft 镜像、映射一个 UDP 端口”就结束了。问题跨越了多个层次:
- ARM64 兼容层:镜像架构是 ARM64,但 BDS 仍通过 Box64 运行;
- 容器套容器的路径语义:Daemon 内部路径不等于 Docker Engine 看到的宿主机路径;
- 持久化与权限:bind mount、systemd 启动顺序、UID/GID 都会影响实例启动;
- 内存资源:8 GB 树莓派同时跑 MySQL 和两个 BDS 也可能明显卡顿;
- 网络协议变化:在本次 NetherNet 环境中,不能只映射 UDP,TCP 也必须纳入检查;
- 运行时配置与持久化配置:
allowlist off成功,不代表下次启动时仍然关闭; - 逐层验证:状态绿点、容器 Healthy、
Server started.都不能替代“玩家实际进入世界”的验收; - 先内网,后公网:先把 Minecraft 和 Docker 打通,再接入 FRP,排障成本最低。
最终我们没有通过反复重装来碰运气,而是借助 docker inspect、docker port、docker top、ss、tcpdump 和日志逐层缩小范围,最终打通了从手机到 Minecraft 世界的连接。下一次再搭建新服务器,只要沿着上面的标准流程做,基本可以避开这次遇到的大部分坑。
十四、追加排障:为什么 Android 能连公网,iPad 却始终失败?
14.1 故障边界
在最初完成 FRP 穿透后,出现了一个很容易误导人的现象:
- Android 手机通过
mc.test.com:19134可以加入服务器。 - iPad 使用官方 Minecraft Bedrock 26.52,局域网连接树莓派
192.168.0.40可以加入。 - 同一台 iPad 改用公网域名,即使换到外部网络,仍无法加入。
- iPad 可以连接其他公网 Minecraft 服务器。
因此不宜直接把故障归因于 Minecraft 世界、iPad 本身、白名单或局域网 Wi-Fi。真正需要比较的是同一服务端的局域网与公网入口,在不同客户端上发生了什么。
14.2 在 VPS 上抓包发现 TLS ClientHello
抓取公网 TCP 19134 流量后,观察到 iPad 先建立 TCP 连接,然后发送约 517 字节 TLS ClientHello,其中 SNI 为 mc.test.com。后端原本是 NetherNet 的明文 HTTP 信令服务,并没有 TLS 终止能力,连接返回 7 字节 TLS fatal alert:
15 03 01 00 02 02 28其中末尾的 02 28 表示 TLS handshake_failure。在那次独立 iPad 测试抓包中,没有继续看到 HTTP 回退请求,也没有看到后续 UDP 游戏流量。
此前另一次抓包中出现过 NetherNet 信令请求:
GET /v1/join HTTP/1.1
Host: mc.test.com:19134服务端返回 HTTP 200 及服务器信息。注意:这条 HTTP 请求不能凭抓包片段就绝对归属为 Android;而 TLS ClientHello 也可能属于客户端的探测流程。单靠这一处抓包不能严格证明全部根因。随后在相同公网入口加入 TLS 兼容层后,iPad 实际连接成功,这构成了更强的工程验证。
14.3 为什么不能直接把 19134 改成 HTTPS?
因为原来能工作的客户端仍可能发送明文 HTTP。如果在 19134 上只开启 TLS,原有明文客户端反而可能失败。
最终方案是 TCP 协议识别 + 独立 TLS 终止 + 统一回源:
- 公网 TCP
19134由 OpenRestystream监听。 - 利用
ssl_preread判断连接是否为 TLS。 - 非 TLS 流量直接转发到本机 FRP TCP
19135。 - TLS 流量先交给
127.0.0.1:19136进行 TLS 解密,再将解密后的 HTTP 信令转发至 FRP19135。 - FRP 继续穿透至树莓派
127.0.0.1:19134,Docker 转发至 BDS19132/TCP。
这个方案的关键是:19134 只监听一次,不能简单写两个同时 listen 19134 的 HTTP/HTTPS server 块;前置 ssl_preread 负责在同一个 TCP 入口上分流。
十五、最终实施:OpenResty、FRP 与 Minecraft 端口
15.1 VPS 上的 OpenResty 配置
OpenResty 容器名:
1Panel-openresty-buDt其网络模式是 host,主配置文件宿主机路径:
/opt/1panel/apps/openresty/openresty/conf/nginx.conf该文件映射到容器内:
/usr/local/openresty/nginx/conf/nginx.conf最终追加在主配置文件顶层的 stream 配置如下(不能放进 http {} 内):
# Minecraft: HTTP + HTTPS on the same public TCP port.
stream {
map $ssl_preread_protocol $minecraft_backend {
"" 127.0.0.1:19135;
default 127.0.0.1:19136;
}
server {
listen 0.0.0.0:19134;
ssl_preread on;
proxy_connect_timeout 5s;
proxy_timeout 120s;
proxy_pass $minecraft_backend;
}
server {
listen 127.0.0.1:19136 ssl;
ssl_certificate /usr/local/openresty/nginx/conf/conf.d/minecraft-ssl/fullchain.pem;
ssl_certificate_key /usr/local/openresty/nginx/conf/conf.d/minecraft-ssl/privkey.pem;
proxy_connect_timeout 5s;
proxy_timeout 120s;
proxy_pass 127.0.0.1:19135;
}
}配置前先备份,执行:
docker exec 1Panel-openresty-buDt nginx -t
docker exec 1Panel-openresty-buDt nginx -s reload实施时曾先用临时 19137/TCP 验证明文 HTTP 和 HTTPS 都返回 200,才把正式入口切换到 19134。这是减少停机风险的重要步骤。
这里的 HTTPS 是在 VPS 上终止,FRP 及后端 BDS 仍使用明文 HTTP 信令。这并不是 VPS 到树莓派全链路 HTTPS,也不意味着 UDP 游戏流量经过 TLS。
15.2 树莓派 frpc.toml 的最终 TCP 代理
配置文件:
/etc/frp/frpc.toml最终 Minecraft TCP 代理:
[[proxies]]
name = "minecraft-bedrock-tcp-19134"
type = "tcp"
localIP = "127.0.0.1"
localPort = 19134
remotePort = 19135这里 name 保留了历史命名 19134,但公网 FRP 监听端口已经是 19135。frps 本身运行在 Google Cloud VPS 的 Docker 容器 1Panel-frps-ozhn 中,采用 Host 网络模式。
UDP 部分保留本次实际使用的范围映射:
{{- range $_, $v := parseNumberRangePair "19140-19155" "19140-19155" }}
[[proxies]]
name = "minecraft-nethernet-udp-{{ $v.First }}"
type = "udp"
localIP = "127.0.0.1"
localPort = {{ $v.First }}
remotePort = {{ $v.Second }}
{{- end }}上述 {{ ... }} 是 FRP 0.58.0 的配置模板语法,不是普通 TOML 的原生语法,也不是需要手工替换成一个端口的占位符。
树莓派的 frpc 由 /home/mango/frpc_watchdog.sh 守护,而不是名为 frpc 的 systemd 服务。实际切换时备份 /etc/frp/frpc.toml,只改 Minecraft TCP 的 remotePort,然后使 frpc 重启,守护脚本自动拉起新进程。检查确认新的 FRP TCP 19135 已监听,并且 /v1/join 返回 200。
15.3 树莓派 Docker 与 BDS
本次最终确认:
树莓派 LAN IP: 192.168.0.40
容器: mc-bedrock-mcsm
TCP: 树莓派 19134 → 容器 19132
UDP: 树莓派 19140–19155 → 容器对应端口
BDS: 1.26.52.3
NetherNet: transport=nethernetBDS 实际配置位于容器内 /workspace/server.properties;对应的 1Panel MCSManager 实例数据目录是:
/opt/1panel/apps/mcsmanager-daemon/mcsmanager-daemon/data/InstanceData/dcab9698e11f4636935dc10fe8494d5f原文早期的 /home/mcadmin/docker-vols/mc_data 属于搭建时的示例/阶段性记录;不要将其误认为此次最终实例的实际目录。MCSManager 仍负责实例生命周期和管理界面。
十六、Let's Encrypt 证书与自动续签
16.1 从手工 DNS 验证迁移到自动 HTTP-01
最初在 VPS 的 1Panel 中通过手动 DNS TXT 验证签发 mc.test.com 证书。证书和私钥分别为 fullchain.pem 与 privkey.pem。检查发现两者匹配,但初次上传的目录和私钥权限过宽,于是将目录收紧至 700、私钥至 600。
为了避免每三个月手动添加 _acme-challenge TXT 记录,最终改为 Docker 版 Certbot + HTTP-01 Webroot 验证。
没有使用系统 apt install certbot:当时 Ubuntu 22.04 的软件包依赖存在未满足问题(包括已有的内核头文件依赖),为了不影响其他服务,改用 Docker 镜像 certbot/certbot:latest。
16.2 HTTP-01 验证路径
在 VPS OpenResty 的 conf.d 目录添加:
/opt/1panel/apps/openresty/openresty/conf/conf.d/minecraft-acme.conf内容:
server {
listen 80;
server_name mc.test.com;
location ^~ /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
default_type text/plain;
try_files $uri =404;
}
location / { return 404; }
}宿主机目录 /opt/1panel/apps/openresty/openresty/root 映射到 OpenResty 容器的 /usr/share/nginx/html。Certbot Docker 使用同一宿主机目录作为 /webroot,从而让 Let's Encrypt 在 http://mc.test.com/.well-known/acme-challenge/... 读取验证文件。
实际先放入测试文件验证公网 HTTP 可读取,再执行 Certbot --dry-run,模拟签发成功;之后正式签发成功。这与游戏信令使用的 TCP 19134 无关:HTTP-01 走标准 TCP 80。
16.3 Certbot 目录及正式部署证书
/opt/1panel/minecraft-certbot/
├── config/ # Let's Encrypt 账号、续签配置、live/archive 证书
├── work/
└── logs/Certbot 管理的源证书位于:
/opt/1panel/minecraft-certbot/config/live/mc.test.com/脚本将证书部署到 OpenResty 已挂载的安全目录:
/opt/1panel/apps/openresty/openresty/conf/conf.d/minecraft-ssl/
├── fullchain.pem
└── privkey.pem # 600私钥没有放进对外提供网页的目录。证书更新后才重新加载 OpenResty。
16.4 实际部署的自动续签脚本
路径:
/usr/local/sbin/minecraft-cert-renew.sh以下为当时安装的脚本逻辑(保留关键实现,便于备份和迁移):
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
exec 9>/run/lock/minecraft-cert-renew.lock
flock -n 9 || exit 0
BASE=/opt/1panel/minecraft-certbot
WEBROOT=/opt/1panel/apps/openresty/openresty/root
SRC="$BASE/config/live/mc.test.com"
DEST=/opt/1panel/apps/openresty/openresty/conf/conf.d/minecraft-ssl
CONTAINER=1Panel-openresty-buDt
log() { echo "$(date -Is) $*"; }
log 'Checking Minecraft certificate renewal'
docker run --rm \
-v "$BASE/config:/etc/letsencrypt" \
-v "$BASE/work:/var/lib/letsencrypt" \
-v "$BASE/logs:/var/log/letsencrypt" \
-v "$WEBROOT:/webroot" \
certbot/certbot:latest renew --non-interactive --quiet
[[ -s "$SRC/fullchain.pem" && -s "$SRC/privkey.pem" ]]
if cmp -s "$SRC/fullchain.pem" "$DEST/fullchain.pem" &&
cmp -s "$SRC/privkey.pem" "$DEST/privkey.pem"; then
log 'Certificate unchanged; no OpenResty reload needed'
exit 0
fi
cert_pub=$(openssl x509 -in "$SRC/fullchain.pem" -pubkey -noout |
openssl pkey -pubin -outform DER | sha256sum | cut -d' ' -f1)
key_pub=$(openssl pkey -in "$SRC/privkey.pem" -pubout -outform DER |
sha256sum | cut -d' ' -f1)
[[ "$cert_pub" == "$key_pub" ]] || {
log 'ERROR: certificate/key mismatch'
exit 1
}
openssl x509 -in "$SRC/fullchain.pem" -checkend 86400 -noout >/dev/null || {
log 'ERROR: certificate expires too soon'
exit 1
}
backup=$(mktemp -d /opt/1panel/minecraft-certbot/backup.XXXXXXXX)
cp -p "$DEST/fullchain.pem" "$DEST/privkey.pem" "$backup/"
install -m 644 "$SRC/fullchain.pem" "$DEST/fullchain.pem.new"
install -m 600 "$SRC/privkey.pem" "$DEST/privkey.pem.new"
mv -f "$DEST/fullchain.pem.new" "$DEST/fullchain.pem"
mv -f "$DEST/privkey.pem.new" "$DEST/privkey.pem"
if docker exec "$CONTAINER" nginx -t &&
docker exec "$CONTAINER" nginx -s reload; then
log 'Certificate deployed and OpenResty reloaded successfully'
rm -rf "$backup"
else
log 'ERROR: OpenResty check/reload failed, restoring prior certificate'
cp -p "$backup/fullchain.pem" "$DEST/fullchain.pem"
cp -p "$backup/privkey.pem" "$DEST/privkey.pem"
docker exec "$CONTAINER" nginx -t &&
docker exec "$CONTAINER" nginx -s reload || true
exit 1
fi脚本包含并发锁、证书与私钥公钥一致性校验、过期时间检查、备份、nginx -t 和失败回滚。它只在部署的证书文件发生变化时 reload OpenResty。
运维注意:此脚本使用certbot/certbot:latest,后续建议固定经过验证的镜像版本或摘要,避免未来镜像升级引入意外变化。--quiet也意味着需要主动查看日志或配置失败告警。
16.5 cron 定时任务
实际写入:
/etc/cron.d/minecraft-cert-renew内容:
# Daily renewal check; Certbot renews only when certificate is due.
17 4 * * * root /usr/local/sbin/minecraft-cert-renew.sh >> /var/log/minecraft-cert-renew.log 2>&1VPS 当时使用 UTC,因此相当于每天日本时间 13:17检查。Certbot 仅在符合续签条件时申请新证书;不代表每天都向 Let's Encrypt 申请一次。
手动运行与查看日志:
sudo /usr/local/sbin/minecraft-cert-renew.sh
sudo tail -50 /var/log/minecraft-cert-renew.log当时验证结果:
- Let's Encrypt HTTP-01
--dry-run成功; - 正式签发成功;
- 新证书部署并 reload OpenResty 成功;
- 再次执行脚本正确报告证书未变化,不需要 reload;
cron为active;- 公网 HTTP 与 HTTPS
GET /v1/join均返回200,HTTPS 证书校验结果为0; - 新证书到期时间:2027-01-08 07:24:53 UTC。
边界说明:截至 2026-10-10,已经验证了自动化脚本、HTTP-01 模拟验证与正式签发,但还没有经过首次真正临近到期时的定时续签周期。因此不应将“未来每次续签一定成功”写成已经验证的事实。
十七、最终验收与后续运维建议
17.1 本次实际验收
| 验收项目 | 结果 |
|---|---|
| iPad 通过 LAN 连接 | 成功(排障前就已验证) |
| Android 通过公网连接 | 原方案已成功;新方案保留明文 HTTP 路径 |
iPad 通过公网 mc.test.com:19134 | 用户实际反馈成功 |
公网 http://mc.test.com:19134/v1/join | HTTP 200 |
公网 https://mc.test.com:19134/v1/join | HTTP 200,证书校验通过 |
FRP TCP 19135 | 监听正常,转发可用 |
UDP 19140–19155 | 保持原有代理配置 |
| 证书自动续签 | 模拟验证、正式签发和脚本重复执行均成功 |
iPad 成功加入世界是最关键的端到端验证。切换后 Android 的再次实机回归结果未在记录中明确反馈,因此不把它写成已经重新实测通过;但 HTTP 信令路径确实已在公网返回 200。
17.2 值得后续继续做的事情
- 将公网 VPS 的 FRP 后端 TCP
19135限制为仅本机 OpenResty 可以访问(例如使用适当的本机防火墙策略),避免绕过 TLS 入口;调整前务必确认 FRP/容器网络模式和云防火墙。 - 为证书续签失败配置通知,并定期核对证书有效期。
- 1Panel 或 OpenResty 应用升级后检查主
nginx.conf中的stream配置是否被覆盖。 - 保存
/etc/frp/frpc.toml、OpenResty 配置和 Minecraft 世界数据的安全备份,切勿公开 FRP token、管理员密码或 TLS 私钥。 - 重新检查 Minecraft 玩家白名单及访问控制,必要时启用并做重启后持久化验证。
- 在维护窗口做树莓派和 VPS 的可控重启验收,检查 MCSManager、FRP 守护脚本、OpenResty 和 cron 能否全部恢复。
十八、最终完整网络拓扑图
下面的 Mermaid 图展示了最终实际运行结构,分别标注 TCP 信令、HTTPS 终止、FRP 穿透、UDP NetherNet 游戏数据、证书自动续签以及 MCSManager/Box64 的位置。
可能使用 HTTP 信令"] I["iPad / iOS Minecraft
TLS / HTTPS 信令"] end DNS["DNS: mc.test.com
A → 公网VPS服务器IP"] subgraph VPS["Google Cloud VPS · 公网VPS服务器IP · Docker / 1Panel"] ENTRY["OpenResty stream
TCP 0.0.0.0:19134
ssl_preread 检测 TLS"] PLAIN["非 TLS 流量
明文 TCP 透传"] TLS["TLS 流量
127.0.0.1:19136
使用 Let's Encrypt 证书解密"] FRPS["FRP frps
TCP :19135
UDP :19140–19155"] ACME["OpenResty HTTP :80
HTTP-01 challenge"] CERTBOT["Docker Certbot
每日 cron 04:17 UTC
仅更新时 reload OpenResty"] end subgraph HOME["家庭网络 · Raspberry Pi 4B · 192.168.0.40 · ARM64"] FRPC["frpc + frpc_watchdog.sh
/etc/frp/frpc.toml"] DOCKER["Docker 端口映射
TCP :19134 → :19132
UDP :19140–19155 → 同端口"] MCS["1Panel / MCSManager Daemon
管理 Minecraft 实例"] BDS["mc-bedrock-mcsm 容器
ARM64 镜像 + Box64
BDS 1.26.52.3 / NetherNet"] WORLD[("独立实例数据 / Minecraft 世界")] end A -->|"TCP :19134"| DNS I -->|"TCP :19134"| DNS DNS --> ENTRY ENTRY -->|"无 TLS"| PLAIN ENTRY -->|"TLS ClientHello"| TLS PLAIN -->|"127.0.0.1:19135"| FRPS TLS -->|"解密后 TCP → 127.0.0.1:19135"| FRPS FRPS <-->|"FRP 控制连接与 TCP 穿透"| FRPC FRPC -->|"127.0.0.1:19134/TCP"| DOCKER DOCKER -->|"TCP 信令 :19132"| BDS A -.->|"UDP 游戏数据 :19140–19155"| FRPS I -.->|"UDP 游戏数据 :19140–19155"| FRPS FRPS <-.->|"FRP UDP 端口范围转发"| FRPC FRPC -.->|"127.0.0.1:19140–19155/UDP"| DOCKER DOCKER -.->|"UDP NetherNet"| BDS CERTBOT -->|"HTTP-01 验证文件"| ACME CERTBOT -->|"证书文件更新 + nginx reload"| TLS MCS -.->|"创建 / 启停 / 管理"| BDS BDS --- WORLD classDef edge fill:#e8f2ff,stroke:#4a84cc,color:#16365c classDef secure fill:#e8f8ee,stroke:#31a36b,color:#125734 classDef compute fill:#fff4e7,stroke:#d59b43,color:#69420e class A,I,DNS edge class ENTRY,TLS,ACME,CERTBOT secure class FRPS,FRPC,DOCKER,MCS,BDS,WORLD,PLAIN compute
读图提示:实线表示主要 TCP 信令路径;虚线表示 UDP 游戏数据或管理/自动化关系。Mermaid 图是 Markdown 内的可编辑文本,不是将文章内容制作成图片。GitHub、支持 Mermaid 的 Markdown 编辑器或博客系统可直接渲染;不支持 Mermaid 的平台可保留源代码或单独导出 SVG。