简介
本文记录在京东云 BE6500 上部署 Docker 的完整排障与最终方案。对应脚本为 install-docker-qwrt-be6500.sh,已在恢复出厂后的 QWRT 环境中完成完整验证。
CXT将Docker安装脚本附在了文章末尾,有需要的自行保存使用。再次感谢罗总扩容2GB,否则没空间装Docker。
上一篇文章:《京东云 BE6500 扩容到 2G rootfs:GPT 容量不匹配的排查与修复BE6500-2G-GPT-Expansion-Notes》
环境
| 项目 | 实际环境 |
|---|---|
| 设备 | JDCloud BE6500 |
| 固件 | QWRT 25.12.2 / QWRT R26.6.1 |
| 平台 | ipq53xx/generic |
| 内核 | 5.4.213 |
| 软件源 | ImmortalWrt 24.10.5,aarch64_cortex-a53 |
| 数据盘 | /dev/sda1,ext4,自动挂载到 /opt |
| Docker | 27.3.1 |
| Docker Compose | v2.39.1 |
Docker 数据目录:
/opt/docker
为什么不能只执行一串 opkg 命令
BE6500 的 QWRT 有三个会影响 Docker 安装的现实问题:
dockerd声明依赖kmod-ipt-physdev,但这套内核没有该模块。- 安装
dockerd会创建docker0、写入 network/firewall UCI 配置,并触发 QWRT 重载 LAN、Wi-Fi、WAN。 - Dropbear 被配置为直接绑定
br-lan。网络重载时 SSH 会断开,监听服务可能需要重新绑定。
因此,前台 SSH 里直接跑安装命令,常会看到终端断开,却无法判断后台是否仍在安装。脚本需要让安装任务独立于 SSH 会话,并在网络稳定后再恢复 Docker 桥接和 Dropbear。
安装组件与 LuCI 选择
脚本安装:
containerd
runc
tini
docker
dockerd
docker-compose
luci-lib-docker
luci-app-dockerman
luci-i18n-dockerman-zh-cn
dockerd -> containerd -> runc 是 Docker 正常运行链路,不是冲突。
LuCI 只保留 Dockerman,不混装 luci-app-docker,避免两个 Docker 前端的菜单、Lua 模块和配置重叠。
kmod-ipt-physdev 缺失
本机内核没有 CONFIG_NETFILTER_XT_MATCH_PHYSDEV,软件源也没有匹配模块。因此脚本仅对 dockerd 使用 --force-depends,再以真实运行结果验收。
安装日志可能出现:
satisfy_dependencies_for: Cannot satisfy ... kmod-ipt-physdev
这在本机是已知的包元数据与内核不匹配提示。只要以下检查成功,就不影响实际使用:
- Docker 守护进程可响应
docker info。 - Docker 根目录为
/opt/docker。 - 存储驱动为
overlay2。 - 容器 DNS、出网和端口映射均正常。
/opt 与旧 Docker 数据
脚本先确认 /opt 是 ext4 挂载点。若磁盘没有挂载,脚本立即退出,不会把 Docker 数据写入路由器内部闪存。
重复执行脚本时,只会处理:
/opt/containerd
/opt/docker
它们会改名归档,例如:
/opt/containerd.old-20260728-193106
/opt/docker.old-20260728-193106
不会删除 /opt 下其他文件。确认新环境稳定后,再按脚本日志打印的精确 rm -rf 命令手动删除旧归档。
Docker 有时会把 /opt/docker 显示为额外的同盘挂载。这是 Docker 的 shared self-bind mount,不是 fstab 错误;只需要挂载 /opt,不要单独给 /opt/docker 写 fstab。
用 procd 解决 SSH 断线
这套 BusyBox 没有 nohup 和 setsid,不适合依赖 screen、nohup 或 setsid。最终方案使用 OpenWrt 内置的 procd。
执行脚本后,前台只创建临时 worker:
SSH 控制脚本
-> 临时 procd worker
-> 安装 Docker、等待网络重载、执行验收
-> 写入 /opt 日志和状态文件
-> 删除临时 worker 服务
SSH 断开不会终止 worker。启动时会显示:
Log: /opt/docker-install-YYYYMMDD-HHMMSS.log
Status: /opt/docker-install-YYYYMMDD-HHMMSS.status
网络恢复后检查:
cat /opt/docker-install-YYYYMMDD-HHMMSS.status
tail -n 150 /opt/docker-install-YYYYMMDD-HHMMSS.log
成功标志:
exit=0 completed=...
QWRT 网络重载的正确处理顺序
dockerd 软件包初始化会触发 QWRT 网络重载。网络重载期间,已启动的 Docker 可能失去 docker0,随后创建容器会报:
adding interface veth... to bridge docker0 failed: Device does not exist
脚本的正确顺序是:
- 安装并配置 Docker。
- 等待 75 秒,让 LAN、Wi-Fi、WAN 完成重载。
- 重启
dockerd,重新创建docker0。 - 检查
docker0存在和docker info正常。 - 重启直接绑定
br-lan的 Dropbear。 - 最后才拉镜像、运行容器网络测试和端口映射测试。
75 秒是为 OpenClash、VPN、复杂无线配置预留的保守值。此前 60 秒已通过测试,75 秒增加了额外余量。
Docker Hub 与显式代理镜像
大陆网络下直接访问 Docker Hub 常超时。脚本配置了:
https://docker.1panel.live/
但透明 mirror 并不保证所有网络都能让普通 docker pull busybox 成功。因此建议使用显式代理名称:
docker pull docker.1panel.live/library/postgres:18-alpine
docker pull docker.1panel.live/library/redis:8-alpine
docker pull docker.1panel.live/weishaw/sub2api:latest
脚本用 docker.1panel.live/library/busybox:latest 做验收。
OpenClash 与 Docker DNS
OpenWrt 宿主机通常使用本地 dnsmasq:
nameserver 127.0.0.1
nameserver ::1
容器无法访问宿主机 loopback。Docker 因此可能自动改用 8.8.8.8、8.8.4.4,从而绕过 OpenClash 的 DNS 分流。
正确方案是让容器把 DNS 指向路由器 LAN 上的 dnsmasq/OpenClash。例如本机:
br-lan: 10.99.0.1/21
容器 DNS 为:
nameserver 10.99.0.1
脚本会自动读取 br-lan 的 IPv4 地址,不会写死 10.99.0.1。流量路径为:
Container -> Router LAN DNS -> dnsmasq / OpenClash -> 分流或代理
这不会影响宿主机 IPv6、WAN6、前缀委派、RA 或 DHCPv6。当前 Docker 默认 bridge 仍是 IPv4;容器 IPv6 需要另外配置。
若以后修改 LAN 网段,需要重新执行脚本,或更新 dockerd.globals.dns 后重启 Docker。
Dockerman 辅助服务
Dockerman 不只是 LuCI 页面。其辅助服务会在 Docker 的 DOCKER-USER 链前建立 DOCKER-MAN 规则,按接口和端口提供额外访问控制。
脚本会在 Docker 网络、DNS、端口映射全部验收完成后执行:
/etc/init.d/dockerman enable
/etc/init.d/dockerman restart
检查:
/etc/init.d/dockerman enabled
echo $?
返回 0 表示已启用开机启动。
使用方法
- 确认
/opt已自动挂载为 ext4。 - 将
install-docker-qwrt-be6500.sh上传到/root/。 - 执行:
sh /root/install-docker-qwrt-be6500.sh
- 记录脚本输出的日志与状态文件路径。
- SSH 若断开,等待网络恢复后重新连接并查看状态文件。
安装过程中不要重复执行脚本、重启路由器或强制停止 Docker。
#!/bin/sh
# JDCloud BE6500 / QWRT Docker installer.
# Existing /opt/containerd and /opt/docker directories are renamed with an
# .old-TIMESTAMP suffix. No existing Docker data is deleted.
#
# Run: sh /root/install-docker-qwrt-be6500.sh
# The controller starts a temporary procd worker. Follow its log under /opt
# so a QWRT network reload cannot terminate the installation.
IMAGE='docker.1panel.live/library/busybox:latest'
TEST_CONTAINER=''
TEST_CONTAINER_NAME='docker-http-test'
OLD_CONTAINERD=''
OLD_DOCKER=''
STATUS_FILE=''
WORKER_SERVICE=''
fail() {
[ -z "$TEST_CONTAINER" ] ||
docker rm -f "$TEST_CONTAINER" >/dev/null 2>&1 || true
echo "ERROR: $*" >&2
exit 1
}
record_exit() {
code=$?
[ -z "$STATUS_FILE" ] ||
printf 'exit=%s completed=%s\n' "$code" "$(date '+%Y-%m-%d %H:%M:%S')" > "$STATUS_FILE"
[ -z "$WORKER_SERVICE" ] || rm -f "$WORKER_SERVICE"
}
start_worker() {
grep -q ' /opt ext4 ' /proc/mounts ||
fail '/opt is not mounted from ext4.'
script_dir="$(cd "$(dirname "$0")" && pwd)" ||
fail 'Cannot resolve the script path.'
script_path="$script_dir/$(basename "$0")"
stamp="$(date +%Y%m%d-%H%M%S)"
run_log="/opt/docker-install-$stamp.log"
run_status="/opt/docker-install-$stamp.status"
: > "$run_status" || fail "Cannot create $run_status."
worker_name="docker-install-worker-$stamp"
worker_service="/etc/init.d/$worker_name"
cat > "$worker_service" <<EOF
#!/bin/sh /etc/rc.common
START=99
USE_PROCD=1
start_service() {
procd_open_instance
procd_set_param command /bin/sh '$script_path' --worker '$run_status' '$run_log' '$worker_service'
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
EOF
chmod 0755 "$worker_service" || fail "Cannot make $worker_service executable."
"$worker_service" start || fail 'Cannot start the procd installation worker.'
sleep 1
[ -s "$run_log" ] || fail 'The procd installation worker did not start.'
echo 'Docker installation started under procd.'
echo "Log: $run_log"
echo "Status: $run_status"
echo 'The router network may reconnect once while Docker creates docker0.'
echo 'Wait for the status file to contain exit=0, then reconnect SSH if needed.'
echo " cat '$run_status'"
echo " tail -n 100 '$run_log'"
}
if [ "$1" != '--worker' ]; then
start_worker
exit 0
fi
STATUS_FILE="$2"
RUN_LOG="$3"
WORKER_SERVICE="$4"
[ -n "$STATUS_FILE" ] || exit 2
[ -n "$RUN_LOG" ] || exit 2
exec </dev/null >> "$RUN_LOG" 2>&1
trap record_exit 0
echo '== 1. Check storage and package feeds =='
grep -q ' /opt ext4 ' /proc/mounts ||
fail '/opt is not mounted from ext4.'
opkg update || fail 'opkg update failed.'
echo '== 2. Stop Docker and archive old Docker state =='
/etc/init.d/dockerman stop >/dev/null 2>&1 || true
/etc/init.d/dockerd stop >/dev/null 2>&1 || true
count=0
while pidof dockerd >/dev/null 2>&1 ||
pidof containerd >/dev/null 2>&1; do
count=$((count + 1))
[ "$count" -lt 15 ] || fail 'Docker processes did not stop.'
sleep 1
done
# Dockerd may make /opt/docker a shared self-bind mount.
while grep -q ' /opt/docker ' /proc/self/mountinfo; do
umount /opt/docker || fail 'Cannot unmount /opt/docker.'
done
grep -q ' /opt/docker/' /proc/self/mountinfo &&
fail 'A nested mount remains below /opt/docker.'
grep -q ' /opt/containerd' /proc/self/mountinfo &&
fail 'A mount remains below /opt/containerd.'
stamp="$(date +%Y%m%d-%H%M%S)"
if [ -e /opt/containerd ]; then
OLD_CONTAINERD="/opt/containerd.old-$stamp"
[ ! -e "$OLD_CONTAINERD" ] ||
fail "$OLD_CONTAINERD already exists."
mv /opt/containerd "$OLD_CONTAINERD" ||
fail 'Cannot archive /opt/containerd.'
echo "Archived: $OLD_CONTAINERD"
fi
if [ -e /opt/docker ]; then
OLD_DOCKER="/opt/docker.old-$stamp"
[ ! -e "$OLD_DOCKER" ] ||
fail "$OLD_DOCKER already exists."
mv /opt/docker "$OLD_DOCKER" ||
fail 'Cannot archive /opt/docker.'
echo "Archived: $OLD_DOCKER"
fi
mkdir -p /opt/docker || fail 'Cannot create /opt/docker.'
chmod 0710 /opt/docker || fail 'Cannot set /opt/docker permissions.'
echo '== 3. Install runtime, engine and one LuCI frontend =='
opkg remove luci-i18n-docker-zh-cn luci-app-docker \
>/dev/null 2>&1 || true
opkg install --force-reinstall \
containerd runc tini docker docker-compose luci-lib-docker ||
fail 'Cannot install Docker runtime packages.'
# This QWRT kernel does not provide kmod-ipt-physdev. Docker bridge, DNS,
# NAT and published-port functions were verified on this device.
opkg install --force-reinstall --force-depends dockerd || true
opkg install --force-reinstall --force-depends \
luci-app-dockerman \
luci-i18n-dockerman-zh-cn || true
[ -x /usr/bin/containerd ] || fail 'containerd is missing.'
[ -x /usr/sbin/runc ] || fail 'runc is missing.'
[ -x /usr/bin/docker ] || fail 'docker is missing.'
[ -x /usr/bin/dockerd ] || fail 'dockerd is missing.'
opkg status dockerd 2>/dev/null |
grep -q '^Status: .* installed$' ||
fail 'dockerd is not installed.'
opkg status luci-app-dockerman 2>/dev/null |
grep -q '^Status: .* installed$' ||
fail 'luci-app-dockerman is not installed.'
opkg files luci-lib-docker 2>/dev/null |
grep -q '/luci/docker.lua$' ||
fail 'luci.docker Lua module is missing.'
echo '== 4. Configure Docker =='
/etc/init.d/dockerd stop >/dev/null 2>&1 || true
uci set dockerd.globals.data_root='/opt/docker/' ||
fail 'Cannot set Docker data root.'
uci set dockerd.globals.iptables='1' ||
fail 'Cannot enable Docker iptables support.'
uci -q delete dockerd.globals.alt_config_file >/dev/null 2>&1 || true
uci -q delete dockerd.globals.registry_mirrors >/dev/null 2>&1 || true
uci add_list dockerd.globals.registry_mirrors='https://docker.1panel.live' ||
fail 'Cannot set Docker Hub mirror.'
lan_dns="$(ip -4 -o addr show br-lan 2>/dev/null |
while read -r _ _ _ cidr _; do
[ -n "$cidr" ] || continue
printf '%s\n' "${cidr%/*}"
break
done)"
[ -n "$lan_dns" ] || fail 'Cannot determine the br-lan IPv4 address for container DNS.'
# Containers cannot reach the host 127.0.0.1 resolver. Use the LAN listener so
# they follow dnsmasq and future OpenClash DNS policy without public DNS.
uci -q delete dockerd.globals.dns >/dev/null 2>&1 || true
uci add_list dockerd.globals.dns="$lan_dns" ||
fail 'Cannot set router LAN DNS for containers.'
uci commit dockerd || fail 'Cannot save Docker configuration.'
rm -f /tmp/dockerd/daemon.json
rm -rf /tmp/luci-*
/etc/init.d/dockerd enable || fail 'Cannot enable dockerd.'
/etc/init.d/dockerd start || fail 'Cannot start dockerd.'
echo '== 5. Wait for Docker and LuCI =='
count=0
until docker info >/dev/null 2>&1; do
count=$((count + 1))
if [ "$count" -ge 30 ]; then
logread -e dockerd | tail -n 100
fail 'Docker startup timed out.'
fi
sleep 1
done
[ "$(docker info --format '{{.DockerRootDir}}')" = '/opt/docker' ] ||
fail 'Docker Root Dir is not /opt/docker.'
[ "$(docker info --format '{{.Driver}}')" = 'overlay2' ] ||
fail 'Docker storage driver is not overlay2.'
docker info --format '{{range .RegistryConfig.Mirrors}}{{println .}}{{end}}' |
grep -qx 'https://docker.1panel.live/' ||
fail 'Docker Hub mirror is not active.'
if command -v lua >/dev/null 2>&1; then
lua -e 'require("luci.docker")' ||
fail 'Lua cannot load luci.docker.'
fi
/etc/init.d/uhttpd status >/dev/null 2>&1 ||
fail 'uHTTPd is not running.'
echo '== 6. Wait for QWRT network reload and restore SSH =='
# dockerd post-install reloads QWRT network, including LAN, Wi-Fi and WAN.
# Do this before the network-dependent Docker checks below.
sleep 75
/etc/init.d/dockerd restart || fail 'Cannot restart dockerd after QWRT network reload.'
count=0
until docker info >/dev/null 2>&1; do
count=$((count + 1))
[ "$count" -lt 30 ] || fail 'Docker did not recover after QWRT network reload.'
sleep 1
done
ip link show docker0 >/dev/null 2>&1 ||
fail 'Docker bridge docker0 is missing after Docker recovery.'
if [ "$(uci -q get dropbear.main._direct)" = '1' ]; then
/etc/init.d/dropbear restart || fail 'Cannot restart Dropbear.'
sleep 2
fi
echo '== 7. Test image pull, bridge network and port publishing =='
docker pull "$IMAGE" || fail "Cannot pull $IMAGE."
docker run --rm "$IMAGE" sh -c \
'nslookup -type=A www.baidu.com >/dev/null && wget -qO- http://www.baidu.com >/dev/null' ||
fail 'Container DNS or outbound network test failed.'
docker run -d --name "$TEST_CONTAINER_NAME" -p 127.0.0.1:18080:80 \
"$IMAGE" sh -c \
'mkdir -p /www && echo Docker-OK >/www/index.html && httpd -f -p 80 -h /www' \
>/dev/null || fail 'Cannot start local HTTP test container.'
TEST_CONTAINER="$TEST_CONTAINER_NAME"
sleep 2
wget -qO- http://127.0.0.1:18080 | grep -qx 'Docker-OK' ||
fail 'Docker published-port test failed.'
docker rm -f "$TEST_CONTAINER" >/dev/null ||
fail 'Cannot remove local HTTP test container.'
TEST_CONTAINER=''
# Restore Dockerman's optional DOCKER-USER access-control chain after the
# Docker bridge and its test container have both passed.
/etc/init.d/dockerman enable || fail 'Cannot enable Dockerman access control.'
/etc/init.d/dockerman restart || fail 'Cannot start Dockerman access control.'
echo '== Docker installation passed =='
docker info --format \
'Root={{.DockerRootDir}} Driver={{.Driver}} Server={{.ServerVersion}}'
docker compose version
df -hT /opt/docker
echo 'Docker Hub mirror configured: https://docker.1panel.live/'
echo 'Use explicit proxy image names for reliable pulls in mainland China:'
echo ' docker.1panel.live/library/postgres:18-alpine'
echo ' docker.1panel.live/library/redis:8-alpine'
echo ' docker.1panel.live/weishaw/sub2api:latest'
if [ -n "$OLD_CONTAINERD" ] || [ -n "$OLD_DOCKER" ]; then
echo 'Old Docker state was archived and was not deleted.'
echo 'After confirming the new installation, remove it manually with:'
[ -z "$OLD_CONTAINERD" ] ||
echo " rm -rf '$OLD_CONTAINERD'"
[ -z "$OLD_DOCKER" ] ||
echo " rm -rf '$OLD_DOCKER'"
fi
保存上述脚本,在路由器运行,即可安装,保证纯净系统。
部署后验证
docker info --format 'Root={{.DockerRootDir}} Driver={{.Driver}} Server={{.ServerVersion}}'
docker compose version
docker run --rm \
docker.1panel.live/library/busybox:latest \
cat /etc/resolv.conf
/etc/init.d/dockerman enabled
echo "dockerman-enabled=$?"
预期:
Root=/opt/docker Driver=overlay2 Server=27.3.1
Docker Compose version v2.39.1
nameserver <路由器 br-lan IPv4 地址>
dockerman-enabled=0
最终验证结果

在恢复出厂后的全新 QWRT 环境中,脚本最终验证通过:
exit=0
Root=/opt/docker
Driver=overlay2
Docker Compose version v2.39.1
验证覆盖了软件包安装、Containerd、LuCI Lua 模块、docker0 恢复、镜像拉取、容器 DNS、容器出网、本机端口映射、Dockerman 启用,以及 SSH 在网络重载后的恢复。
注意事项
- 没有运行容器时,
docker0显示NO-CARRIER或state DOWN是正常现象。 - 不建议同时安装
luci-app-docker和luci-app-dockerman。 kmod-ipt-physdev提示在本机为已知非致命警告,最终以容器运行验收为准。- Docker 数据在
/opt/docker,旧数据会归档而非自动删除。