上一篇把 Docker 环境铺好之后,这次直接把签名服务器跑起来。很多人在做 QQ 机器人时卡住的第一道坎就在这里:框架的日志能正常启动、事件也能收到,但一到发消息就报错,或者干脆上线就掉线,说到底就是缺一个稳定工作的签名服务器。我这次搭的是 bs-qsign 这套实现,用 Docker 把它单独隔离成一个容器,对外只暴露一个 HTTP 接口,机器人框架那边按地址去调就行。这种方式最大的好处是签名服务崩了不影响框架、框架重装不用动签名、升级镜像只换一个 tag。下面这篇把我从零到跑通的完整过程写下来,包括目录怎么规划、参数怎么配、接口怎么自测、容量怎么算,以及那些只有实际跑起来才会撞上的坑。
1. 先把架构捋清楚:签名服务器在链路里到底站什么位置
1.1 机器人框架为什么会单独需要一个签名服务
一句话概括:现在的第三方协议实现里,服务端下发和校验的关键字段,是必须由客户端侧的本地库参与计算出来的。你在框架里收到的明文消息、要发出去的消息体,在协议层面都得带上这一串签名值才算合法。这个计算过程依赖一份与协议版本强绑定的二进制库,它跑不起来,框架就只能收事件、发不出消息。
这就解释了为什么很多人第一次搭环境会出现“能登录、能看日志、就是发不出消息”的状态——框架本身没问题,缺的是背后那个算签名的进程。把签名单独做成一个 HTTP 服务,好处是解耦:签名服务负责算,框架负责业务,两边用一层接口隔开。框架侧只要配一个sign_api_addr之类的地址,剩下的事情交给签名服务。
我个人的理解是,把这层做成独立服务,本质上是把“易变的、和协议版本强相关的部分”从“稳定的、写业务逻辑的部分”里剥出来。因为这份本地库会随协议变化而更新,一旦内嵌进框架,每次更新就得重编译、重发布整个机器人;拆成 HTTP 服务之后,我只需要换一个镜像 tag,框架一行代码都不用动。
1.2 为什么用 Docker 跑,而不是直接装在宿主机
直接装在宿主机当然也能跑,但我试过之后还是回到了 Docker,理由很实际:
- 依赖隔离干净。这类签名服务内部要加载本地动态库,对系统库版本、glibc、时区数据都有要求。宿主机上你还有一堆别的服务,万一哪天升级个系统包把某个依赖顶掉了,签名服务先趴窝,而且排查起来极其难受。容器里跑,基础镜像就是它的小世界,别人动不到。
- 版本回滚快。签名服务的版本更新频率取决于协议变化,有时候还得留一个旧版本兜底。Docker 环境下打两个 tag 并存,回滚就是改一行
image字段加docker compose up -d,十秒钟的事情。宿主机上你得留一堆目录、改一堆软链接,容易出错。 - 资源边界可控。一个签名服务吃多少内存、占几个核,用
--memory和--cpus卡死,它再狂也影响不到宿主机上跑的其他东西。这一点在实际跑起来之后特别重要,因为某些实现遇到格式异常的数据包时内存会短时间飙一下。 - 日志集中。
docker logs加日志轮转参数,比在宿主机上满地找.log文件清爽得多。
真正让我下决心的是有一次排查内存泄漏:宿主机直装的情况下,进程 OOM 直接把整台机器的可用内存吃光,连带别的服务一起挂;容器化之后--memory=1g一卡,它自己被杀掉重启,我的框架和数据库纹丝不动。
1.3 整体拓扑与端口规划
我习惯先把端口规划清楚再动手,省得后面改起来牵一发动全身。这次的拓扑很简单:签名服务和机器人框架都在同一台机器上,两者走 Docker 内部网络或者127.0.0.1通信,对外一概不暴露。
| 服务 | 容器内端口 | 宿主机映射 | 暴露范围 | 说明 |
|---|---|---|---|---|
| 签名服务 | 8080 | 18080 | 仅 127.0.0.1 | 只给框架调用,不对公网开放 |
| 机器人框架 | 3000 | 13000 | 内网 | 管理后台,建议加认证 |
| 数据库 | 6379 | 不映射 | 仅容器网络 | 状态缓存用 |
端口映射这里有个细节值得说:务必写成-p 127.0.0.1:18080:8080,而不是-p 18080:8080。后者的意思是所有网卡都监听,云主机上等于把签名接口直接摆到公网上,任何人都能拿你的服务算签名,轻则资源被人白嫖,重则被拿去干别的。我见过不止一次因为图省事写成全监听,结果机器被扫到的情况。多打两个字符127.0.0.1:,能省掉后面一大堆麻烦。
另外,如果你打算把签名服务和框架都放在一个docker-compose.yml里,那更简单:不用映射端口,直接用服务名互相访问,比如框架里填http://qsign:8080。前提是两边在同一个自定义网络里,Docker 的内置 DNS 能解析服务名。我后面给的例子会同时给出两种写法。
2. 环境准备与镜像落地:把依赖一次装齐
2.1 宿主机基础检查,别急着拉镜像
拿到一台机器先别急着docker run,花三分钟做几项检查,能挡掉后面 80% 的莫名其妙的问题。
# 1. 内核和架构 uname -a uname -m # x86_64 还是 aarch64,直接影响能用哪个镜像 # 2. Docker 版本与 cgroup 驱动 docker version docker info | grep -iE "cgroup|storage driver" # 3. 磁盘 df -h /var/lib/docker # 4. 时区与时间同步 timedatectl这几项里我最看重的两个是架构和时间同步。
架构这块,签名服务依赖的本地动态库大概率只提供 x86_64 版本。如果你手上是一台 ARM 的机器(比如某些云厂商的廉价实例),直接跑 amd64 镜像会走 QEMU 模拟层,性能掉到原来的二三十分之一,签名延迟从几十毫秒涨到一两秒,框架那边超时直接判定失败。这种情况下的正解是换一台 x86 的机器,而不是加--platform硬扛。我在 ARM 上试过一次,单次签名稳定在 1.5 秒左右,容器 CPU 打满,完全没法用。
时间同步这块,签名计算里会用时间戳参与,容器时间和宿主机时间偏差太大,可能出现算出来的结果对不上的情况。timedatectl看System clock synchronized: yes就放心了,如果是no,装个时间同步服务先把这个解决掉。
磁盘顺带提一句:镜像本身通常几百 MB 到 1 GB 不等,加上日志,/var/lib/docker至少留 10 GB 余量。我遇到过磁盘写满导致容器重启循环的情况,日志里看不出任何有价值的信息,全是被截断的写入失败。
2.2 目录结构规划:把配置和数据分开放
我习惯把容器的可变部分全部挂出来,容器本身当成无状态的。这样删了重建一点不心疼。
mkdir -p /opt/qsign/{conf,data,logs} chmod -R 755 /opt/qsign三个目录的分工:
conf:配置文件,比如端口、线程数、缓存开关、协议相关参数。这个目录我会用 git 管一下,改了什么有记录。data:运行时产生的数据,比如会话状态、缓存落盘文件(如果实现支持)。这个目录定期备份,但不用进版本控制。logs:日志文件。虽然大部分时候看docker logs就够,但有些实现会自己写文件,分开放便于轮转。
权限这里有个小坑:容器内的进程不一定以 root 跑,有些镜像会切到非特权用户(比如 UID 1000)。如果挂载目录属主是 root 且权限是 700,容器内的进程写不进去,表现是启动成功了但配置文件读不到、或者缓存目录创建失败。稳妥的做法是先把权限放开到 755,跑通之后再根据实际 UID 收紧。查容器内实际 UID 的方法:
docker run --rm --entrypoint id <镜像名>拿到 UID 之后chown -R 1000:1000 /opt/qsign/data,比无脑 777 靠谱得多。
2.3 镜像获取与版本核对
镜像来源上我建议按实际情况选:能自己构建就自己构建,构建脚本控制在手里;用别人维护的镜像,就一定把 tag 钉死,别用latest。
# 假设你已经在本地构建好或者从私有仓库拉到 docker images | grep qsign # 核对镜像架构,避免 ARM 上误用 amd64 docker inspect --format '{{.Architecture}} {{.Os}}' <镜像名>:<tag>钉死 tag 这件事我是吃过亏的。早期图省事用了latest,某次docker compose pull之后签名服务换了个版本,接口返回的字段名变了(把token改成了signToken之类),框架那边解析失败,整晚消息都发不出去,而我压根没意识到自己“升级”过。从那之后我所有生产环境容器的 tag 都写成具体的版本号或者日期戳,升级是显式动作,绝不自动发生。
还有一点:把镜像 ID 记下来。docker inspect --format '{{.Id}}' <镜像名>:<tag>,记在你的变更日志里。真出了问题,能确定当前跑的到底是哪一份产物。
3. 容器启动参数详解与配置落地
3.1 docker run 版本:先手动跑一遍
第一遍我建议用docker run手动跑,参数一个个加上去,出问题好定位。跑通了再翻译成 compose 文件。
docker run -d \ --name qsign \ --restart unless-stopped \ -p 127.0.0.1:18080:8080 \ -e TZ=Asia/Shanghai \ -e JAVA_OPTS="-Xms256m -Xmx768m" \ -v /opt/qsign/conf:/app/conf \ -v /opt/qsign/data:/app/data \ -v /opt/qsign/logs:/app/logs \ --memory=1g \ --memory-swap=1g \ --cpus=1.5 \ --log-opt max-size=50m \ --log-opt max-file=3 \ --health-cmd="curl -fsS http://127.0.0.1:8080/ || exit 1" \ --health-interval=30s \ --health-timeout=5s \ --health-retries=3 \ <镜像名>:<tag>参数看上去多,但每个都有明确用途,我逐个说下为什么这么配。
-e TZ=Asia/Shanghai:容器默认是 UTC。签名计算里涉及时间戳的地方,如果服务内部把时间格式化成字符串参与运算,时区不一致可能导致结果偏差。这个不是必然出问题,但加上没有任何成本,我统一加。
JAVA_OPTS里的堆大小:这是给基于 JVM 的实现准备的(bs-qsign 这类常见实现很多是 Java 写的)。-Xms256m -Xmx768m的意思是初始堆 256 MB、最大堆 768 MB。为什么设这个值?因为容器总内存卡了 1 GB,堆最大 768 MB,留给堆外内存(本地库加载、直接内存、元空间、线程栈)大约 200 多 MB,是个比较安全的比例。如果堆和容器上限设成一样大,堆外内存一涨,容器就被 OOM Killer 干掉,日志里只有一句Killed,非常难查。
--memory-swap=1g和--memory=1g相等,意思是禁用 swap。签名服务对延迟敏感,一旦用上 swap,单次签名从 50 ms 变成几百毫秒甚至秒级,框架侧超时率飙升。宁可它被杀掉重启,也别慢吞吞地活着。
--cpus=1.5:给 1.5 核。这个数字不是拍脑袋来的——签名服务大部分时间是空闲的,但并发请求上来时会吃满单核。给 1.5 核既能应对突发,又不至于和框架抢 CPU。
--log-opt max-size=50m --log-opt max-file=3:限制单个日志文件 50 MB,最多保留 3 个。不设的话,某些实现 debug 级别的日志一天能写几个 G,把磁盘吃满。这个是我强烈建议加的,成本为零。
--health-cmd:健康检查。注意我这里是curl -fsS http://127.0.0.1:8080/,路径要按实际实现调整。如果你的镜像里没有 curl,用wget -q --spider或者干脆写个 TCP 探测。健康检查的价值在于:容器进程活着不代表服务可用,加了这个之后docker ps能看到healthy/unhealthy,编排工具也能据此重启。
3.2 compose 版本:稳定运行的推荐形态
手动跑通之后,我把配置固化成 compose 文件。这样重启机器、升级版本都是一条命令的事。
services: qsign: image: <镜像名>:<tag> container_name: qsign restart: unless-stopped environment: TZ: Asia/Shanghai JAVA_OPTS: "-Xms256m -Xmx768m" volumes: - ./conf:/app/conf - ./data:/app/data - ./logs:/app/logs ports: - "127.0.0.1:18080:8080" deploy: resources: limits: memory: 1g cpus: "1.5" logging: driver: json-file options: max-size: "50m" max-file: "3" healthcheck: test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/ || exit 1"] interval: 30s timeout: 5s retries: 3 start_period: 90sstart_period: 90s这个参数很关键,我单独说一下。签名服务启动时要加载本地动态库、初始化内部引擎,首次启动可能花 30 到 90 秒。这段时间内服务还没就绪,健康检查会连续失败。如果不设start_period,容器可能在启动过程中就被判定 unhealthy,编排工具直接把你重启掉,进入重启循环,永远起不来。我最早就是因为这个,容器一直反复重启,日志里每次都是初始化到一半被 kill。加上start_period之后彻底解决。
如果你的编排环境支持depends_on加健康检查条件,框架那边可以这么写:
bot-framework: depends_on: qsign: condition: service_healthy这样能保证框架在签名服务真正可用之后才启动,不用在代码里做重试。
3.3 配置文件里最容易配错的四个地方
很多实现的配置是通过配置文件而不是环境变量传的。我列几个我实际踩过的点。
第一,端口要和服务内部监听一致。配置文件里写的端口是容器内监听端口(比如 8080),跟宿主机映射的 18080 完全没关系。我见过有人在配置文件里写 18080,结果容器内监听 18080、映射写18080:8080,两边对不上,一直连不通。
第二,线程数和并发要匹配。有些实现默认线程池是 2,遇到并发请求就会排队。如果你的机器人账号多、消息频率高,排队一长就超时。我一般按 CPU 核数 × 2 设置,比如 1.5 核,线程池设 4 比较合适,再多就是互相抢 CPU,反而增加单次延迟。
第三,缓存开关要开。同一批签名请求里存在大量重复,比如相同命令字、相同序列、相同数据体的组合。开缓存之后命中率往往很高,能显著降低 CPU 占用。缓存大小给个 256 MB 到 512 MB 就够,别贪大,容器内存总共才 1 GB。
第四,日志级别别设在 debug。调试时开 debug 没问题,跑稳之后一定改回 info 甚至 warn。debug 级别下每次签名请求都会打几行日志,机器人在线多了,日志量很可观,既费磁盘又拖性能。
改完配置记得docker compose restart qsign,注意是 restart 不是 reload——大部分实现不监听配置变更,必须重启才生效。
4. 联调验证:从 curl 到框架对接
4.1 接口自测,先确认服务真的活着
容器状态healthy只说明进程活着、端口通了,不代表签名功能正常。我一般分三层验证,逐层往上。
第一层,探活。
curl -i http://127.0.0.1:18080/能返回 200 或者一段简短的文本,说明 HTTP 服务起来了。如果是 404,说明根路径没实现,换个已知路径再试,比如/ping。
第二层,看启动日志有没有报错。
docker logs --tail 100 qsign重点看有没有UnsatisfiedLinkError、no such file、Permission denied这类关键字。前者说明本地库没加载上,通常还是架构或者依赖问题;后者一般是挂载目录权限问题。
第三层,真实调用一次签名接口。这一步需要构造一个符合实现要求的请求。不同实现参数名有差异,常见的是查询字符串形式:
curl -s -G "http://127.0.0.1:18080/sign" \ --data-urlencode "uin=1000xxxxx" \ --data-urlencode "qua=某个客户端版本串" \ --data-urlencode "cmd=wtlogin.login" \ --data-urlencode "seq=1" \ --data-urlencode "buffer=00112233" \ --data-urlencode "data=0a0b0c"返回通常是一个 JSON,里面带code、msg和签名结果字段。code=0一般代表成功。字段名以你实际用的实现为准,别照抄。
curl -G --data-urlencode这个组合我是强烈推荐的,因为buffer这类字段常常是十六进制字符串,长度可能几百上千字符,直接拼在 URL 里容易因为特殊字符被截断或转义错误。用--data-urlencode让 curl 自己处理编码,省心。
4.2 写个小脚本批量验证,比手敲靠谱
单个 curl 只能证明“能跑”,说明不了“跑得稳”。我写了个十几行的 Python 脚本,循环调 50 次,记录成功率和耗时分布。
import time import requests URL = "http://127.0.0.1:18080/sign" params = { "uin": "1000xxxxx", "qua": "某个客户端版本串", "cmd": "MessageSvc.PbSendMsg", "seq": "1", "buffer": "00112233", "data": "0a0b0c", } ok, fail, costs = 0, 0, [] for i in range(50): t0 = time.time() try: r = requests.get(URL, params=params, timeout=5) dt = (time.time() - t0) * 1000 costs.append(dt) if r.status_code == 200 and r.json().get("code") == 0: ok += 1 else: fail += 1 print("异常返回:", r.status_code, r.text[:200]) except Exception as e: fail += 1 print("请求异常:", e) costs.sort() print(f"成功 {ok} / 失败 {fail}") print(f"P50 {costs[len(costs)//2]:.0f} ms P95 {costs[int(len(costs)*0.95)]:.0f} ms 最大 {costs[-1]:.0f} ms")跑出来的数据比自己瞎猜有用得多。我这边第一次跑的典型结果是这样:首批 5 次里有 2 次超过 800 ms,之后稳定在 40 到 70 ms 之间。前几次慢是因为引擎在预热、本地库在首次加载,属于正常现象。如果你跑 50 次全程都是几百毫秒,那就要查了——大概率是容器内存不够、在走 swap,或者 CPU 被限得太死。
4.3 对接到框架与容量估算
框架那边的配置很简单,找到签名服务地址那一栏,填http://127.0.0.1:18080(同机走宿主机映射端口)或者http://qsign:8080(同 compose 网络走服务名)。有些框架还会让你填一个 key 或者 token,这个取决于签名服务有没有开鉴权。
容量这块我给个实打实的估算过程,方便你按自己的规模调资源。
已知条件:P50 耗时约 50 ms,线程池 4。理论峰值 QPS = 1000 / 50 × 4 = 80。这是理想值,实际打七折,按 55 QPS 估。
再看一个机器人账号的实际请求量:正常聊天场景下,收发消息的高峰大概每秒 2 到 5 次签名请求,加上登录、心跳、拉群信息之类的杂项,按 8 QPS 估。那么单实例大概能支撑 55 / 8 ≈ 6 到 7 个账号的尖峰并发。但尖峰不是持续的,日常平均可能只有尖峰的十分之一,所以实际上 10 到 15 个账号跑在单实例上问题不大。
内存这边:基础占用(JVM 本身 + 本地库)约 250 到 350 MB,每个活跃会话额外 30 到 50 MB。10 个账号大约 300 + 400 = 700 MB,配 1 GB 上限留了余量。如果你的账号数上到 30 个,建议直接给 2 GB 并配两个实例做负载均衡,别硬撑——单实例内存打满之后,表现是随机超时,非常难定位。
这些数字都是基于常见实践的估算,你要以自己脚本实测的 P95 为准。P95 比 P50 更有参考价值,因为框架侧超时通常发生在尾部请求上。
5. 常见故障速查与避坑清单
5.1 启动类故障:容器起来了,服务没起来
这是最常见的一类。docker ps显示Up,但接口连不上。排查顺序我固定成这样:
先看docker ps的状态列,如果是Restarting,说明进程在反复崩溃,直接docker logs --tail 200 qsign看最后一次崩溃前的输出。如果是Up (unhealthy),说明进程活着但健康检查没过,先手动进容器curl一下本地端口。
docker exec -it qsign sh # 容器内执行 curl -i http://127.0.0.1:8080/如果容器内通、宿主机不通,问题在端口映射,检查docker port qsign的输出对不对,以及有没有写成127.0.0.1:18080:8080却从外网去访问(那当然不通,这是设计如此)。
如果容器内也不通,看进程在不在:ps aux | grep -i java(或者对应的进程名)。进程不在就是启动失败,回到日志。
启动失败里排第一的原因是架构不匹配。日志里会看到类似Exec format error或者本地库加载失败。确认方法前面说过:docker inspect --format '{{.Architecture}}'。
排第二的是挂载目录权限。日志里是Permission denied或者配置文件读取失败。用docker exec进去id看当前用户,再ls -l /app/conf看属主,对不上就 chown。
排第三的是内存不足被杀。日志末尾只有一句Killed,docker inspect看OOMKilled是true。这时候要么调大内存上限,要么调小堆大小,别两个都设成一样大。
5.2 运行类故障:跑着跑着就不对劲了
跑通之后的故障更隐蔽,我整理了几种典型现象和对应处理。
现象一:偶发超时,成功率 90% 左右。先去宿主机看docker stats qsign,如果 CPU 长期贴着上限,说明资源给少了,加--cpus。如果 CPU 不高但延迟波动大,看内存是否接近上限、有没有在走 swap(--memory-swap设成和--memory相等就能禁用)。
现象二:跑几小时后内存缓慢上涨。这是典型的缓存无上限或者会话没回收。先把缓存大小调小,观察曲线。如果还是涨,就是实现本身的问题,最实际的办法是配一个定时重启的策略,比如每天凌晨低峰期重启一次,配合框架侧的重连重试。这个方案不优雅,但管用,我在好几个长期跑的服务上都这么处理。
现象三:签名返回成功但框架报错。这类问题最容易误判成签名服务的锅。先看框架日志里报的具体是什么,很可能是版本不匹配——签名服务返回的字段格式和框架期望的不一致。核对两边的版本号,看是不是某一边升级了另一边没跟上。
现象四:容器时间不对导致偶发失败。进容器date一下,跟宿主机对比。前面说的TZ环境变量就是防这个的。
5.3 速查表与个人踩坑记录
把上面这些浓缩成一张表,出问题时直接对照。
| 现象 | 大概率原因 | 处理动作 |
|---|---|---|
| 容器反复 Restarting | 启动参数错误 / 架构不匹配 | 看docker logs末尾,核对Architecture |
| Up 但接口连不上 | 端口映射写错 / 只监听 127.0.0.1 | docker port核对,容器内先自测 |
| 日志有 Permission denied | 挂载目录属主不对 | docker exec id查 UID 后 chown |
| 日志只有 Killed | OOM | 调大--memory或调小-Xmx |
| 启动后 90 秒内 unhealthy | 初始化慢,健康检查太严格 | 加start_period |
| P95 延迟几百毫秒 | CPU 限太死 / 走 swap | 加--cpus,--memory-swap与--memory相等 |
| 内存缓慢上涨 | 缓存无上限 / 会话未回收 | 限缓存,配定时重启 |
| 签名成功但框架报错 | 两边版本不匹配 | 核对版本,回滚一侧 |
说几个我自己印象深的坑。
第一个是首次启动别急着判断失败。我第一次跑的时候,容器起来 20 秒接口还不通,我以为配错了,反复重启了七八次,每次都打断初始化,结果永远起不来。后来老老实实等了两分钟,服务自己就好了。所以配start_period不只是给编排工具看的,也是提醒自己耐心点。
第二个是别在容器里改配置。早期我图方便,直接docker exec进去 vim 改文件,改完重启容器发现改动没了——因为挂载目录和镜像内路径没对上,我改的是容器内的临时层。从那之后所有配置改动都改宿主机挂载目录里的文件,改完确认docker exec cat能看到同样内容,再重启。
第三个是日志轮转一定要配。这件事我吃过大亏,一个 debug 级别的服务跑了三天,日志文件涨到 20 多 G,把磁盘写满,导致同机器上的数据库写入失败。加了max-size和max-file之后就再没出现过。同时把应用层日志级别从 debug 调到 info,双保险。
第四个是版本号写死在 compose 里。latest看着省事,实际上是把升级的控制权交给了别人。我现在所有 compose 文件的 image 字段都是名字:具体版本,升级是一个需要我手动改文件、手动up -d、手动验证的动作。多花两分钟,少一次半夜被告警叫醒。
最后分享一个我觉得挺实用的小习惯:给每个容器在docker-compose.yml里加一行注释,写上这个镜像的用途、构建日期、以及它依赖的协议版本。半年后你回头看这个文件,会感谢当时的自己。