目录

NoneBot2 + NapCat 港服 Docker 部署实践:从踩坑到可复现部署

26

引言

目标听起来并不复杂:在香港云服务器上,用 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 ────────────┘

这里有两个关键点:

  1. 连接方向:NapCat 作为 WebSocket 客户端,主动连接 NoneBot 暴露的反向 WebSocket 地址。
  2. 文件路径: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_healthydepends_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下载 等。

此外,插件默认采用群列表黑名单模式:默认禁用所有群,只有显式启用的群才能使用。因此即使命令写对了,如果没有先启用当前群,也不会正常触发。

解决方案

  1. 打开 Debug 日志,确认 matcher 是否加载、是否命中。
  2. 直接看插件源码或 README,确认命令名和权限要求。
  3. 由超级用户在目标群内先执行:
/开启jm

或者:

/jm启用群 群号

坑五:构建上下文和 .dockerignore 位置也会踩坑

一开始我把 .dockerignore 放在项目根目录,但 Compose 里的构建上下文写的是:

build:
  context: ./bot
  dockerfile: Dockerfile

这意味着 Docker 的构建上下文根目录是 ./bot,因此真正生效的 .dockerignore 应该放在:

bot/.dockerignore

如果把它放在项目根目录,Docker 构建 ./bot 时根本不会读取它。这个问题不影响程序运行,但会让缓存目录、临时文件、历史产物被错误地送进构建上下文,导致构建变慢、镜像内容变脏,甚至把敏感配置打进镜像。


三、完整配置

版本说明:以下配置基于 nonebot-plugin-jmdownloader 1.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 有三个重点:

  1. 使用 python:3.11,满足插件版本要求。
  2. 依赖安装发生在镜像构建阶段,不放在容器启动阶段。
  3. 代码复制进镜像,生产环境不再依赖 ./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 的日志等级配置项,日志等级名称建议使用大写,例如 DEBUGINFO
  • 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

这里有几个和初版不同的地方:

  1. jm-bot 使用 expose,不使用 ports
    因为 8080 只需要给 Docker network 内的 NapCat 访问,不需要暴露到公网或宿主机。

  2. NapCat 的 depends_on 指向 jm-bot
    因为实际连接方向是 NapCat 主动连接 NoneBot。

  3. depends_on 使用 condition: service_healthy
    这要求使用较新的 Docker Compose V2,也就是 docker compose 命令,而不是老的 docker-compose V1。

  4. 不再挂载 ./bot:/app
    生产环境中,代码已经复制进镜像;只挂载缓存目录和 .env.prod

  5. 不强制写 container_name
    Compose 默认会生成容器名,服务名 jm-bot 仍然可以作为网络内主机名使用。除非你有固定容器名需求,否则不写更利于后续扩展和迁移。

开发版配置

如果还在频繁修改 bot.py,开发调试时可以临时使用 bind mount:

services:
  jm-bot:
    volumes:
      - ./bot:/app
      - ./bot/cache:/root/.cache/nonebot2

这样改代码后不一定需要重新 build。但它有两个副作用:

  1. ./bot:/app 会遮蔽镜像内的 /app 内容。
  2. 镜像本身不再是完整产物,换机器或换目录后不能独立运行。

所以建议:

  • 开发期:允许 ./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.0
  • PORT 是否为 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 镜像不是不能用,但它会让依赖安装问题更难判断。尤其是在排障阶段,业务链路、网络配置、插件依赖、协议连接都还没确定时,过早追求镜像体积只会增加变量。

更稳妥的做法是:

  1. 先用完整版镜像跑通。
  2. 锁定依赖版本。
  3. 确认启动、连接、文件发送都稳定。
  4. 再考虑 Slim、多阶段构建和镜像裁剪。

优化应该发生在正确性之后。

关于排障:日志和源码比直觉可靠

当机器人没有反应时,最自然的反应是反复试命令。但这其实是在猜。

真正有效的路径是:

  1. 打开 Debug 日志。
  2. 看 matcher 是否加载。
  3. 看 matcher 是否命中。
  4. 看插件源码或 README 里的命令注册。
  5. 再回到配置和权限排查。

“一行源码”往往比“一百次猜测”更快。

关于教程写作:能跑通不等于可复现

最开始那版配置只是“在我机器上能跑”。但一篇真正有用的部署教程,还要回答这些问题:

  • 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_levelsuperuserscommand_start
  • PyPI: nonebot-plugin-jmdownloader 1.3.0

首发于个人博客,2026 年 3 月;2026 年 6 月修订