Files
onlyinone/docs/deploy.md
T
jkooe 1e9a5ff72e feat(deploy): 服务端 Docker 化 + 本机部署文档 + 修正客户端默认地址
新增(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。
2026-10-27 01:01:26 +08:00

11 KiB
Raw Blame History

本机 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 步。


一、前提

  1. 装 Docker Desktop

    docker --version           # Docker version 29.x 即可
    docker compose version     # v2 及以上(v5.x 也是 v2 系列,正常)
    
  2. 确认 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://。


四、局域网其他机器连不上?

按顺序排查,每步都要过:

  1. 确认容器在监听所有网卡

    curl http://192.168.0.156:3100/api/v1/health
    

    在自己机器上用局域网 IP(不是 127.0.0.1)请求。通了说明监听没问题, 问题在别人那边(防火墙/网络)。

  2. 防火墙放行 3100(macOS) 系统设置 → 网络 → 防火墙 → 选项,允许 Docker 接受入站连接; 或在「防火墙选项」里确认没有拦截 com.docker.backend。

  3. 确认两台机器在同一网段 客户端 ping 192.168.0.156 能通再说。

  4. 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 stop 0.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。