新增(server/): - Dockerfile:node:22-alpine、非 root(node uid 1000)、npm ci --omit=dev、 HEALTHCHECK 用 /api/v1/health(alpine 无 curl,改用 node fetch)、exec 形式 CMD。 node:sqlite 是 Node 内置模块,alpine/musl 上实测可用,无需换 slim。 - .dockerignore:排除 .env / data / test / smoke.mjs / coverage / docs, 保证密钥与用户密码哈希不进镜像层。 - docker-compose.yml:3100:3000(避开本机已被占用的 3000)、env_file 读 .env、 bind mount ./data:/app/data(用户要能直接看到/拷走 onlyinone.db)、 restart: unless-stopped、init: true、healthcheck。 docs/deploy.md:TL;DR、首次部署、客户端填什么、局域网、升级/备份、故障表、设计取舍。 修正 DEFAULT_BASE_URL: `https://api.onlyinone.app` 是从不存在的域名,用户不填地址就永远连接失败。 改为 `http://127.0.0.1:3100`(与 compose 宿主机端口一致),本机 Docker 开箱即用。 选「默认可用」而非「留空+强制 UI 提示」:改动面只有一行常量、不碰 UI 逻辑, 而本机试跑正是首要场景;局域网需手填 IP,已在文档写明。 测试:新增默认地址回归防护(不可为空、必须能过 normalizeBaseUrl 且不被改写、 必须是纯 origin、端口须为 3100)。launcher 525/525、server 82/82。
11 KiB
本机 Docker 部署指南
本文只讲在本机用 Docker 跑服务端,以及客户端该填什么地址。 不涉及云服务器、反向代理和 HTTPS(那是后续阶段的事)。
服务端的角色:账号认证 + 设备登记 + 证书同步(server/ 目录)。
数据只有一个 SQLite 文件,所以部署本身很轻。
TL;DR(三条命令)
cd server
cp .env.example .env # 然后把 JWT_SECRET 填进去
docker compose up -d --build
验证:
curl http://127.0.0.1:3100/api/v1/health
# {"ok":true,"data":{"version":"0.1.0","uptimeMs":10191,"dbOk":true}}
JWT_SECRET是必填的,不填或填得太弱,容器会启动即退出(exit 1)。 生成命令见下面「首次部署」第 1 步。
一、前提
-
装 Docker Desktop
docker --version # Docker version 29.x 即可 docker compose version # v2 及以上(v5.x 也是 v2 系列,正常) -
确认 3100 端口空闲
lsof -nP -iTCP:3100 -sTCP:LISTEN没有输出 = 空闲。有输出就换端口(见「常见故障 · EADDRINUSE」)。
为什么不是 3000? 开发机上 3000 是最容易被别的容器/服务占用的端口, 本项目就踩过一次(
lsof显示另一个 Docker 容器在监听*:3000)。 所以宿主机侧固定用 3100,容器内部仍然是 3000。
二、首次部署
1. 生成 JWT_SECRET
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
# 或者:openssl rand -hex 48
得到一个 96 字符的十六进制串。服务端会校验:
- 长度 ≥ 32 字符
- 不能命中内置弱值黑名单(
changeme、secret、示例值等) - 不能是同一个字符重复(
aaaaaaa...)
不满足就拒绝启动并 exit 1(这是设计如此,不是 bug)。
2. 建 .env
cd server
cp .env.example .env
编辑 .env,只需改一处:
PORT=3000 # 容器内端口,不要改
HOST=0.0.0.0
JWT_SECRET=把上一步生成的串粘到这里
LOG_LEVEL=info
PORT=3000是容器内部的端口。对外暴露的是docker-compose.yml里ports: '3100:3000'的左边(3100)。想换对外端口请改 compose 的左边, 不要改.env里的PORT—— compose 里的environment会覆盖它, 改了也不生效,只会让你困惑。
.env 已经在 .gitignore 里,不会被提交。
3. 启动
docker compose up -d --build
docker compose ps
期望看到 Up (healthy):
NAME IMAGE STATUS PORTS
onlyinone-server onlyinone-server:local Up 10 seconds (healthy) 0.0.0.0:3100->3000/tcp
第一次 --build 要装依赖,约 12 分钟;之后启动 28 秒(要跑数据库迁移)。
4. 验证
curl -s http://127.0.0.1:3100/api/v1/health
必须看到 "ok":true 且 "dbOk":true。
三、客户端「服务器地址」填什么
本机使用
http://127.0.0.1:3100
启动器的默认值就是这个,不填也能连(见 launcher/src/core/config.ts 的 DEFAULT_BASE_URL)。
局域网其他机器使用
http://192.168.0.156:3100
其中 192.168.0.156 换成跑 Docker 那台机器的局域网 IP:
ipconfig getifaddr en0 # macOS
ip addr show | grep 'inet ' # Linux
填地址的四条规则
| 规则 | 说明 |
|---|---|
不要带 /api 或 /api/v1 |
客户端自己会拼 /api/v1/auth/login 等路径。你再带一段,就变成 /api/v1/api/v1/auth/login → 404。 |
| 末尾斜杠会被去掉 | http://127.0.0.1:3100/ 和 http://127.0.0.1:3100 等价,不用纠结。 |
| 不能带用户名密码 | http://user:pass@host:3100 会被拒绝(避免凭据出现在日志/设置文件里)。 |
| 只接受 http / https | 填 ftp://... 会被直接拒绝,不会「好心」帮你补协议。 |
省事的话,只填 127.0.0.1:3100(不带协议)也行,会自动补 http://。
四、局域网其他机器连不上?
按顺序排查,每步都要过:
-
确认容器在监听所有网卡
curl http://192.168.0.156:3100/api/v1/health在自己机器上用局域网 IP(不是 127.0.0.1)请求。通了说明监听没问题, 问题在别人那边(防火墙/网络)。
-
防火墙放行 3100(macOS) 系统设置 → 网络 → 防火墙 → 选项,允许 Docker 接受入站连接; 或在「防火墙选项」里确认没有拦截
com.docker.backend。 -
确认两台机器在同一网段 客户端
ping 192.168.0.156能通再说。 -
IP 会变 DHCP 续租后 IP 可能变化,客户端就断了。两个办法:
- 在路由器里给这台机器做 DHCP 静态绑定(推荐,一次性);
- 或使用 mDNS 主机名:
http://主机名.local:3100(同一局域网内可用)。
五、日常操作
看日志
服务端日志是双写的(server/logger.js):
| 位置 | 怎么看 |
|---|---|
| 容器 stdout(Docker 收集) | docker compose logs -f |
| 宿主机文件(落在 bind mount 里) | server/data/logs/server.log |
cd server
docker compose logs -f --tail 100 # 实时跟踪
docker compose logs --no-color | grep '"level":"error"' # 只看错误
日志格式是单行 JSON,自带脱敏:密码、token、证书私钥永远不会出现, 证书只记 fingerprint 前 8 位。
停止 / 启动 / 重启
docker compose stop # 停容器,保留容器和数据
docker compose start # 再启动
docker compose restart # 重启(数据不受影响)
docker compose down # 删容器和网络,**保留** ./data 里的数据
docker compose up -d # 恢复运行(数据还在,不用重新注册)
down不会删./data—— 它是宿主机上的普通目录,不是 volume 的附属物。
升级
改了服务端代码后:
cd server
docker compose up -d --build # 重新构建镜像并滚动替换容器
数据库迁移在启动时自动执行(startServer 内部调 runMigrations),
不需要单独跑 migrate 命令。迁移前会自动备份到 data/backups/。
六、备份与恢复
数据全在 server/data/ 这一个目录里:
server/data/
├── onlyinone.db ← 数据库(用户、设备、证书),这是要备份的
├── backups/ ← 迁移前自动备份
└── logs/ ← 日志(不用备份)
备份(建议先停容器,保证文件一致):
cd server
docker compose stop
cp data/onlyinone.db ~/onlyinone-backup-$(date +%Y%m%d).db
docker compose start
恢复:
cd server
docker compose stop
cp ~/onlyinone-backup-20260101.db data/onlyinone.db
docker compose start
onlyinone.db里有用户密码哈希(scrypt)和设备证书。 备份文件要当成敏感文件对待,不要丢进网盘/仓库。
七、常见故障
| 现象 | 原因 | 处理 |
|---|---|---|
docker compose up 报 EADDRINUSE / port is already allocated |
宿主机 3100 被占 | lsof -nP -iTCP:3100 -sTCP:LISTEN 找到占用进程干掉,或改 docker-compose.yml 里 ports 的左边(如 3200:3000),记得同步改客户端地址 |
容器起来几秒就退出(Exited (1)) |
大概率 JWT_SECRET 不合格 |
docker compose logs 看最后一行,会明确写「长度不足 / 命中弱值 / 全同字符」 |
Exited (1) 且日志有「数据库迁移失败」 |
旧库结构冲突或文件损坏 | 看 data/backups/ 里的自动备份,必要时恢复到上一个备份 |
客户端连不上,但 curl 127.0.0.1:3100 正常 |
客户端地址填错,或填了别的机器 | ① 先用 curl http://127.0.0.1:3100/api/v1/health 确认服务活着;② 再从客户端那台机器 curl http://192.168.0.156:3100/api/v1/health;③ 都不通就查防火墙 |
| 客户端报 404 | 地址里多带了 /api 或 /api/v1 |
只填 http://host:3100,路径客户端自己拼 |
启动报 SQLITE_CANTOPEN / 数据库只读 |
宿主机 data/ 目录的 uid 与容器内 node(uid 1000) 不匹配(Linux 上才会) |
sudo chown -R 1000:1000 server/data。macOS 的 Docker Desktop 走文件共享层,通常不会遇到 |
docker compose ps 一直 starting 不变 healthy |
迁移卡住或端口没起来 | docker compose logs -f 看卡在哪;健康检查有 20s start_period,20s 内 starting 是正常的 |
docker compose exec onlyinone-server ... 报 service is not running |
容器还没起来,或者用了容器名当服务名 | 先 docker compose ps 确认 Up。本项目的服务名和容器名故意取成一样(onlyinone-server),所以两者都能用 |
八、这份部署的设计取舍(读一遍能省很多坑)
| 决策 | 为什么 |
|---|---|
| 宿主机端口 3100 | 3000 在开发机上极易被占(本项目实测被另一个容器占用) |
用 bind mount ./data:/app/data 而不是 named volume |
用户要能直接看到并拷走 onlyinone.db。bind mount 之后它就是宿主机上的普通文件;named volume 的真实路径在 Docker 虚拟机里,用户够不着 |
容器非 root(node,uid 1000) |
万一服务端被攻破,攻击者拿到的不是 root |
JWT_SECRET 只走 env_file,不写 Dockerfile ENV |
镜像层可以被 docker save 导出。写进 ENV 等于把密钥公开给所有拿到镜像的人 |
基础镜像 node:22-alpine |
依赖全是纯 JS,没有 glibc 需求;node:sqlite 是内置模块,alpine 上实测可用;体积约 1/4 |
健康检查用 node -e "fetch(...)" 而不是 wget/curl |
alpine 没有 curl;busybox 的 wget --spider 对 4xx 判定不可靠。fetch 与运行时同实现,语义精确 |
init: true |
见下 |
不写 version: 字段 |
compose v2 已废弃,写了只会刷 WARNING |
init: true 是必须的(实测)
Linux 内核不会把「默认动作是终止」的信号投递给 PID 1,除非进程自己装了 handler。
server.js 没有装 SIGTERM handler,所以如果 node 直接当 PID 1:
docker compose stop会静默等满 10 秒超时,然后 SIGKILL(exit 137)
加上 init: true 后由 tini 当 PID 1,node 变成子进程,信号正常转发:
docker compose stop0.2 秒完成,exit 143(干净的 SIGTERM)
关于 node:sqlite 的 warning
日志里会有一条:
ExperimentalWarning: SQLite is an experimental feature and might change at any time
这是预期输出,不是错误,不影响退出码,也不影响功能。
node:sqlite 从 Node 22.5 起内置,仍然带着 experimental 标记而已。
镜像内实测:Node 22.23.4 + SQLite 3.51.3,建表/写入/查询全部正常。
九、想换端口 / 多实例
改 docker-compose.yml:
services:
onlyinone-server:
ports:
- '3200:3000' # 左边换成想要的宿主机端口
volumes:
- ./data:/app/data # 多实例必须用不同目录,否则会抢同一个 db 文件
然后 docker compose up -d,客户端地址同步改成 http://<IP>:3200。