Fork Recipes 日常运维与升级手册

Fork Recipes 双通道备份恢复(backupper 快照 201/204/201 与 pg_dump 49KB 异库校验闭环)、四容器巡检清单、docker update 垂直调整、pull+up 升级与 pinned tag 回滚锚点、migrate 幂等实测,全链路运维手册附真实回显。

Fork RecipesFork Recipes自托管DockerPostgreSQLDjango2026/11/01

Fork Recipes 日常运维与升级手册

目标:给已按本专栏 《Fork Recipes Docker Compose 部署指南》 完成部署(nginx + forkapi + fork.recipes + PostgreSQL 四容器形态)的运维者一套可照抄的巡检、备份恢复、监控、升级回滚流程。本文全部命令与回显为同一台 Ubuntu 24.04 宿主机(IP 192.168.1.172,端口 8146/8148/5434)2026-11-01 实测。项目架构背景见 《Fork Recipes 介绍与选型指南》。

一、环境信息

被管对象(docker compose -f docker-compose-postgres.yml ps 实测稳态):

fork-recipes-db   db      running
forkapi           be      running
nginx             nginx   running
recipes           web     running
  • 版本口径:前端 fork.recipes v.4.1.1-Hot-fix(镜像 forkrecipes:latest,本地构建)、后端镜像 mikebgrep/forkapi:latest(v.6.1.1.Hot-fix 系列)、postgres:17-alpine、nginx:fork-recipes
  • 数据落点:业务主数据在 PostgreSQL(bind 宿主 tmp/fork-recipes/data/postgres)、食谱图片/视频/快照在命名卷 forkrecipes_web_media、前端会话 SQLite 在容器层(官方挂载错位,不持久化——影响与修法见 6.4 与常见问题)
  • 工作目录约定:cd /opt/data/quzuo/tmp/fork-recipes/deploy/fork.recipes(compose 文件所在目录),备份产物落 tmp/fork-recipes/backup/

二、日常巡检

2.1 容器状态与重启次数

$ docker ps --format '{{.Names}}\t{{.Status}}' | grep -E 'nginx|forkapi|recipes|fork-recipes-db'
nginx               Up 22 minutes
recipes             Up 9 minutes
forkapi             Up 22 minutes
fork-recipes-db     Up 22 minutes

要点:recipes 的 Up 时间明显短于其他三个是正常的(本文 5.3 做过一次 restart web 演练);异常信号是反复出现 Up Less than a minute(崩溃循环),立刻 docker logs <容器> 看尾部。

2.2 uwsgi socket 就绪(nginx↔前后端的命脉)

nginx 靠共享卷里两个 socket 文件转发,缺一即 502:

$ docker exec nginx ls -la /tmp/uwsgi/
total 8
drwxr-xr-x 2 root root 4096 Nov  1 14:57 .
drwxrwxrwt 1 root root 4096 Nov  1 14:44 ..
srw-rw---- 1 www-data www-data 0 Nov  1 14:44 uwsgi.sock
srw-rw---- 1 www-data www-data 0 Nov  1 14:57 uwsgi_recipes.sock

uwsgi.sock=后端 API,uwsgi_recipes.sock=前端 UI;文件 mtime 即对应进程最近一次拉起时间。

2.3 服务可用性三点探测

$ curl -s -o /dev/null -w "FE login page HTTP:%{http_code}\n" http://192.168.1.172:8146/login/
FE login page HTTP:200
$ curl -s -o /dev/null -w "API category HTTP:%{http_code}\n" http://192.168.1.172:8148/api/recipe/category -H "X-Auth-Header: ced9106d-4f94-4754-b20d-0bdbf49b4fb2"
API category HTTP:200
$ curl -s -o /dev/null -w "admin panel HTTP:%{http_code} -> %{redirect_url}\n" http://192.168.1.172:8148/admin/
admin panel HTTP:302 -> http://192.168.1.172:8148/login/?next=/admin/

判定:前端 200、只读 API 200(X-Auth-Header 值取自 .env)、管理面板 302 到登录页,三者齐即全链路健康;任何一环 502 先查 2.2 的 socket,500 先查 docker logs forkapi。

2.4 资源占用基线

