NoneBot2 + NapCat 港服 Docker 部署实践:从踩坑到可复现部署
引言
目标听起来并不复杂:在香港云服务器上,用 Docker 跑一个 NoneBot2 机器人,通过 OneBot V11 协议接入 NapCat,让 QQ 群里的指令能够被机器人接收、处理,并把处理结果以文件形式发送回群聊。
方案选型也很标准:NoneBot2 作为 Python 机器人框架,NapCat 作为 QQ 协议端,Docker Compose 负责多容器编排。理论上只要两个容器网络互通、协议地址配置正确、文件路径可见,整条链路就应该工作。
但实际部署时,我从容器“假死”排到文件共享失败,从指令石沉大海排到依赖安装的沉默崩溃,整个过程持续了近十个小时。这篇文章把踩过的坑、排查思路和最终可复现配置整理出来。它既是一份操作手册,也是一篇关于 Docker 隔离机制、依赖管理和系统排障方法的复盘。
本文只讨论容器化部署、机器人框架接入和故障排查方法。涉及第三方站点、内容下载或群聊分发时,请自行确认用途符合当地法律法规、平台规则和群聊管理规范。
一、架构概览
这套部署采用 Docker Multi-Container 架构。两个核心容器通过同一个自定义 Docker network 通信:
┌─────────────┐ OneBot V11 Reverse WebSocket ┌──────────────┐
│ NapCat │ ───────────────────────────────► │ jm-bot │
│ QQ 协议端 │ ws://jm-bot:8080/... │ NoneBot2 │
│ port:6099 │ │ port:8080 │
└──────┬──────┘ └──────┬───────┘
│ │
└──────────── 共享 bind mount:cache ────────────┘
这里有两个关键点:
- 连接方向:NapCat 作为 WebSocket 客户端,主动连接 NoneBot 暴露的反向 WebSocket 地址。
- 文件路径:NoneBot 生成的文件需要让 NapCat 也能在同一路径下访问,否则协议端拿不到文件。
后面的大部分问题,本质上都和这两个点有关。
二、五个核心踩坑点
坑一:启动时序竞争——“容器启动了”不等于“服务 Ready 了”
现象:重启服务后,NapCat 日志持续报:
ECONNREFUSED 172.19.0.x:8080
原因:一开始我把 pip install 写进了 jm-bot 容器的启动命令里。每次容器启动,都要先从网络下载安装 Python 依赖。在依赖安装完成之前,bot.py 根本没有运行,8080 端口也不会监听。
而 NapCat 启动很快,几乎立刻尝试连接 jm-bot:8080,自然就会被拒绝。
更关键的是,原配置里写的是:
jm-bot:
depends_on:
- napcat
但实际连接方向是 NapCat 主动连接 NoneBot。从依赖关系看,应该是 NapCat 等 jm-bot,而不是反过来。
解决方案分两层:
第一层,把依赖安装前置到镜像构建阶段。容器启动时只运行 bot.py,不再动态安装依赖。
第二层,使用 Docker Compose 的 healthcheck + condition: service_healthy。depends_on 默认只保证容器按顺序创建,不保证服务已经可用;加上健康检查之后,NapCat 会等到 jm-bot 的 8080 端口真正可连接后再启动。
坑二:Slim 镜像不是原罪,但它会放大依赖问题
现象:为了节省镜像体积,我一开始使用了 python:3.10-slim。容器状态显示“运行中”,但日志完全空白,不报错,也不工作。
原因:Slim 镜像本身不是问题。真正的问题是:当某个 Python 包没有命中当前平台可用的预编译 wheel、不得不从源码构建时,Slim 镜像往往缺少编译工具链、系统头文件和运行库,安装过程就可能失败、卡住,甚至因为日志缓冲看起来像“什么都没发生”。
再加上 Python 默认 stdout 有缓冲,如果没有设置 PYTHONUNBUFFERED=1,安装失败或启动失败的信息可能不会及时刷到 Docker 日志里。
解决方案:排障阶段优先使用完整版 python:3.11,减少变量,先把业务链路跑通。等链路稳定后,再考虑切换到 python:3.11-slim,并显式安装构建依赖,或者使用多阶段构建来缩小最终镜像。
这次的经验是:先保证正确性,再优化镜像体积。
坑三:文件隔离——“文件已生成”不代表协议端能读到
现象:漫画下载完成、PDF 打包成功、日志显示一切正常,但最终发送文件失败。NapCat 日志里出现类似:
识别URL失败, uri=/root/.cache/nonebot2/.../xxx.pdf
原因:这是 Docker 容器隔离导致的典型问题。jm-bot 把文件生成在自己的容器文件系统里,然后通过 OneBot 协议告诉 NapCat:
去 /root/.cache/nonebot2/.../xxx.pdf 读取文件
但 NapCat 容器里并没有这个文件。两个容器的文件系统彼此隔离,除非显式挂载共享目录,否则 A 容器里的路径对 B 容器不可见。
解决方案:把同一个宿主机目录同时挂载到两个容器中,并且容器内目标路径必须一致:
volumes:
- ./bot/cache:/root/.cache/nonebot2
这里严格来说是 bind mount,不是 Docker named volume。它的含义是:把宿主机上的 ./bot/cache 目录挂载到容器内的 /root/.cache/nonebot2。
关键不是“用了 volume”这个词,而是:
jm-bot写入的路径,必须也是 NapCat 容器内真实存在、可读取的路径。
坑四:指令匹配偏差——猜命令名是最低效的排障方式
现象:网络通了,文件共享也通了,但在群里发送:
/jm 12345
机器人毫无反应。Debug 日志显示:
Checking for matchers completed
也就是说,NoneBot 完成了匹配器检查,但没有任何 matcher 命中。
原因:我凭使用习惯猜了命令名。后面直接看插件源码和 README 才发现,插件并没有注册 /jm 这条命令,实际命令是 /jm搜索、/jm下载 等。
此外,插件默认采用群列表黑名单模式:默认禁用所有群,只有显式启用的群才能使用。因此即使命令写对了,如果没有先启用当前群,也不会正常触发。
解决方案:
- 打开 Debug 日志,确认 matcher 是否加载、是否命中。
- 直接看插件源码或 README,确认命令名和权限要求。
- 由超级用户在目标群内先执行:
/开启jm
或者:
/jm启用群 群号
坑五:构建上下文和 .dockerignore 位置也会踩坑
一开始我把 .dockerignore 放在项目根目录,但 Compose 里的构建上下文写的是:
build:
context: ./bot
dockerfile: Dockerfile
这意味着 Docker 的构建上下文根目录是 ./bot,因此真正生效的 .dockerignore 应该放在:
bot/.dockerignore
如果把它放在项目根目录,Docker 构建 ./bot 时根本不会读取它。这个问题不影响程序运行,但会让缓存目录、临时文件、历史产物被错误地送进构建上下文,导致构建变慢、镜像内容变脏,甚至把敏感配置打进镜像。
三、完整配置
版本说明:以下配置基于
nonebot-plugin-jmdownloader1.3.0。该版本发布于 2026-03-24,要求 Python>=3.11,<4.0。如果你使用其他版本,请以对应版本的 README / PyPI / 源码为准。
目录结构
/opt/docker-compose/jm-bot/
├── bot/
│ ├── bot.py
│ ├── .env.prod
│ ├── Dockerfile
│ ├── requirements.in
│ ├── requirements.lock.txt
│ ├── .dockerignore
│ └── cache/ # 与 NapCat 共享,自动生成
├── napcat/
│ ├── config/
│ └── qq/
└── docker-compose.yml
注意:因为 build.context 是 ./bot,所以 .dockerignore 必须放在 bot/ 目录下。
bot/requirements.in
requirements.in 用来声明顶层依赖:
nonebot2[fastapi]
nonebot-adapter-onebot
nonebot-plugin-alconna
nonebot-plugin-jmdownloader==1.3.0
nonebot-plugin-apscheduler
然后生成锁定文件:
cd /opt/docker-compose/jm-bot/bot
uv pip compile requirements.in -o requirements.lock.txt
如果没有使用 uv,也可以在本地虚拟环境跑通后执行:
pip freeze > requirements.lock.txt
排障阶段可以先用宽松版本跑通;发布教程或正式部署时,建议提交锁定后的 requirements.lock.txt,否则未来依赖升级后,读者复制同一份配置得到的可能已经不是同一套依赖图。
bot/.dockerignore
__pycache__/
*.pyc
*.pyo
*.pyd
.env
.env.*
!.env.example
cache/
.venv/
.git/
*.log
这里把 .env.prod 排除在镜像外,是为了避免把敏感配置打进镜像。后面通过 Compose 以只读 bind mount 的方式挂载进去。
bot/Dockerfile
FROM python:3.11
WORKDIR /app
ENV PYTHONUNBUFFERED=1
# 先复制依赖锁文件,充分利用 Docker 层缓存
COPY requirements.lock.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.lock.txt
# 再复制应用代码
COPY bot.py .
CMD ["python", "bot.py"]
这个 Dockerfile 有三个重点:
- 使用
python:3.11,满足插件版本要求。 - 依赖安装发生在镜像构建阶段,不放在容器启动阶段。
- 代码复制进镜像,生产环境不再依赖
./bot:/app这种开发式挂载。
bot/.env.prod
排障期可以临时使用更宽松的配置,但生产环境建议保留限制:
ENVIRONMENT=prod
DRIVER=~fastapi
HOST=0.0.0.0
PORT=8080
LOG_LEVEL=DEBUG
SUPERUSERS=["你的QQ号"]
COMMAND_START=["/"]
# 插件配置:生产环境建议保留限制,避免滥用
JMCOMIC_GROUP_LIST_MODE=blacklist
JMCOMIC_ALLOW_PRIVATE=True
JMCOMIC_USER_LIMITS=5
JMCOMIC_OUTPUT_FORMAT=pdf
JMCOMIC_MAX_PAGE_COUNT=200
JMCOMIC_ALLOW_ALBUM_DOWNLOAD=False
JMCOMIC_PUNISH_ON_VIOLATION=True
几个关键点:
LOG_LEVEL是 NoneBot 的日志等级配置项,日志等级名称建议使用大写,例如DEBUG或INFO。JMCOMIC_GROUP_LIST_MODE=blacklist表示默认禁用所有群,只有显式启用的群才能使用。JMCOMIC_USER_LIMITS=5是每位用户每周下载次数限制。JMCOMIC_MAX_PAGE_COUNT=200用来限制单次下载页数,避免一次请求生成过大的文件。JMCOMIC_PUNISH_ON_VIOLATION=True表示普通用户下载违规内容时会被阻止并触发惩罚逻辑。
如果只是排障,可以临时放宽:
JMCOMIC_USER_LIMITS=99999
JMCOMIC_PUNISH_ON_VIOLATION=False
但不建议把这种配置作为生产示例。群聊机器人需要默认最小权限、有限额度、可追踪日志。
NapCat WebSocket 配置
在 NapCat WebUI 中添加 OneBot V11 反向 WebSocket 客户端:
类型:WebSocket Client / 反向 WebSocket
地址:ws://jm-bot:8080/onebot/v11/ws
启用:是
注意,两个容器在同一个 Docker network 里时,不能写 127.0.0.1。在 NapCat 容器内,127.0.0.1 指向的是 NapCat 自己,而不是 jm-bot。
这里应该写 Compose 服务名:
jm-bot
Docker 的内置 DNS 会把它解析到对应容器。
NoneBot OneBot 适配器支持的 OneBot V11 反向 WebSocket 路径包括:
ws://jm-bot:8080/onebot/v11/
ws://jm-bot:8080/onebot/v11/ws
ws://jm-bot:8080/onebot/v11/ws/
本文使用:
ws://jm-bot:8080/onebot/v11/ws
docker-compose.yml
下面是生产版配置:
services:
jm-bot:
build:
context: ./bot
dockerfile: Dockerfile
hostname: jm-bot
expose:
- "8080"
volumes:
# 共享缓存目录:jm-bot 写文件,NapCat 读文件
- ./bot/cache:/root/.cache/nonebot2
# 敏感配置只读挂载,避免打进镜像
- ./bot/.env.prod:/app/.env.prod:ro
healthcheck:
test: ["CMD-SHELL", "python -c 'import socket; s=socket.socket(); s.settimeout(2); s.connect((\"127.0.0.1\", 8080)); s.close()'"]
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
networks:
- bot-net
restart: always
napcat:
image: mlikiowa/napcat-docker:latest
hostname: napcat
environment:
- WEBUI_TOKEN=你的登录密码
ports:
- "6099:6099"
volumes:
- ./napcat/config:/app/napcat/config
- ./napcat/qq:/app/.config/QQ
# 必须和 jm-bot 挂载到同一个容器内路径
- ./bot/cache:/root/.cache/nonebot2
depends_on:
jm-bot:
condition: service_healthy
networks:
- bot-net
restart: always
networks:
bot-net:
driver: bridge
这里有几个和初版不同的地方:
-
jm-bot使用expose,不使用ports。
因为 8080 只需要给 Docker network 内的 NapCat 访问,不需要暴露到公网或宿主机。 -
NapCat 的
depends_on指向jm-bot。
因为实际连接方向是 NapCat 主动连接 NoneBot。 -
depends_on使用condition: service_healthy。
这要求使用较新的 Docker Compose V2,也就是docker compose命令,而不是老的docker-composeV1。 -
不再挂载
./bot:/app。
生产环境中,代码已经复制进镜像;只挂载缓存目录和.env.prod。 -
不强制写
container_name。
Compose 默认会生成容器名,服务名jm-bot仍然可以作为网络内主机名使用。除非你有固定容器名需求,否则不写更利于后续扩展和迁移。
开发版配置
如果还在频繁修改 bot.py,开发调试时可以临时使用 bind mount:
services:
jm-bot:
volumes:
- ./bot:/app
- ./bot/cache:/root/.cache/nonebot2
这样改代码后不一定需要重新 build。但它有两个副作用:
./bot:/app会遮蔽镜像内的/app内容。- 镜像本身不再是完整产物,换机器或换目录后不能独立运行。
所以建议:
- 开发期:允许
./bot:/app - 生产期:使用 Dockerfile 的
COPY,只挂载缓存和配置
四、运行与使用
1. 首次启动
建议使用 Docker Compose V2 命令:
cd /opt/docker-compose/jm-bot
docker compose up -d --build
后续重启:
docker compose up -d
查看状态:
docker compose ps
查看日志:
docker compose logs -f jm-bot
docker compose logs -f napcat
2. 确认健康检查
如果 NapCat 一直没有启动,先看 jm-bot 的健康状态:
docker inspect --format='{{json .State.Health}}' jm-bot
如果健康检查失败,优先检查:
bot.py是否真的启动了 NoneBot.env.prod是否被正确挂载HOST是否为0.0.0.0PORT是否为8080- 插件是否安装并加载成功
3. 启用群聊
插件默认群列表模式为 blacklist,也就是默认禁用所有群。需要超级用户在目标群发送:
/开启jm
或者私聊机器人:
/jm启用群 群号
4. 群友使用指令
以下指令基于 nonebot-plugin-jmdownloader 1.3.0。所有指令都需要带上 COMMAND_START 前缀;本文配置为 /。
| 操作 | 指令 |
|---|---|
| 搜索 | /jm搜索 关键词 |
| 搜索结果翻页 | /jm下一页 |
| 查询详情 | /jm查询 漫画ID |
| 下载 | /jm下载 漫画ID |
| 下载指定章节 | /jm下载集 漫画ID 章节 |
| 设置群文件夹 | /jm设置文件夹 文件夹名 |
| 启用本群 | /开启jm |
| 禁用本群 | /关闭jm |
| 启用指定群 | /jm启用群 群号 |
| 禁用指定群 | /jm禁用群 群号 |
五、排障清单
如果照着配置部署后仍然不工作,可以按下面顺序排查。
1. NapCat 是否能解析 jm-bot
进入 NapCat 容器:
docker compose exec napcat sh
测试 DNS:
getent hosts jm-bot
如果解析不到,说明两个容器不在同一个 Docker network。
2. NapCat 是否能连上 8080
在 NapCat 容器里测试:
python - <<'PY'
import socket
s = socket.socket()
s.settimeout(3)
s.connect(("jm-bot", 8080))
print("ok")
s.close()
PY
如果连接失败,优先看 jm-bot 是否启动、健康检查是否通过、端口是否监听。
3. NoneBot 是否加载了 OneBot 适配器
bot.py 里应当包含类似逻辑:
import nonebot
from nonebot.adapters.onebot.v11 import Adapter as ONEBOT_V11Adapter
nonebot.init()
driver = nonebot.get_driver()
driver.register_adapter(ONEBOT_V11Adapter)
nonebot.load_plugin("nonebot_plugin_jmdownloader")
if __name__ == "__main__":
nonebot.run()
如果没有注册 OneBot V11 适配器,NapCat 即使连上端口,也无法完成协议交互。
4. 文件路径是否在两个容器内都存在
分别进入两个容器检查:
docker compose exec jm-bot ls -lah /root/.cache/nonebot2
docker compose exec napcat ls -lah /root/.cache/nonebot2
如果 jm-bot 里有文件、NapCat 里没有,说明 bind mount 没挂对,或者两个容器内目标路径不一致。
5. 命令是否真的命中 matcher
把日志等级调成:
LOG_LEVEL=DEBUG
然后观察 jm-bot 日志:
docker compose logs -f jm-bot
如果只看到 Checking for matchers completed,但没有处理函数日志,通常说明:
- 指令名写错了
COMMAND_START不匹配- 当前群没有启用
- 权限不满足
- 插件没有加载成功
六、复盘:这次部署真正教会了我什么
关于 Docker:隔离不是魔术
Docker 的便利性容易让人产生一种错觉:容器一编排,服务就应该自动互通。实际上,Docker 默认给每个容器提供独立的文件系统、网络命名空间和运行环境。
网络要显式放进同一个 network。文件要显式挂载共享目录。启动顺序要显式声明依赖关系。服务是否 ready 还要靠健康检查确认。
这次遇到的启动时序竞争和文件路径不可见,本质上都不是 Docker 的问题,而是我一开始没有把隔离边界想清楚。
关于镜像选择:先跑通,再优化
Slim 镜像不是不能用,但它会让依赖安装问题更难判断。尤其是在排障阶段,业务链路、网络配置、插件依赖、协议连接都还没确定时,过早追求镜像体积只会增加变量。
更稳妥的做法是:
- 先用完整版镜像跑通。
- 锁定依赖版本。
- 确认启动、连接、文件发送都稳定。
- 再考虑 Slim、多阶段构建和镜像裁剪。
优化应该发生在正确性之后。
关于排障:日志和源码比直觉可靠
当机器人没有反应时,最自然的反应是反复试命令。但这其实是在猜。
真正有效的路径是:
- 打开 Debug 日志。
- 看 matcher 是否加载。
- 看 matcher 是否命中。
- 看插件源码或 README 里的命令注册。
- 再回到配置和权限排查。
“一行源码”往往比“一百次猜测”更快。
关于教程写作:能跑通不等于可复现
最开始那版配置只是“在我机器上能跑”。但一篇真正有用的部署教程,还要回答这些问题:
- Python 版本是否匹配插件要求?
- 依赖是否锁定?
- 构建上下文是否干净?
- 生产环境是否避免挂载代码目录?
- 服务是否真的 ready?
- 端口是否不必要地暴露到宿主机?
- 文件路径是否在两个容器里一致?
- 指令表是否对应当前插件版本?
- 风险配置是否被读者误以为可以直接照抄?
把这些细节补齐之后,文章才从“个人踩坑记录”变成“别人也能复用的部署指南”。
结语
这次部署看起来只是把 NoneBot2 和 NapCat 放进两个容器里,但真正耗时的地方不是写 YAML,而是理解为什么“配置看起来没问题,系统却不工作”。
最终沉淀下来的经验,比跑通一个机器人更有价值:
depends_on不等于服务 ready;- 容器 A 的文件不等于容器 B 可见;
- 反向 WebSocket 要看清连接方向;
- 依赖安装应该发生在构建阶段;
- 生产环境不要随便暴露内部端口;
- 排障时,日志和源码永远比感觉可靠。
如果你也在做类似部署,希望这篇文章能帮你少踩几个坑,把时间花在真正值得折腾的地方。
参考资料
- Docker Docs: Control startup and shutdown order in Compose
- Docker Docs: Bind mounts
- NoneBot OneBot Adapter: 配置连接
- NoneBot Docs: 配置项
log_level、superusers、command_start - PyPI: nonebot-plugin-jmdownloader 1.3.0
首发于个人博客,2026 年 3 月;2026 年 6 月修订