docs(plan): docker deploy conformance implementation plan (5 tasks); spec: smoke storage path item
This commit is contained in:
@@ -0,0 +1,677 @@
|
||||
# Docker 部署规范化改写 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把 booklib 的 Docker 部署重写为团队规范形态:dev/release 双 compose、共享 named volumes、prepare.sh 渲染配置、dev 源码热挂载 + delve、release 编译产物、文件存储挂 `deploy/api/storage`。
|
||||
|
||||
**Architecture:** 单 API(`backend/cmd/server`) + SPA(`web`)不变;部署层重写为 `deploy/` 内自包含(compose 项目目录=deploy/,build context=仓库根 `..`)。配置由 `deploy/templates/*.tpl` 经 `prepare.sh` 渲染为 `deploy/nginx/`、`deploy/redis/` 产物后 `:ro` 挂载。
|
||||
|
||||
**Tech Stack:** Docker Compose v2、Go 1.26(delve headless)、Node 22(vite)、nginx:1.27-alpine、postgres:16-alpine、redis:7-alpine。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-09-07-docker-deploy-spec-design.md`(本计划的验收即 spec §9)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- compose 项目名固定 `name: booklib`(两个 compose 文件顶层都有);named volume 一律命名 `postgres_data`、`redis_data`(实际名 `booklib_postgres_data`/`booklib_redis_data`),两文件完全一致。
|
||||
- dev = `deploy/docker-compose.dev.yml`(`target: dev` 镜像、源码挂载、PG 宿主 5432、Redis 宿主 6379、delve 2345、healthcheck 门控);release = `deploy/docker-compose.yml`(`Dockerfile.*.prod` `target: runner`、无源码挂载、infra 不出宿主端口)。
|
||||
- 所有 `:ro` 挂载的配置文件都是 `prepare.sh` 渲染产物,模板是唯一编辑入口;渲染产物目录全部 gitignore。
|
||||
- 文件存储路径统一 `deploy/api/storage` → 容器 `/data/books`,`CACHE_DIR=/data/books/cache`。
|
||||
- 环境变量键名不变:`JWT_SECRET`、`ADMIN_USER`、`ADMIN_PASSWORD`、`SCAN_INTERVAL_SEC`(`.env` 迁至 `deploy/.env`)。
|
||||
- 删除清单(最终态不再存在):根 `docker-compose.yml`、根 `.env.example`、`deploy/Dockerfile.api`、`deploy/Dockerfile.web`、`deploy/nginx.conf`、根 `library/` 约定。
|
||||
- 不动业务代码,唯一两处适配:`web/vite.config.ts` 代理目标读 env、两个 smoke 脚本的 `.env` 路径。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: prepare.sh + 配置模板 + .env 迁移
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/templates/nginx.conf.tpl`、`deploy/templates/default.conf.tpl`、`deploy/templates/redis.conf.tpl`
|
||||
- Create: `deploy/prepare.sh`(chmod +x)
|
||||
- Create: `deploy/api/storage/.gitkeep`
|
||||
- Move: `.env.example` → `deploy/.env.example`;本地 `.env` → `deploy/.env`(untracked,用 `mv`)
|
||||
- Modify: `.gitignore`、`.dockerignore`、`scripts/smoke.sh:6`、`scripts/smoke-web.sh:6`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: 渲染产物 `deploy/nginx/nginx.conf`、`deploy/nginx/conf.d/default.conf`、`deploy/redis/redis.conf`;目录 `deploy/logs/nginx/`。变量表:`WEB_PORT`(8080)、`NGINX_CLIENT_MAX_BODY_SIZE`(200m)、`REDIS_MAXMEMORY`(128mb)、`REDIS_MAXMEMORY_POLICY`(allkeys-lru)、`DELVE_PORT`(2345)。
|
||||
|
||||
- [ ] **Step 1: 写三个模板**
|
||||
|
||||
`deploy/templates/nginx.conf.tpl`(nginx 主配置,日志写挂载目录):
|
||||
|
||||
```nginx
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /var/log/nginx/access.log;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
```
|
||||
|
||||
`deploy/templates/default.conf.tpl`(server 块,内容 = 现 `deploy/nginx.conf`,仅 max_body 参数化;此文件后续会被 entrypoint 拷入容器再注入 resolver,所以 resolver 行保留占位值):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
client_max_body_size {{NGINX_CLIENT_MAX_BODY_SIZE}};
|
||||
# 地址为占位默认值(127.0.0.11=Docker 内嵌 DNS);容器 entrypoint 启动时会按
|
||||
# /etc/resolv.conf 的首个 nameserver 重写本行,兼容 podman aardvark-dns。
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
# spec §7 风险接受所假设的 CSP:全部同源,blob:/data: 供 SW/reader 用
|
||||
add_header Content-Security-Policy "default-src 'self'; img-src 'self' blob: data:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' blob: data:; object-src 'none'; frame-src 'self' blob:" always;
|
||||
|
||||
location /api/ {
|
||||
set $api_upstream http://api:8080; # 变量式 → 每次按 DNS 解析,scale 后轮询到新副本(spec §11)
|
||||
proxy_pass $api_upstream; # 无 URI 部分:保留 /api 前缀转发
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`deploy/templates/redis.conf.tpl`:
|
||||
|
||||
```nginx
|
||||
# booklib redis 配置 — 由 deploy/prepare.sh 从 templates/redis.conf.tpl 渲染,勿直接编辑产物
|
||||
maxmemory {{REDIS_MAXMEMORY}}
|
||||
maxmemory-policy {{REDIS_MAXMEMORY_POLICY}}
|
||||
dir /data
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写 `deploy/prepare.sh` 并 chmod +x**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# 渲染 deploy/templates/*.tpl → deploy/nginx、deploy/redis,并创建运行所需目录。
|
||||
# 约定:每次 up 之前(或改模板/.env 后)必须重跑本脚本。幂等,可反复执行。
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ -f .env ]; then
|
||||
set -a; . ./.env; set +a
|
||||
fi
|
||||
|
||||
NGINX_CLIENT_MAX_BODY_SIZE=${NGINX_CLIENT_MAX_BODY_SIZE:-200m}
|
||||
REDIS_MAXMEMORY=${REDIS_MAXMEMORY:-128mb}
|
||||
REDIS_MAXMEMORY_POLICY=${REDIS_MAXMEMORY_POLICY:-allkeys-lru}
|
||||
|
||||
for t in templates/nginx.conf.tpl templates/default.conf.tpl templates/redis.conf.tpl; do
|
||||
[ -f "$t" ] || { echo "prepare.sh: missing template $t" >&2; exit 1; }
|
||||
done
|
||||
|
||||
mkdir -p nginx/conf.d redis logs/nginx api/storage
|
||||
|
||||
sed -e "s|{{NGINX_CLIENT_MAX_BODY_SIZE}}|${NGINX_CLIENT_MAX_BODY_SIZE}|g" \
|
||||
templates/nginx.conf.tpl > nginx/nginx.conf
|
||||
sed -e "s|{{NGINX_CLIENT_MAX_BODY_SIZE}}|${NGINX_CLIENT_MAX_BODY_SIZE}|g" \
|
||||
templates/default.conf.tpl > nginx/conf.d/default.conf
|
||||
sed -e "s|{{REDIS_MAXMEMORY}}|${REDIS_MAXMEMORY}|g" \
|
||||
-e "s|{{REDIS_MAXMEMORY_POLICY}}|${REDIS_MAXMEMORY_POLICY}|g" \
|
||||
templates/redis.conf.tpl > redis/redis.conf
|
||||
|
||||
echo "prepare.sh: rendered nginx($(pwd)/nginx), redis($(pwd)/redis), logs($(pwd)/logs/nginx), storage($(pwd)/api/storage)"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 迁移 .env 与 .env.example**
|
||||
|
||||
```bash
|
||||
git mv .env.example deploy/.env.example
|
||||
[ -f .env ] && mv .env deploy/.env
|
||||
```
|
||||
|
||||
改写 `deploy/.env.example` 为:
|
||||
|
||||
```bash
|
||||
JWT_SECRET=change-me-openssl-rand-hex-32
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=change-me-min-8
|
||||
SCAN_INTERVAL_SEC=60
|
||||
|
||||
# ---- 部署参数(deploy/prepare.sh 渲染模板 / compose 插值用) ----
|
||||
WEB_PORT=8080
|
||||
NGINX_CLIENT_MAX_BODY_SIZE=200m
|
||||
REDIS_MAXMEMORY=128mb
|
||||
REDIS_MAXMEMORY_POLICY=allkeys-lru
|
||||
DELVE_PORT=2345
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 更新 .gitignore / .dockerignore / smoke 脚本,并放置 storage 占位**
|
||||
|
||||
```bash
|
||||
mkdir -p deploy/api/storage && touch deploy/api/storage/.gitkeep
|
||||
```
|
||||
|
||||
|
||||
`.gitignore`:删除 `library/` 行,追加:
|
||||
|
||||
```
|
||||
deploy/nginx/
|
||||
deploy/redis/redis.conf
|
||||
deploy/logs/
|
||||
deploy/api/storage/*
|
||||
!deploy/api/storage/.gitkeep
|
||||
```
|
||||
|
||||
`.dockerignore`:删除 `library/` 行,追加 `deploy/logs/`、`deploy/nginx/`、`deploy/redis/`、`deploy/api/storage/`。
|
||||
|
||||
`scripts/smoke.sh` 和 `scripts/smoke-web.sh` 的第 6 行 `[ -f .env ] && set -a && . ./.env && set +a` 均替换为:
|
||||
|
||||
```bash
|
||||
ENV_FILE=${ENV_FILE:-deploy/.env}
|
||||
[ -f "$ENV_FILE" ] || ENV_FILE=.env
|
||||
[ -f "$ENV_FILE" ] && set -a && . "./$ENV_FILE" && set +a
|
||||
```
|
||||
|
||||
`scripts/smoke.sh` 依赖旧的 `./library:/data/books` 绑定(第 28、55 行),随存储路径迁移同步改为:
|
||||
|
||||
```bash
|
||||
mkdir -p deploy/api/storage/smoke-books # 原:mkdir -p library/smoke-books
|
||||
[ ! -f deploy/api/storage/smoke-books/note.txt ] || die "file survived delete" # 原:library/smoke-books/note.txt
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 验证渲染**
|
||||
|
||||
```bash
|
||||
deploy/prepare.sh && deploy/prepare.sh # 幂等跑两遍
|
||||
grep -R '{{' deploy/nginx deploy/redis && echo "FAIL: placeholder left" || echo OK
|
||||
grep -q 'maxmemory 128mb' deploy/redis/redis.conf && echo OK
|
||||
ls deploy/logs/nginx deploy/api/storage
|
||||
```
|
||||
|
||||
Expected: 两次 `OK`,无 `FAIL`。再验证缺省值路径:临时 `env -i` 不行(脚本 source .env),改用 `ENV_FILE` 无法关——直接确认 `deploy/.env` 不存在时仍渲染默认值:`mv deploy/.env /tmp/e 2>/dev/null; deploy/prepare.sh; grep 'maxmemory 128mb' deploy/redis/redis.conf && mv /tmp/e deploy/.env 2>/dev/null; true`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(deploy): prepare.sh + config templates, migrate env to deploy/, gitignore rendered outputs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 双 Dockerfile(dev/prod)+ entrypoint-resolver 改造
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/Dockerfile.api.dev`、`deploy/Dockerfile.api.prod`、`deploy/Dockerfile.web.dev`、`deploy/Dockerfile.web.prod`
|
||||
- Modify: `deploy/entrypoint-resolver.sh`
|
||||
- Delete: `deploy/Dockerfile.api`、`deploy/Dockerfile.web`、`deploy/nginx.conf`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `/etc/booklib/{nginx.conf,default.conf}` 挂载约定(Task 3 落地)。
|
||||
- Produces: 可构建镜像 target:`Dockerfile.api.dev`→`dev`、`Dockerfile.api.prod`→`runner`、`Dockerfile.web.dev`→`dev`、`Dockerfile.web.prod`→`runner`;build context=仓库根。
|
||||
|
||||
- [ ] **Step 1: 写 `deploy/Dockerfile.api.dev`**
|
||||
|
||||
```dockerfile
|
||||
FROM golang:1.26 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS dev
|
||||
# 源码由 compose 挂载进 /app;镜像只带工具链 + delve
|
||||
RUN go install github.com/go-delve/delve/cmd/dlv@latest
|
||||
EXPOSE 8080 2345
|
||||
CMD ["sh", "-c", "go mod download && dlv debug ./cmd/server --headless --listen=0.0.0.0:2345 --api-version=2 --continue --log"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写 `deploy/Dockerfile.api.prod`**
|
||||
|
||||
```dockerfile
|
||||
FROM golang:1.26-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
COPY backend/ ./
|
||||
RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/server
|
||||
|
||||
FROM alpine:3.20 AS runner
|
||||
RUN adduser -D -H app
|
||||
COPY --from=build /server /server
|
||||
# /data/books 由宿主 bind(./api/storage)覆盖;/data 下目录预建并授权,兼容 podman
|
||||
RUN mkdir -p /data && chown app:app /data
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/server"]
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 写 `deploy/Dockerfile.web.dev`**
|
||||
|
||||
```dockerfile
|
||||
FROM node:22 AS dev
|
||||
WORKDIR /app
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 写 `deploy/Dockerfile.web.prod`(不再 baked nginx 配置)**
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine AS runner
|
||||
COPY --chmod=755 deploy/entrypoint-resolver.sh /entrypoint-resolver.sh
|
||||
COPY --from=build /src/dist /usr/share/nginx/html
|
||||
# nginx 配置不 bake 进镜像:由 compose 挂到 /etc/booklib/(渲染产物),
|
||||
# entrypoint 启动时拷入原位、注入运行时 resolver 再 exec nginx
|
||||
ENTRYPOINT ["/entrypoint-resolver.sh"]
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 改 `deploy/entrypoint-resolver.sh`(先拷贝再 sed,因 `:ro` 挂载不可原地改写)**
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
# 双运行时(Docker/podman)DNS 修复:镜像内 baked 的 resolver 地址无法同时成立
|
||||
# (Docker=127.0.0.11,podman aardvark=网络网关,见容器 /etc/resolv.conf)。
|
||||
# 启动前取 resolv.conf 首个 nameserver 注入 nginx 配置,保住 spec §11 的
|
||||
# 变量式 proxy_pass 运行时重解析(valid=10s,scale/重建后秒级感知新 IP)。
|
||||
# 配置以 :ro 挂在 /etc/booklib/(deploy/prepare.sh 渲染产物),先拷入原位再改写。
|
||||
set -eu
|
||||
for f in nginx.conf default.conf; do
|
||||
[ -r "/etc/booklib/$f" ] || { echo "entrypoint-resolver: missing /etc/booklib/$f — 先跑 deploy/prepare.sh" >&2; exit 1; }
|
||||
done
|
||||
cp /etc/booklib/nginx.conf /etc/nginx/nginx.conf
|
||||
cp /etc/booklib/default.conf /etc/nginx/conf.d/default.conf
|
||||
RESOLVER=$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null || true)
|
||||
[ -n "$RESOLVER" ] || RESOLVER=127.0.0.11
|
||||
CONF=/etc/nginx/conf.d/default.conf
|
||||
sed -i "s#resolver [0-9a-fA-F:.]* valid=#resolver ${RESOLVER} valid=#" "$CONF"
|
||||
echo "entrypoint-resolver: resolver=${RESOLVER} injected into ${CONF}"
|
||||
nginx -t
|
||||
if [ $# -gt 0 ]; then exec "$@"; fi
|
||||
exec nginx -g 'daemon off;'
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 删除旧文件并构建四个镜像验证**
|
||||
|
||||
```bash
|
||||
git rm deploy/Dockerfile.api deploy/Dockerfile.web deploy/nginx.conf
|
||||
docker build -f deploy/Dockerfile.api.dev --target dev -t booklib:api-dev .
|
||||
docker build -f deploy/Dockerfile.api.prod --target runner -t booklib:api-prod .
|
||||
docker build -f deploy/Dockerfile.web.dev --target dev -t booklib:web-dev .
|
||||
docker build -f deploy/Dockerfile.web.prod --target runner -t booklib:web-prod .
|
||||
docker run --rm --entrypoint sh booklib:api-prod -c 'test -x /server && echo binary-ok'
|
||||
docker run --rm --entrypoint sh booklib:api-dev -c 'command -v dlv >/dev/null && echo dlv-ok'
|
||||
docker run --rm --entrypoint sh booklib:web-dev -c 'test -d node_modules/vite && echo deps-ok'
|
||||
```
|
||||
|
||||
Expected: 四个 build 成功;`binary-ok`、`dlv-ok`、`deps-ok`。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(deploy): split Dockerfiles into dev/runner targets, entrypoint copies rendered nginx conf before resolver injection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: release compose(deploy/docker-compose.yml)
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/docker-compose.yml`
|
||||
- Delete: `docker-compose.yml`(根)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 渲染产物路径、Task 2 的镜像 target 与 `/etc/booklib/` 约定、`deploy/.env`。
|
||||
- Produces: 服务名 `web|api|postgres|redis`(Task 4 dev 文件沿用同名)。
|
||||
|
||||
- [ ] **Step 1: 写 `deploy/docker-compose.yml`**
|
||||
|
||||
```yaml
|
||||
# release:编译产物、无源码挂载、infra 端口不出宿主机
|
||||
name: booklib
|
||||
services:
|
||||
web:
|
||||
build: { context: .., dockerfile: deploy/Dockerfile.web.prod, target: runner }
|
||||
ports: ["${WEB_PORT:-8080}:80"]
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/booklib/nginx.conf:ro
|
||||
- ./nginx/conf.d/default.conf:/etc/booklib/default.conf:ro
|
||||
- ./logs/nginx:/var/log/nginx
|
||||
depends_on: [api]
|
||||
api:
|
||||
build: { context: .., dockerfile: deploy/Dockerfile.api.prod, target: runner }
|
||||
environment:
|
||||
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
|
||||
REDIS_URL: redis://redis:6379
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ADMIN_USER: ${ADMIN_USER}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
BOOKS_DIR: /data/books
|
||||
CACHE_DIR: /data/books/cache
|
||||
SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60}
|
||||
volumes:
|
||||
- ./api/storage:/data/books
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
redis: { condition: service_started }
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib }
|
||||
volumes: [postgres_data:/var/lib/postgresql/data]
|
||||
healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 }
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
|
||||
volumes:
|
||||
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
|
||||
- redis_data:/data
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 删除根 compose**
|
||||
|
||||
```bash
|
||||
git rm docker-compose.yml
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 未跑 prepare 的失败路径验证(spec §8)**
|
||||
|
||||
```bash
|
||||
mv deploy/nginx /tmp/bk-ng && docker compose -f deploy/docker-compose.yml up -d 2>&1 | grep -i "not found\|error"; mv /tmp/bk-ng deploy/nginx
|
||||
```
|
||||
|
||||
Expected: compose 因绑定挂载源缺失报错,无容器半起(如有残留先 `docker compose -f deploy/docker-compose.yml down`)。
|
||||
|
||||
- [ ] **Step 4: 起 release 并跑双冒烟**
|
||||
|
||||
```bash
|
||||
deploy/prepare.sh
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
bash scripts/smoke.sh && bash scripts/smoke-web.sh
|
||||
docker compose -f deploy/docker-compose.yml ps # 全 healthy/running
|
||||
curl -s localhost:8080/api/healthz
|
||||
```
|
||||
|
||||
Expected: 两套冒烟全绿、`ok`。若宿主 :8080 被 Task 4 前的旧栈占用,先 `docker compose down` 旧项目。
|
||||
|
||||
- [ ] **Step 5: 验证卷名与 nginx 日志落宿主**
|
||||
|
||||
```bash
|
||||
docker volume ls | grep booklib_
|
||||
docker compose -f deploy/docker-compose.yml exec web sh -c 'ls /etc/nginx/conf.d/default.conf >/dev/null && grep -m1 resolver /etc/nginx/conf.d/default.conf'
|
||||
ls deploy/logs/nginx/ # 有 access.log(冒烟请求后)
|
||||
```
|
||||
|
||||
Expected: `booklib_postgres_data`、`booklib_redis_data`;resolver 行已是容器内实际 nameserver(非 127.0.0.11 亦可——Docker 下就是 127.0.0.11,podman 下为网关 IP);access.log 非空。
|
||||
|
||||
- [ ] **Step 6: Commit(数据卷留在盘上给 Task 4 做共享验证)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(deploy): release compose under deploy/ with shared named volumes and rendered config mounts; drop root compose"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: dev compose(四服务全容器化)+ vite 代理适配
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/docker-compose.dev.yml`(整体重写,替换旧内容)
|
||||
- Modify: `web/vite.config.ts`(`server.proxy` 一行)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2 dev 镜像(`go`/`dlv`/`npm` 可用)、Task 1 `deploy/api/storage`、`deploy/redis/redis.conf`、Task 3 的卷(同名 → 数据共享)。
|
||||
- Produces: `VITE_PROXY_TARGET` env 约定;dev 端口头约定:宿主 PG `localhost:5432`(lib/lib/lib)、Redis `localhost:6379`、delve `:2345`、vite `:5173`。
|
||||
|
||||
- [ ] **Step 1: vite 代理目标可配置**
|
||||
|
||||
`web/vite.config.ts` 中 `server: { proxy: { "/api": "http://localhost:8080" } },` 替换为:
|
||||
|
||||
```ts
|
||||
server: { proxy: { "/api": process.env.VITE_PROXY_TARGET ?? "http://localhost:8080" } },
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 重写 `deploy/docker-compose.dev.yml`**
|
||||
|
||||
```yaml
|
||||
# dev:源码热挂载 + target: dev 镜像 + delve :2345,infra 端口暴露宿主,healthcheck 门控
|
||||
# 起停用 down(不是 rm),否则 web 的匿名 node_modules 卷会成孤儿
|
||||
name: booklib
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib }
|
||||
ports: ["5432:5432"]
|
||||
volumes: [postgres_data:/var/lib/postgresql/data]
|
||||
healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 }
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
|
||||
ports: ["6379:6379"]
|
||||
volumes:
|
||||
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
|
||||
- redis_data:/data
|
||||
api:
|
||||
build: { context: .., dockerfile: deploy/Dockerfile.api.dev, target: dev }
|
||||
command: sh -c "go mod download && dlv debug ./cmd/server --headless --listen=0.0.0.0:2345 --api-version=2 --continue --log"
|
||||
environment:
|
||||
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
|
||||
REDIS_URL: redis://redis:6379
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ADMIN_USER: ${ADMIN_USER}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
BOOKS_DIR: /data/books
|
||||
CACHE_DIR: /data/books/cache
|
||||
SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60}
|
||||
volumes:
|
||||
- ../backend:/app
|
||||
- ./api/storage:/data/books
|
||||
ports: ["${DELVE_PORT:-2345}:2345"]
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
redis: { condition: service_started }
|
||||
web:
|
||||
build: { context: .., dockerfile: deploy/Dockerfile.web.dev, target: dev }
|
||||
command: npm run dev -- --host 0.0.0.0
|
||||
environment: { VITE_PROXY_TARGET: http://api:8080 }
|
||||
volumes:
|
||||
- ../web:/app
|
||||
- /app/node_modules
|
||||
ports: ["5173:5173"]
|
||||
depends_on: [api]
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 起 dev 栈并验证四要点**
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml down # 先停 release,端口让给 dev
|
||||
deploy/prepare.sh
|
||||
docker compose -f deploy/docker-compose.dev.yml up -d --build
|
||||
curl -sf localhost:5173/api/healthz # vite 代理 → 容器 api:200
|
||||
curl -sf localhost:5173 | grep -qi '<div id="root">' # vite 页面
|
||||
nc -z localhost 2345 && echo delve-ok # delve 端口
|
||||
nc -z localhost 5432 && nc -z localhost 6379 && echo infra-ok
|
||||
```
|
||||
|
||||
Expected: `delve-ok`、`infra-ok`,两个 curl 成功。
|
||||
|
||||
- [ ] **Step 4: 验证热重启语义(改码不 rebuild)**
|
||||
|
||||
```bash
|
||||
touch backend/cmd/server/main.go
|
||||
docker compose -f deploy/docker-compose.dev.yml restart api
|
||||
sleep 8 && curl -sf localhost:5173/api/healthz && echo hot-reload-ok
|
||||
```
|
||||
|
||||
Expected: `hot-reload-ok`(dlv 重新编译挂载的源码后健康检查恢复)。
|
||||
|
||||
- [ ] **Step 5: 验证数据与 release 共享(spec §9.3)**
|
||||
|
||||
Task 3 冒烟写入的数据(卷同名共享)应透过 dev 可见——用与 `scripts/smoke.sh` 相同的登录形状(`/api/auth/login`,body `{"username","password"}`)经 5173 代理断言:
|
||||
|
||||
```bash
|
||||
set -a; . deploy/.env; set +a
|
||||
TOK=$(curl -fsS localhost:5173/api/auth/login -H 'content-type: application/json' \
|
||||
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASSWORD\"}" | sed -E 's/.*"token":"([^"]+)".*/\1/')
|
||||
[ -n "$TOK" ] && echo login-ok
|
||||
curl -fsS localhost:5173/api/libraries -H "authorization: Bearer $TOK" | grep -q '"smoke"' && echo shared-data-ok
|
||||
```
|
||||
|
||||
Expected: `login-ok`、`shared-data-ok`(libraries 含 Task 3 冒烟创建的 `smoke` 库)。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(deploy): dev compose runs full stack with source mounts, delve, shared volumes; vite proxy target via env"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: README 重写 + 旧卷迁移文档 + 终验
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`(整体重写,见 Step 1)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1–4 全部产物。
|
||||
- Produces: 无(收尾)。
|
||||
|
||||
- [ ] **Step 1: 重写 `README.md`**
|
||||
|
||||
````markdown
|
||||
# Book & Comic Library
|
||||
|
||||
个人书库/漫画库:Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、磁盘+Redis 缓存)+ Docker Compose 部署。设计见 `docs/superpowers/specs/2026-09-04-book-comic-library-design.md`;部署规范见 `docs/superpowers/specs/2026-09-07-docker-deploy-spec-design.md`。
|
||||
|
||||
## Deploymode
|
||||
|
||||
- release:`deploy/docker-compose.yml` — 多阶段 `Dockerfile.{api,web}.prod`(target runner)编译产物,不挂源码;infra 端口只在容器网络。
|
||||
- dev:`deploy/docker-compose.dev.yml` — 四服务全容器化,源码挂 `../backend:/app`、`../web:/app`(web 带匿名 `node_modules` 卷),`target: dev` 镜像;api 跑 `go mod download && dlv debug ./cmd/server`(热重启不 rebuild,delve :2345);infra 暴露宿主 PG 5432 / Redis 6379;healthcheck 门控。
|
||||
|
||||
## Volume Mount
|
||||
|
||||
- 共享 named volumes:`booklib_postgres_data`、`booklib_redis_data`(两个 compose 同名,dev/release 看到同一份数据;仅 `down -v` 清除)。
|
||||
- 配置/日志绑定挂载:`deploy/nginx/{nginx.conf,conf.d/default.conf}`、`deploy/redis/redis.conf`(`:ro`)与 `deploy/logs/nginx` —— 全部来自 `deploy/prepare.sh`,**容器里配置不对/缺失 = 忘了重跑它**。
|
||||
- 文件存储:`deploy/api/storage` → 容器 `/data/books`(缓存写 `/data/books/cache`)。
|
||||
|
||||
## 跑起来(生产形态)
|
||||
|
||||
```bash
|
||||
cp deploy/.env.example deploy/.env # 填 JWT_SECRET、ADMIN_USER、ADMIN_PASSWORD(≥8 位,低于 8 位 seed 会跳过并 log)
|
||||
deploy/prepare.sh
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
bash scripts/smoke.sh && bash scripts/smoke-web.sh
|
||||
```
|
||||
|
||||
- web: `http://localhost:8080`(`WEB_PORT` 可改),API 走 nginx `/api/` 前缀反代到无状态 api 副本(`--scale api=N`)。
|
||||
- 原始书放进 `deploy/api/storage/`(挂到 `/data/books`),scanner 周期入库(默认 60s)。
|
||||
- nginx access/error 日志:`deploy/logs/nginx/`。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
deploy/prepare.sh
|
||||
docker compose -f deploy/docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
- 前端: http://localhost:5173(vite,HMR 直接生效;`/api` 代理到容器内 api)。
|
||||
- Go 改码后:`docker compose -f deploy/docker-compose.dev.yml restart api`(重编译挂载源码,无需 rebuild)。
|
||||
- 断点调试:delve headless 在 `localhost:2345`(VSCode launch:`{"type":"go","request":"attach","mode":"remote","host":"localhost","port":2345}`;命中断点后用 dlv 命令继续)。
|
||||
- 直连基础设施跑测试:PG `localhost:5432`(lib/lib/lib)、Redis `localhost:6379`:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
export DATABASE_URL='postgres://lib:lib@localhost:5432/lib?sslmode=disable'
|
||||
export REDIS_URL='redis://localhost:6379'
|
||||
go vet ./... && gofmt -l .
|
||||
go test -p 1 -count=1 ./...
|
||||
```
|
||||
|
||||
`-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。dev 栈起停用 `down`(不是 `rm`),否则匿名 node_modules 卷成孤儿。无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。
|
||||
|
||||
前端门槛:`cd web && npm run check`(tsc + vitest + vite build)。
|
||||
|
||||
## 旧卷迁移(一次性,升级自上一版部署)
|
||||
|
||||
```bash
|
||||
# 老 PG 数据 → 新共享卷
|
||||
docker run --rm -v book-comic-library_pgdata:/from -v booklib_postgres_data:/to alpine cp -a /from/. /to/
|
||||
# 老 cache 卷是封面/解压派生数据,直接丢弃(自动重建)
|
||||
docker volume rm book-comic-library_pgdata book-comic-library_cache
|
||||
```
|
||||
|
||||
书库文件:原宿主 `./library/` 的内容移入 `deploy/api/storage/`。
|
||||
|
||||
## 可信代理与限流
|
||||
|
||||
- nginx 在 compose 网络内,api 的 `ClientIP` 只信 `TRUSTED_PROXY_CIDRS`(逗号分隔 CIDR,默认 `172.16.0.0/12`,即 compose 网段)。外部伪造 `X-Forwarded-For` 换不掉限流桶;换部署网络时改这个 env。
|
||||
- 登录限流 5 次/分钟/IP **按尝试计数,成功登录也计**——爆破和正常高频登录同账。
|
||||
|
||||
## 改 schema 前必读
|
||||
|
||||
`db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。
|
||||
|
||||
## PWA
|
||||
|
||||
不可变资源(封面/CBZ 页/原文件)SW cache-first,读过的内容离线可翻;登出会清 SW 缓存。
|
||||
````
|
||||
|
||||
- [ ] **Step 2: release 终验(spec §9.1/9.5)**
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.dev.yml down
|
||||
docker compose -f deploy/docker-compose.yml up -d --build
|
||||
bash scripts/smoke.sh && bash scripts/smoke-web.sh
|
||||
cd backend && go vet ./... && gofmt -l . && go test -p 1 -count=1 ./...
|
||||
cd ../web && npm run check
|
||||
```
|
||||
|
||||
Expected: 冒烟全绿、后端测试全过、前端 check 过。
|
||||
|
||||
- [ ] **Step 3: 规范符合性核对表(spec §9.4,逐条人工打勾)**
|
||||
|
||||
| 规范条目 | 核对命令/位置 |
|
||||
|---|---|
|
||||
| dev/release 两文件路径 | `deploy/docker-compose{.dev,}.yml` |
|
||||
| dev 源码挂载 + 匿名 node_modules | dev compose `volumes` |
|
||||
| `target: dev` / `*.prod target: runner` | compose `build.target` |
|
||||
| infra 端口:dev 暴露(5432/6379)、release 内网 | 两文件 `ports` |
|
||||
| delve 2345 | dev compose `ports` |
|
||||
| healthcheck 门控 | dev/release `depends_on.condition` |
|
||||
| `{$project}_postgres/redis_data` 同名共享 | `docker volume ls` 一次 |
|
||||
| 渲染配置 `:ro` + logs 挂载 | 两文件 `volumes` |
|
||||
| 存储挂 `deploy/{service}/storage` | `./api/storage:/data/books` |
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs(readme): rewrite run/dev/migrate instructions for dev/release deploy spec"
|
||||
```
|
||||
Reference in New Issue
Block a user