$ docker stats --no-stream --format '{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}'
nginx            0.00%    7.406MiB / 15.57GiB
recipes          0.00%    35.75MiB / 15.57GiB
forkapi          0.00%    184.8MiB / 15.57GiB
fork-recipes-db  0.00%    78.54MiB / 15.57GiB

空闲基线合计约 306 MiB;forkapi 是内存大头(uwsgi 多 worker + 抓取任务),本文第五章已把它限额到 512 MiB。

2.5 磁盘水位

$ du -sh /opt/data/quzuo/tmp/fork-recipes/data/postgres
64M    /opt/data/quzuo/tmp/fork-recipes/data/postgres

增长监控对象:PG 数据目录(结构化数据)、forkrecipes_web_media 卷(图片/视频/快照 zip,视频食谱会显著放大)、镜像本身 2.96 GB 为固定成本。

三、备份与恢复

Fork Recipes 有两条互补备份通道,都要用起来:

通道覆盖范围不覆盖适用
应用层 api/backupper 快照业务模型全量(data.json:食谱/食材/步骤/分类/用户…)+ 食谱媒体文件前端 SQLite、PG 系统表、uwsgi 配置迁移到新实例、误删食谱回滚
数据库层 pg_dumpPostgreSQL 全部表与序列媒体文件(图片在卷里)日常定时备份、库级恢复

3.1 backupper 快照:创建

$ curl -s -X POST http://192.168.1.172:8148/api/backupper/ \
    -H "Authorization: Token 59cfa1b255ba57535043e4b80fde7eeae393ce0d" \
    -H "X-Auth-Header: ced9106d-4f94-4754-b20d-0bdbf49b4fb2" -w "\nHTTP:%{http_code}\n"
["Successfully created backup file: backups/fork_recipes_2026.11.01_14.50.35.zip"]
HTTP:201

快照 zip 直接落在 web_media 卷的 backups/ 子目录,可经 media URL 下载:

$ curl -s -o bk.zip -w "HTTP:%{http_code} size:%{size_download}\n" \
    http://192.168.1.172:8148/media/backups/fork_recipes_2026.11.01_14.50.35.zip
HTTP:200 size:2580
$ docker exec forkapi python -c "import zipfile;print(zipfile.ZipFile('media/backups/fork_recipes_2026.11.01_14.50.35.zip').namelist())"
['data.json', 'images/b3a050cf-911d-46de-b8fc-6faa8badc9ea_tomato.png']

(zip 内容 = data.json + 食谱图片,实测吻合。)

3.2 backupper 快照:列表、恢复、导入

$ curl -s http://192.168.1.172:8148/api/backupper/ \
    -H "Authorization: Token 59cfa1b255ba57535043e4b80fde7eeae393ce0d" \
    -H "X-Auth-Header: ced9106d-4f94-4754-b20d-0bdbf49b4fb2"
[{"pk":1,"file":"http://192.168.1.172:8148/media/backups/fork_recipes_2026.11.01_14.50.35.zip","created_at":"2026-11-01T14:50:35.228000Z","size":2580},{"pk":2,"file":"http://192.168.1.172:8148/media/backups/fork_recipes_imported.zip","created_at":"2026-11-01T15:01:28.592505Z","size":2580}]

恢复到当前实例(覆盖式:解包重放 data.json,先建快照再操作):

$ curl -s -X PATCH http://192.168.1.172:8148/api/backupper/ \
    -H "Authorization: Token 59cfa1b255ba57535043e4b80fde7eeae393ce0d" \
    -H "X-Auth-Header: ced9106d-4f94-4754-b20d-0bdbf49b4fb2" \
    -H "Content-Type: application/json" -d '{"backup_pk":1}' -w "HTTP:%{http_code}\n"
HTTP:204

从外部 zip 导入(跨实例迁移入口,只认 .zip 后缀):

$ curl -s -X POST http://192.168.1.172:8148/api/backupper/import/ \
    -H "Authorization: Token 59cfa1b255ba57535043e4b80fde7eeae393ce0d" \
    -H "X-Auth-Header: ced9106d-4f94-4754-b20d-0bdbf49b4fb2" \
    -F "file=@bk.zip;filename=fork_recipes_imported.zip" -w "\nHTTP:%{http_code}\n"
"fork_recipes_imported.zip"
HTTP:201

