Files
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

325 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 本机 Docker 部署指南
本文只讲**在本机用 Docker 跑服务端**,以及**客户端该填什么地址**。
不涉及云服务器、反向代理和 HTTPS(那是后续阶段的事)。
服务端的角色:账号认证 + 设备登记 + 证书同步(`server/` 目录)。
数据只有一个 SQLite 文件,所以部署本身很轻。
---
## TL;DR(三条命令)
```bash
cd server
cp .env.example .env # 然后把 JWT_SECRET 填进去
docker compose up -d --build
```
验证:
```bash
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**
```bash
docker --version # Docker version 29.x 即可
docker compose version # v2 及以上(v5.x 也是 v2 系列,正常)
```
2. **确认 3100 端口空闲**
```bash
lsof -nP -iTCP:3100 -sTCP:LISTEN
```
没有输出 = 空闲。有输出就换端口(见「常见故障 · EADDRINUSE」)。
> **为什么不是 3000?** 开发机上 3000 是最容易被别的容器/服务占用的端口,
> 本项目就踩过一次(`lsof` 显示另一个 Docker 容器在监听 `*:3000`)。
> 所以宿主机侧固定用 **3100**,容器内部仍然是 3000。
---
## 二、首次部署
### 1. 生成 JWT_SECRET
```bash
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
# 或者:openssl rand -hex 48
```
得到一个 96 字符的十六进制串。服务端会校验:
- 长度 ≥ 32 字符
- 不能命中内置弱值黑名单(`changeme`、`secret`、示例值等)
- 不能是同一个字符重复(`aaaaaaa...`)
不满足就**拒绝启动并 exit 1**(这是设计如此,不是 bug)。
### 2. 建 `.env`
```bash
cd server
cp .env.example .env
```
编辑 `.env`,**只需改一处**:
```ini
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. 启动
```bash
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` 要装依赖,约 1~2 分钟;之后启动 2~8 秒(要跑数据库迁移)。
### 4. 验证
```bash
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:
```bash
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. **确认容器在监听所有网卡**
```bash
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` |
```bash
cd server
docker compose logs -f --tail 100 # 实时跟踪
docker compose logs --no-color | grep '"level":"error"' # 只看错误
```
日志格式是单行 JSON,自带脱敏:密码、token、证书私钥永远不会出现,
证书只记 fingerprint 前 8 位。
### 停止 / 启动 / 重启
```bash
docker compose stop # 停容器,保留容器和数据
docker compose start # 再启动
docker compose restart # 重启(数据不受影响)
docker compose down # 删容器和网络,**保留** ./data 里的数据
docker compose up -d # 恢复运行(数据还在,不用重新注册)
```
> `down` 不会删 `./data` —— 它是宿主机上的普通目录,不是 volume 的附属物。
### 升级
改了服务端代码后:
```bash
cd server
docker compose up -d --build # 重新构建镜像并滚动替换容器
```
数据库迁移在**启动时自动执行**(`startServer` 内部调 `runMigrations`),
不需要单独跑 migrate 命令。迁移前会自动备份到 `data/backups/`。
---
## 六、备份与恢复
数据全在 `server/data/` 这一个目录里:
```
server/data/
├── onlyinone.db ← 数据库(用户、设备、证书),这是要备份的
├── backups/ ← 迁移前自动备份
└── logs/ ← 日志(不用备份)
```
**备份**(建议先停容器,保证文件一致):
```bash
cd server
docker compose stop
cp data/onlyinone.db ~/onlyinone-backup-$(date +%Y%m%d).db
docker compose start
```
**恢复**:
```bash
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`:
```yaml
services:
onlyinone-server:
ports:
- '3200:3000' # 左边换成想要的宿主机端口
volumes:
- ./data:/app/data # 多实例必须用不同目录,否则会抢同一个 db 文件
```
然后 `docker compose up -d`,客户端地址同步改成 `http://<IP>:3200`。