导入后它出现在列表(pk=2),再 PATCH {"backup_pk":2} 即完成「新实例装空库 → 导入 zip → 应用快照」的迁移闭环。

3.3 pg_dump 数据库备份(含异库恢复校验)

备份:

$ docker exec fork-recipes-db pg_dump -U forkuser -d forkrecipes > backup/pg_dump_2026.11.01.sql
$ ls -la backup/pg_dump_2026.11.01.sql
-rw-r--r-- 1 root root 49533 Nov  1 23:01 backup/pg_dump_2026.11.01.sql
$ grep -c "CREATE TABLE\|INSERT INTO" backup/pg_dump_2026.11.01.sql
23

恢复演练(恢复到同实例新建的临时库做完整性校验,不碰生产库):

$ docker exec -i fork-recipes-db psql -U forkuser -d postgres -c "CREATE DATABASE forkrecipes_rt OWNER forkuser"
CREATE DATABASE
$ docker exec -i fork-recipes-db psql -U forkuser -d forkrecipes_rt -q < backup/pg_dump_2026.11.01.sql
$ echo RESTORE_OK
RESTORE_OK
$ docker exec fork-recipes-db psql -U forkuser -d forkrecipes_rt -c "select count(*) from recipe_recipe;" -c "select count(*) from recipe_ingredient;" -c "select count(*) from recipe_category;"
 count
-------
     1
(1 row)
 count
-------
     2
(1 row)
 count
-------
     4
(1 row)

行数与生产库(1 食谱 / 2 食材 / 4 分类)完全一致,恢复有效;校验完删除临时库:

$ docker exec fork-recipes-db psql -U forkuser -d postgres -c "DROP DATABASE forkrecipes_rt;"
DROP DATABASE

真要就地恢复生产库时(灾难场景):docker compose stop be web nginx → psql -U forkuser -d postgres -c "DROP DATABASE forkrecipes;" → CREATE DATABASE forkrecipes OWNER forkuser → 灌入 dump → docker compose up -d。

3.4 媒体卷备份

图片/视频/快照 zip 都在 forkrecipes_web_media 卷,用一次性容器打包(只读挂载,不影响服务):

$ docker run --rm -v forkrecipes_web_media:/mnt:ro -v $(pwd):/backup alpine \
    tar czf /backup/web_media_2026.11.01.tar.gz -C /mnt .
$ ls -la web_media_2026.11.01.tar.gz
-rw-r--r-- 1 root root 3195 Nov  1 23:06 web_media_2026.11.01.tar.gz
$ tar tzf web_media_2026.11.01.tar.gz | head -6
./
./backups/
./backups/fork_recipes_imported.zip
./backups/fork_recipes_2026.11.01_14.50.35.zip
./backups/readme.md
./videos/

恢复对偶命令:docker run --rm -v forkrecipes_web_media:/mnt -v $(pwd):/backup alpine tar xzf /backup/web_media_2026.11.01.tar.gz -C /mnt。

3.5 建议备份计划(cron 示例)

0 3 * * * docker exec fork-recipes-db pg_dump -U forkuser -d forkrecipes > /opt/data/quzuo/tmp/fork-recipes/backup/pg_$(date +\%F).sql
30 3 * * * docker run --rm -v forkrecipes_web_media:/mnt:ro -v /opt/data/quzuo/tmp/fork-recipes/backup:/backup alpine tar czf /backup/media_$(date +\%F).tar.gz -C /mnt .

💡 技术拓展:backupper 的 data.json 本质是 Django 模型序列化全量导出(含用户与密码 hash),与 pg_dump 的差别在于跨数据库形态可移植(SQLite 实例的快照可以导入 PostgreSQL 实例);pg_dump 则是库级精确快照、可异库校验。两者互为校验,别只留一份。

四、监控与告警

应用无内建指标端点(源码无 Prometheus/metrics 模块,与 Fider 不同),监控靠日志 + 容器指标三层:

  1. 入口层 nginx access log(docker logs nginx 直出,格式含状态码):
$ docker logs nginx --tail 3
192.168.1.172 - - [01/Nov/2026:14:57:19 +0000] "GET /api/recipe/home/preview/?page=1 HTTP/1.1" 200 268 "-" "curl/8.5.0"
192.168.1.172 - - [01/Nov/2026:15:01:28 +0000] "POST /api/backupper/import/ HTTP/1.1" 201 27 "-" "curl/8.5.0"
192.168.1.172 - - [01/Nov/2026:15:01:28 +0000] "GET /api/backupper/ HTTP/1.1" 200 290 "-" "curl/8.5.0"

异常码速查:502=对应 uwsgi socket 未就绪(查 2.2);403=API 忘带 X-Auth-Header;500=应用异常(查第 2 层日志)。

  1. 应用层:docker logs forkapi(uwsgi 请求行 + 迁移/任务输出)、docker logs recipes(前端)。请求行样例(实测):
[pid: 8|app: 0|req: 5/5] 192.168.1.172 () {38 vars in 545 bytes} [Sun Nov  1 14:46:41 2026] POST /api/auth/token => generated 128 bytes in 147 msecs (HTTP/1.1 200) 8 headers in 243 bytes
  1. 资源层:docker stats --no-stream(基线见 2.4)+ du -sh data/postgres(2.5)。

最小外部探活脚本(可接进现有监控):

#!/bin/sh
for u in "http://192.168.1.172:8146/login/" "http://192.168.1.172:8148/api/recipe/category"; do
  code=$(curl -s -o /dev/null -w "%{http_code}" -H "X-Auth-Header: ced9106d-4f94-4754-b20d-0bdbf49b4fb2" "$u")
  [ "$code" = "200" ] || echo "ALERT $u -> $code"
done

实测两个探测点分别返回 200/200,脚本无 ALERT 输出即健康。

五、扩容缩容

5.1 水平扩展不可行的原因(架构约束)

官方 compose 的 be/web 用 network_mode: host + 固定容器名 + unix socket 共享卷 + 前端会话存本地 SQLite:同机起第二个 forkapi 会撞容器名与 host 网络端口,nginx 也只认卷里那一个 socket 文件。结论:Fork Recipes 按单实例规划,多实例并行需要自行改造(去 host 网络、socket 改 TCP uwsgi_pass、会话集中化),官方不支持。

5.2 垂直调整(实测)

给后端限内存(防抓取任务 OOM 拖垮整机):

$ docker update --memory 512m --memory-swap 512m forkapi
forkapi
$ docker inspect forkapi --format '{{.HostConfig.Memory}}'
536870912

要点:docker update 即时生效不用重启容器;但该限额不写回 compose,容器重建即失效——长期生效请写进 compose 的 mem_limit: 字段。Docker 29 实测 docker update --memory 0 不能复位为不限(仍显示 536870912),复位需 down + up -d 重建。

5.3 单服务重启(配置改动后)

$ docker compose -f docker-compose-postgres.yml restart web
 Container recipes Restarting
 Container recipes Started

前端 .env 改动(如 SERVICE_BASE_URL)对 web/be 重启即生效;nginx conf 改动必须重建镜像(配置 COPY 进镜像,见部署篇第七章)。

六、升级与回滚

6.1 升级前置:双通道备份

先做 3.1(backupper 快照)+ 3.3(pg_dump),再动版本。

6.2 后端升级(镜像级)

$ docker compose -f docker-compose-postgres.yml pull be
 Image mikebgrep/forkapi:latest Pulling
 Image mikebgrep/forkapi:latest Pulled
$ docker compose -f docker-compose-postgres.yml up -d
 Container recipes Running
 Container nginx Running
 Container forkapi Running
 Container fork-recipes-db Running

要点:latest 标签无变化时 up -d 显示 Running(不重建,实测);拉到新摘要后 compose 会自动重建 be 容器,启动命令里的 migration.sh 自动跑迁移——重复执行是幂等的(实测):

$ docker exec forkapi python manage.py migrate
Running migrations:
  No migrations to apply.

生产建议用摘要或版本 tag 钉死镜像(image: mikebgrep/forkapi:<tag>),不要常年跟 latest:项目发版集中在 2025 上半年,latest 与 tag 内容漂移不可控。

6.3 前端升级(源码构建级)

前端无官方镜像,升级=切 tag + 重建:

git fetch --tags && git checkout v.4.1.1-Hot-fix   # 目标 tag
vi Dockerfile                       # 确认 FROM 仍是 python:3.12-slim-bookworm(trixie 坑,见排错篇)
docker compose -f docker-compose-postgres.yml build web
docker compose -f docker-compose-postgres.yml up -d web

6.4 回滚

镜像回滚锚点(升级前先打,实测):

$ docker tag mikebgrep/forkapi:latest mikebgrep/forkapi:pinned-20261101
$ docker images --format '{{.Repository}}:{{.Tag}}\t{{.ID}}' | grep 'mikebgrep/forkapi'
docker.1ms.run/mikebgrep/forkapi:latest      68ba6c32870d
mikebgrep/forkapi:latest                     68ba6c32870d
mikebgrep/forkapi:pinned-20261101            68ba6c32870d
hub.rat.dev/mikebgrep/forkapi:latest         68ba6c32870d

回滚=把 compose 的 be 镜像指回 pinned 标签后 up -d(三个标签同一镜像 ID 68ba6c32870d,指回即还原)。

数据库回滚的硬约束:Django 迁移没有自动 down——新镜像一旦跑过迁移,旧镜像可能读不懂新 schema。回滚顺序必须是:改回旧镜像 → 用 3.3 的 pg_dump 恢复升级前库 → 起服务。若只是应用行为异常而 schema 未变,直接回镜像即可。

应用数据回退(误删食谱等):不碰镜像,直接 PATCH api/backupper/ 应用升级前快照(3.2 实测 204)。

6.5 版本口径提醒

本文部署的前端 tag v.4.1.1-Hot-fix 钉 Django 5.1.8,而 fork.recipes master 分支已升 Django 5.2.x——切 master 升级属于跨大版本行为,先备份、在测试实例验证登录与建谱后再动生产。

七、故障排查手册(定位流程)

现象 → 第一步看哪里
502(前端或 API 全挂)   → docker exec nginx ls /tmp/uwsgi/(2.2)→ 缺哪个 socket 就 docker logs 对应容器
500(API 报错)          → docker logs forkapi 尾部 50 行;先排除 GET /api/recipe/ 根路径(官方必 500,用 home/preview)
登录成功又跳回登录页      → .env 的 SERVICE_BASE_URL 是否宿主机可达;DJANGO_SECRET 前后端是否同值
图片不显示(URL 404)     → web_media 卷是否挂全(be+nginx 两处)、nginx 81 端口 /media 块是否在
建谱 400                 → servings/prep_time/cook_time 必须整数、image 必填(部署篇 4.7-④)
重建容器后要重新登录      → 前端 SQLite 挂载错位(设计内行为,数据无损)
compose up 报端口占用     → ss -ltn 找占用方,改 compose ports 映射(本项目 8146/8148/5434 为避让产物)

逐条现象→根因→解法的完整实录见本专栏《Fork Recipes 故障排查与 FAQ》。

八、常见问题

Q1:backupper 快照里有用户密码吗? 有。data.json 含用户模型全量(密码为 hash),快照 zip 按敏感文件管理,media URL 是匿名可下载的(实测 /media/backups/...zip 200 无需认证)——公网部署务必给 8148 加反代认证或防火墙。

Q2:SEED_DEFAULT_DATA=true 会反复覆盖数据吗? 不会。seed 只在首次 migrate 时建管理员与分类(get_or_create 语义),第二次启动 No migrations to apply. 后 seed 也不重复插(实测升级演练后分类仍 4 个、食谱仍 1 个)。

Q3:改了管理员密码忘了怎么办? 命令行重置(不依赖邮件找回,实测回显):

$ printf 'zz123456\nzz123456\n' | docker exec -i forkapi python manage.py changepassword admin
Password changed successfully for user 'admin'

非交互场景(如批量脚本)改用 shell 直设,实测:

$ docker exec forkapi python manage.py shell -c "from django.contrib.auth import get_user_model; u=get_user_model().objects.get(username='admin'); u.set_password('ChangeMe'); u.save(); print('password reset ok')"
password reset ok

实测把 admin 改成 zz123456 后,再用 shell 方式回改为种子密码 ChangeMe,POST api/auth/token 验证登录恢复且返回原 token。也可在管理面板用户列表直接改。

Q4:备份要备几份? 最少三件套:pg_dump(结构化)+ web_media tar(媒体)+ backupper zip(跨形态迁移用)。前端 SQLite 无需备份(会话数据,丢了重新登录)。

九、参考链接

Fork Recipes 日常运维与升级手册 - Quzuo