由 7 名成员并行审计 + 队长线上实测复核产出,合计约 3,600 行: - backend/audit-backend.md 后端架构与代码质量(22 条) - backend/audit-security.md 认证/授权/敏感信息(23 条) - backend/audit-testing.md 测试与可验证性(含真实命令输出) - backend/audit-requirements.md 需求覆盖对照(多租户规格错位) - frontend/audit-frontend.md 前端与移动端工程(32 条) - frontend/audit-uxui.md 交互与视觉一致性 - docker/audit-deploy.md 容器化与部署链路 - AUDIT-REPORT.md 总报告:止血项、修复路线、待决策事项、对既有结论的修正 各报告均标注级别、文件:行号、影响、建议修法,并单列未验证项与抽样边界。
57 KiB
FastapiAdmin 容器化 / 部署链路审计报告
- 审计任务:t6 — Docker 与部署运维审计(docker/ + deploy.sh + CI)
- 审计对象:
docker/(compose、backend/Dockerfile、nginx、mysql、redis)、deploy.sh、deploy-artifacts.sh、.github/workflows、根.dockerignore、backend/env/* - 工作区:
/Users/tao/workspace/FastapiAdmin,分支工作树(含未提交改动) - 约束遵守:只读审计。未执行
docker build/docker compose up、未启动任何容器、未安装依赖、未提交 git;本报告是唯一新增文件。 - 证据标注规则:
文件:行号均为审计时刻工作区实际内容;实测命令输出见第 8 节。
1. 结论速览
1.1 上线前必须修(blocker / high)
| ID | 级别 | 一句话问题 | 关键证据 |
|---|---|---|---|
| B1 | blocker | 生产环境从未注入 SECRET_KEY,JWT 签名 + 数据加密主密钥都是源码里的公开默认值 |
backend/app/config/setting.py:66;docker/docker-compose.yaml:99-119;backend/env/.env.prod(无该键) |
| B2 | blocker | 按当前工作树构建后端镜像必然失败:pip 装依赖时 requirements/base.txt 还没被 COPY 进镜像 |
docker/backend/Dockerfile:14,17,20;backend/requirements.txt:12-16 |
| B3 | blocker | 真实 OPENAI_API_KEY 与生产库口令被 COPY ./backend/ . 烤进镜像层 |
docker/backend/Dockerfile:20;.dockerignore:18(只排除 .env);backend/env/.env.prod |
| H1 | high | MySQL(3306)/Redis(6379)/Backend(8001) 全部绑定 0.0.0.0 直接对公网暴露 |
docker/docker-compose.yaml:22-23,58-59,120-121;docker-compose config 无任何 host_ip |
| H2 | high | FORWARDED_ALLOW_IPS=* + 8001 直连 → X-Forwarded-For 可伪造,绕过 IP 登录限流 |
docker/docker-compose.yaml:117-119;backend/app/config/setting.py:34,74 |
| H3 | high | 生产 TLS 私钥权限 0666(全局可写),pfx/jks 明文口令随目录一起上传 |
ls -l docker/nginx/ssl/server.key;docker/nginx/ssl/service.fastapiadmin.com/{nginx,iis,tomcat}/;docker/.env(0644) |
| H4 | high | 证书纯手动更换、无自动续期、无到期告警;HTTP 块把 ACME http-01 校验路径也 301 到 HTTPS | docker/nginx/nginx.conf:73-77,86-87;证书 notAfter=Mar 6 2027 |
| H5 | high | 没有任何 CI:.github/workflows/ 是空目录 |
ls -laR .github/ 仅一个空 workflows/ |
| H6 | high | 构建上下文约 68 MiB,内含 21 个 ssl 私钥文件与两套前端产物 | .dockerignore 未排除 docker/、backend/dist;实测 context≈68.4 MiB |
| H7 | high | 后端镜像以 root 运行,无 no-new-privileges / cap_drop / read_only |
docker/backend/Dockerfile(26 行,无 USER);docker-compose.yaml:89-159(无 security_opt) |
| H8 | high | ./deploy.sh 会在全站停机式重建后无论成败都打印"部署完成"并返回 0,且无备份、无回滚 |
deploy.sh:78,106-113,125-137,141 |
1.2 可排期优化(medium / low)
| ID | 级别 | 一句话问题 | 关键证据 |
|---|---|---|---|
| M1 | medium | nginx 只等 backend "已启动"而不等健康 → 冷启动窗口 502 | docker-compose.yaml:178-180(backend 自身有 healthcheck:138-147) |
| M2 | medium | deploy.sh 依赖探测允许独立版 docker-compose,其余流程却硬编码 docker compose |
deploy.sh:48 vs deploy.sh:72,78,81,92,98,100,106,146 |
| M3 | medium | MySQL/Redis 口令以明文出现在容器 argv / healthcheck(docker inspect 可见) |
docker-compose.yaml:33,66,71;docker-compose config 实测展开明文 |
| M4 | medium | 基础镜像全部浮动 tag,不可复现且 nginx 1.25 已非当前主线 | docker-compose.yaml:13,53,164;docker/backend/Dockerfile:2 |
| M5 | medium | 数据面与前端面同网络、无 stop_grace_period、container_name 固定、mysql/redis 无 CPU 限制 |
docker-compose.yaml:12,52,90,163,217-219 |
| M6 | medium | 挂载宿主机 /etc/localtime 冗余且宿主缺文件时会导致容器启动失败 |
docker-compose.yaml:26(对比 TZ 环境变量 17/57/100/168) |
| M7 | medium | 缺 HSTS / CSP / Permissions-Policy,无 OCSP stapling、无 ssl_session_tickets off |
docker/nginx/nginx.conf:58-63,85-92(grep 全无命中) |
| M8 | medium | 全站无 Cache-Control/expires,带 hash 的 immutable 资源每次回源 | docker/nginx/nginx.conf(grep 无 Cache-Control/expires);docker/nginx/web/dist/js/index.*.js |
| M9 | medium | location /web、/app、/api/v1 前缀匹配过宽;WS 头对所有 API 请求无条件设置 |
docker/nginx/nginx.conf:103,110,116,131-133 |
| M10 | medium | nginx 允许 50m 请求体而应用只收 10MB,等于把 40MB 无效流量灌进 Python | docker/nginx/nginx.conf:30;backend/app/config/setting.py:223 |
| M11 | medium | nginx 健康检查只校验配置语法,证书过期/无法服务时仍是 healthy | docker-compose.yaml:184 |
| M12 | medium | 部署过程无落盘审计日志、无版本记录;cleanup() 无条件清空构建缓存 |
deploy.sh:96-101,116-121,125-137 |
| M13 | medium | .env 以 source 方式 eval 执行;占位口令只在"首次生成文件"时被拦截 |
deploy.sh:26-38;docker/.env.example:4,9,12 |
| M14 | medium | 域名硬编码在 nginx.conf 与 setting.py,README 声称存在 NGINX_SERVER_NAME 变量但实际没有 |
docker/nginx/nginx.conf:75,83;backend/app/config/setting.py:186;docker/README.md:63 |
| M15 | medium | 真正带备份/回滚的部署脚本 deploy-artifacts.sh 未纳入版本管理,且硬编码生产 IP + root 登录 |
git ls-files deploy-artifacts.sh 为空;deploy-artifacts.sh:46-48 |
| L1 | low | mysql/init 是空目录,但 compose 注释与 README 声称"自动执行 init SQL" |
docker/docker-compose.yaml:27-28;docker/README.md:17 |
| L2 | low | docker/README.md 与实际实现严重漂移(含不存在的 CLI 参数、"自动拉取代码"、错误的访问地址) |
docker/README.md:87,93-103,132-138,219-221 vs deploy.sh:5-6,143-161 |
| L3 | low | 镜像里带 22 MiB 旧前端产物,且 app.frontend("/") 注册 catch-all,未匹配路径返回 SPA 而非 JSON 404 |
backend/app/__init__.py:127-129;.dockerignore 未排除 backend/dist |
| L4 | low | 镜像内保留大体积 source map(redoc standalone map 2.5 MiB 等) | backend/static/swagger/redoc/bundles/redoc.standalone.js.map |
| L5 | low | pip 源硬编码清华镜像,无法通过 build ARG 替换 | docker/backend/Dockerfile:17 |
| L6 | low | ENV TZ Asia/Shanghai 使用已废弃的空格语法;Dockerfile 无 HEALTHCHECK |
docker/backend/Dockerfile:8 |
| L7 | low | mkdir -p 建数据目录但不修属主(MySQL 需 999:999) |
deploy.sh:41;docker/README.md:206 |
2. 上线前必须修(详细)
B1 [blocker] 生产 SECRET_KEY 使用源码内公开默认值 → JWT 可伪造 + 落库密文可解密
证据
backend/app/config/setting.py:66:注释说"无默认值",实际有默认值。SECRET_KEY: str = "fastapiadmin-dev-secret-key-do-not-use-in-production" # JWT密钥(必须通过环境变量 SECRET_KEY 设置,无默认值)- 全仓库检索
SECRET_KEY赋值来源(.venv/、node_modules/除外)只命中:测试用的backend/tests/conftest.py:32与源码默认值本身。 docker/docker-compose.yaml:99-119的 backendenvironment列表里没有SECRET_KEY,也没有env_file:。docker/.env.example(26 行)无SECRET_KEY;backend/env/.env.prod键清单(实测 awk 抽取)无SECRET_KEY;backend/env/.env.example:2只在注释里提到要改SECRET_KEY。- 该密钥还被复用为数据加密主密钥:
backend/app/utils/crypto_util.py:47-52在DATA_ENCRYPTION_KEY未配置时用SECRET_KEY做 HKDF 派生主密钥,crypto_util.py:52还兼容SHA256(SECRET_KEY)的历史密文。DATA_ENCRYPTION_KEY同样未在 compose 与.env.prod中出现(setting.py:79默认None)。
影响:任何读到过源码的人都能签发任意用户(含 admin)的有效 access/refresh token(backend/app/core/security.py:114,139);同时可解密数据库里用 Fernet 加密的存储源口令等敏感数据。
改法
docker/docker-compose.yamlbackend 服务增加密钥注入(三选一,推荐 1):或在environment: SECRET_KEY: "${SECRET_KEY:?错误: 请设置 SECRET_KEY 环境变量}" DATA_ENCRYPTION_KEY: "${DATA_ENCRYPTION_KEY:?错误: 请设置 DATA_ENCRYPTION_KEY 环境变量}"docker/.env中定义后由 compose 自动读取,并把两行加入docker/.env.example。backend/env/.env.prod与backend/env/.env.example补齐SECRET_KEY、DATA_ENCRYPTION_KEY键位(值留空)。- 生成:
openssl rand -hex 32。 - 加一道启动自检(后端或部署脚本均可):若
SECRET_KEY等于默认值则拒绝启动。 - 轮换顺序:先配
DATA_ENCRYPTION_KEY=旧SECRET_KEY的派生等价物或用DATA_ENCRYPTION_OLD_KEYS过渡,再换SECRET_KEY(crypto_util.py:47-62有旧密钥解密链),避免已落库密文不可解。
B2 [blocker] 当前工作树后端镜像必然构建失败(依赖层顺序错误)
证据
docker/backend/Dockerfile:
14: COPY ./backend/requirements.txt . # → /home/requirements.txt
17: RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
20: COPY ./backend/ . # → /home/requirements/ 在这一层才出现
而工作树 backend/requirements.txt:10-14 已经是拆分后的组合式清单:
-r requirements/base.txt
-r requirements/db/mysql.txt
-r requirements/db/postgres.txt
-r requirements/db/sqlite.txt
-r requirements/storage.txt
(证据时效:该文件在审计期间被其他并行审计成员修改过,行号由 12-16 变为 10-14;上述 -r 引用内容本身未变,已复核。)
backend/requirements/base.txt 等 5 个文件位于 backend/requirements/,而 git ls-files backend/requirements/ 输出为空(该目录尚未提交,git status 显示 ?? backend/requirements/)。
判定:第 17 行执行时 /home 下只有 requirements.txt。pip 对 -r 相对路径的解析基准无论是"当前工作目录 /home"还是"清单文件所在目录 /home",目标都落在 /home/requirements/base.txt —— 该路径在本层尚不存在(第 20 行才 COPY)。因此 docker compose build 与 deploy.sh 的 build_image() 都会以 Could not open requirements file: ... requirements/base.txt 失败。
改法(任选其一)
- 推荐:把依赖清单目录先 COPY 再安装,并按数据库类型只装所需驱动:
COPY ./backend/requirements/ /home/requirements/ RUN pip install --no-cache-dir \ -r /home/requirements/base.txt \ -r /home/requirements/db/mysql.txt \ -r /home/requirements/storage.txt - 或者把
backend/requirements/提交进仓库,并同步更新requirements.txt的引用路径。
对照:
deploy-artifacts.sh动态生成的 Dockerfile 已经写对了顺序(先COPY requirements/ /home/requirements/再pip install -r /home/requirements/base.txt),说明这是仓库内 Dockerfile 未跟上依赖拆分导致的不一致。
B3 [blocker] 真实 OPENAI_API_KEY 与生产库口令被烤进后端镜像
证据
docker/backend/Dockerfile:20:COPY ./backend/ .—— 把整个backend/复制进镜像/home,包含backend/env/。- 根
.dockerignore:18只有一行.env;dockerignore 的.env只匹配名为.env的文件/目录,不匹配backend/env/.env.prod、backend/env/.env.dev。 - 实测
backend/env/.env.prod键值特征(脱敏,仅长度与前 4 字符):OPENAI_API_KEY长度 35、以sk-d开头;DATABASE_PASSWORD长度 13、REDIS_PASSWORD长度 13;DATABASE_HOST为172.x内网地址、DATABASE_USER=root。这些都是真实可用凭据,非占位串。 - 反证团队已知该风险:
deploy-artifacts.sh在构建时用 Python 脚本显式剔除OPENAI_API_KEY/OPENAI_BASE_URL/OPENAI_MODEL(脚本内drop_prefixes),但仓库内的docker/backend/Dockerfile没有任何等效处理。 - 附带:
backend/env/.env.prod还是运行时配置的实际来源(setting.py:16-21以ENV_DIR/.env.{ENVIRONMENT}为env_file,path_conf.py:4,16→/home/env/.env.prod),compose 只覆盖了 DB/Redis 相关键,OPENAI_API_KEY等继续取自镜像内文件。
影响:任何拿到镜像的人(docker save、镜像仓库、构建缓存、离线交付包)都能取走生产大模型 API Key;同理 DATABASE_HOST/USER 泄露内网拓扑。
改法
.dockerignore增加排除(放在第 18 行附近):backend/env/.env backend/env/.env.* !backend/env/.env.example- Dockerfile 改为白名单式 COPY(更彻底):
运行时配置全部由 compose
COPY ./backend/app ./app COPY ./backend/main.py ./main.py COPY ./backend/alembic.ini ./alembic.ini COPY ./backend/static ./static COPY ./backend/sql ./sql COPY ./backend/templates ./templates COPY ./backend/data ./data COPY ./backend/banner.txt ./banner.txtenvironment/env_file注入。 - 既然密钥已进过镜像层:视为已泄露,上线前轮换
OPENAI_API_KEY、MySQL/Redis 口令,并清理本机悬空构建缓存。
H1 [high] MySQL / Redis / Backend 端口直接对公网暴露
证据
docker/docker-compose.yaml:22-23:- "${MYSQL_PORT:-3306}:3306"docker/docker-compose.yaml:58-59:- "${REDIS_PORT:-6379}:6379"docker/docker-compose.yaml:120-121:- "${BACKEND_PORT:-8001}:8001"- 三处都没有
127.0.0.1:前缀;实测cd docker && docker-compose config输出的ports条目中host_ip字段一个都没有(grep -c host_ip= 0),即全部绑定0.0.0.0。 docker/redis/conf/redis.conf(17 行)没有bind、没有protected-mode yes,只有requirepass由 composecommand注入(docker-compose.yaml:64-66)。docker/README.md:195自己写着"生产环境建议注释 MySQL/Redis 的 ports 映射",docker/README.md:214写着"通过防火墙只开放 80/443" —— 但 compose 默认值并未落实。
影响:只要云安全组/主机防火墙放行,3306 与 6379 直接可被爆破与未授权探测;8001 直连会绕过 nginx 的限流、安全响应头、HTTPS 与上传限制,并暴露 /api/v1/docs。
改法
mysql:
ports:
- "127.0.0.1:${MYSQL_PORT:-3306}:3306" # 仅本机调试;生产建议整段删除
redis:
ports:
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
backend:
ports:
- "127.0.0.1:${BACKEND_PORT:-8001}:8001"
更彻底:删掉 mysql/redis 的 ports:(容器间用 app_network 互通即可),backend 同样删除,只保留 nginx 的 80/443;并在云安全组层面显式拒绝 3306/6379/8001 入方向。
另外在 redis.conf 补 bind 0.0.0.0 + protected-mode yes 只是兜底,真正的收敛靠端口绑定。
H2 [high] FORWARDED_ALLOW_IPS=* 配合 8001 直连 → 可伪造客户端 IP 绕过限流
证据
docker/docker-compose.yaml:117-119:# 信任反向代理(nginx)传来的 X-Forwarded-Proto/For FORWARDED_ALLOW_IPS: "*"- 结合 H1:backend:8001 绑定
0.0.0.0,攻击者可直连 8001 并自带X-Forwarded-For: 1.2.3.4。 - 依赖客户端 IP 的安全控制:
backend/app/config/setting.py:73-74(LOGIN_RATE_LIMIT_MAX_ATTEMPTS=10按 IP 限流)、setting.py:34(TRUSTED_PROXY_HOPS=1)、以及操作日志 / 在线用户记录。backend/env/.env.example里的TRUSTED_PROXY_HOPS注释明确写了"后端直接暴露公网时填 0,防止 X-Forwarded-For 伪造"——但 compose 没有设这个值,且 8001 恰恰是暴露的。
改法
- 先按 H1 收敛端口(首要)。
FORWARDED_ALLOW_IPS改为 nginx 所在的具体网段而非*,例如固定网络后写172.18.0.0/16;不要用*。- 若确实需要保留宿主机直连调试,同时在 compose 给 backend 加
TRUSTED_PROXY_HOPS: "0"。
H3 [high] TLS 私钥权限过大 + pfx/jks 明文口令随目录上传
证据(实测 ls -l)
-rw-r--r--@ docker/.env
-rw-rw-rw-@ docker/nginx/ssl/server.key # 0666
-rw-rw-rw-@ docker/nginx/ssl/server.pem # 0666
-rw-rw-rw-@ docker/nginx/ssl/service.fastapiadmin.com/nginx/service.fastapiadmin.com.key
-rw-rw-rw-@ docker/nginx/ssl/service.fastapiadmin.com/nginx/service.fastapiadmin.com.crt
docker/nginx/ssl/service.fastapiadmin.com/iis/password.txt、.../tomcat/password.txt各 6 字节明文口令(用于 pfx/jks 导出)。docker/nginx/ssl/service.fastapiadmin.com.zip是证书打包文件。docker/.env权限 0644(全局可读),而docker/README.md:45明确要求chmod 600 docker/.env;deploy.sh:29-33用cp .env.example .env生成文件时也不会改权限。- 好消息:证书/私钥/
.env都没有被 git 跟踪(.gitignore覆盖docker/nginx/ssl/*、docker/.env;git ls-files docker/已确认只有.gitkeep与配置模板)。 - 但
deploy.sh:5-6的部署模型是"把项目目录整体 scp/FTP 上传服务器",.env、server.key、*.zip、password.txt会原样落到服务器磁盘,权限一并带过去;同时它们也落在 Docker 构建上下文里(见 H6)。
改法
- 立即收紧:
chmod 700 docker/nginx/ssl && chmod 600 docker/nginx/ssl/*.key docker/.env;deploy.sh在load_env后加chmod 600 "${ENV_FILE}"与find docker/nginx/ssl -name '*.key' -exec chmod 600 {} +。 - 把
service.fastapiadmin.com/(含 iis/tomcat 的 pfx/jks/password.txt)与*.zip移出仓库工作目录,只在服务器上保留 nginx 需要的fullchain.pem+privkey.pem两份。 - 密钥交给 Docker secret / 宿主只读挂载(
docker/nginx/ssl已是:ro,见docker-compose.yaml:175,这点是对的),并在.gitignore之外加一道 pre-commit 检查防止误传。 - 若这些证书曾进入过镜像或构建缓存,按"已泄露"处理。
H4 [high] 证书手动更换、无续期、无到期监控;HTTP 块阻断 ACME http-01
证据
docker/nginx/nginx.conf:86-87固定读取/etc/nginx/ssl/server.pem与/etc/nginx/ssl/server.key;docker-compose.yaml:175把./nginx/ssl只读挂进容器。- 证书实测(
openssl x509 -noout -subject -issuer -dates):- Subject:
CN=service.fastapiadmin.com - Issuer:
C=PL/O=Asseco Data Systems S.A./CN=Certum DV TLS G2 R39 CA notBefore=Aug 19 14:10:22 2026 GMT,notAfter=Mar 6 14:10:21 2027 GMT- SAN:
DNS:service.fastapiadmin.com(单域名,无通配符) server.pem与 CA 交付的.crt内容一致(md5 相同),且与server.key公钥匹配(pubkey md5 相同)→ 配置与证书是配套的,这点没问题。
- Subject:
docker/nginx/nginx.conf:73-77的 80 端口 server 把所有请求return 301 https://...,其中没有location /.well-known/acme-challenge/例外 → Let's Encrypt http-01 校验会跟随 301 到 HTTPS(对同名域名通常仍可完成校验,但一旦server_name不匹配或走 IP 校验就会失败)。仓库内也没有任何 acme.sh / certbot / systemd timer / cron 配置。
影响:证书 2027-03-06 到期,而链路上没有任何机制会提醒;历史上"到期当天才发现"的概率很高。
改法
docker/nginx/nginx.conf:73-77的 HTTP server 增加:location /.well-known/acme-challenge/ { root /usr/share/nginx/html; allow all; }- 用 acme.sh(DNS-01 或 http-01)自动签发与续期,续期钩子:
acme.sh --install-cert -d service.fastapiadmin.com --key-file docker/nginx/ssl/server.key --fullchain-file docker/nginx/ssl/server.pem --reloadcmd "docker compose -f docker/docker-compose.yaml exec nginx nginx -s reload" - 加到期检查(cron 每天):
openssl x509 -in docker/nginx/ssl/server.pem -noout -checkend 1209600 || echo "证书 14 天内到期" | <告警通道> - 同步把
nginx -t之类的健康检查升级为带 HTTPS 探测(见 M11)。
H5 [high] 没有任何 CI
证据:ls -laR .github/ 输出 workflows/ 为空目录(total 0,无任何文件);仓库内也不存在其他 CI 配置(.gitlab-ci.yml、Jenkinsfile、.woodpecker.yml 等均无;find 命中的 yaml 只有 compose 与前端配置)。
影响:没有自动门禁意味着本报告 B2 这类"依赖层顺序写错、镜像根本构建不出来"的问题只能在生产部署时暴露;也没有镜像漏洞扫描、没有配置校验、没有后端测试门禁。
改法:新增 .github/workflows/ci.yml,至少包含
docker compose -f docker/docker-compose.yaml config -q(配置/变量插值校验,需提供 dummy env)docker build -f docker/backend/Dockerfile .(不 push,仅验证依赖层可解)ruff check backend+pytest backend/tests- 镜像分层扫描:
trivy image --severity HIGH,CRITICAL - 触发条件
on: [push, pull_request],paths覆盖docker/**、backend/**、.dockerignore
H6 [high] 构建上下文臃肿且包含私钥(约 68 MiB,21 个密钥文件)
证据(实测)
- 根
.dockerignore未排除docker/、backend/dist、backend/tests、backend/data、.agent-teams、*.zip。 - 按
.dockerignore规则静态估算的上下文体积:≈68.4 MiB,主要构成(实测逐个文件统计):backend/data/ip2region/ip2region_v4.xdb10.61 MiB(运行时需要,保留)docker/nginx/web/dist/**≈2.55+1.91+1.53+1.38+1.33 MiB… 合计 ≈22 MiB(死重)backend/dist/**≈22 MiB(死重,见 L3)backend/static/swagger/**数 MiB(含.map)docker/nginx/ssl/**21 个文件(含server.key、service.fastapiadmin.com/**/*.key、.zip、password.txt)
- 对比:被正确排除的有
.git(151 MiB)、backend/.venv(231 MiB)、frontend(1.5 GiB)、backend/logs(6.5 MiB)——这部分是好的。
影响:每次构建把 68 MiB 上下文送给构建守护进程(远程 daemon / colima / BuildKit 场景下走网络),其中含生产私钥;COPY ./backend/ . 的改变会让 22 MiB 的 backend/dist 一起进镜像层。
改法:在根 .dockerignore 追加
# 容器资产不进后端构建上下文(含 TLS 私钥)
docker/
# 后端镜像不需要的产物
backend/dist
backend/tests
backend/data/logs
.agent-teams
docker/nginx
*.zip
注意 backend/data/ip2region 必须保留(backend/app/config/path_conf.py:31-32 依赖该库做 IP 归属地查询),所以只排除 backend/data/logs,不要整目录排除 backend/data。
若同时想减小上下文,可把 deploy.sh 改为在 docker/ 目录内构建(build.context: ../ 目前是根目录,这也是私钥进上下文的原因)。
H7 [high] 镜像以 root 运行,缺少容器安全基线
证据:docker/backend/Dockerfile 全文 26 行,没有 USER 指令;没有 PYTHONUNBUFFERED / PYTHONDONTWRITEBYTECODE;没有进程管理器(tini 等)。docker/docker-compose.yaml 四个服务(9-219 行)都没有 security_opt、cap_drop、read_only、user:。
影响:容器逃逸/依赖链漏洞时以 root 权限作业;/home 可写导致代码被覆盖;缺 no-new-privileges 让 setuid 提权路径保持开放。
改法
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 LANG=C.UTF-8
RUN useradd -m -u 10001 app && mkdir -p /home/logs /home/static/upload && chown -R app:app /home
USER app
compose 侧:
init: true
security_opt: ["no-new-privileges:true"]
cap_drop: ["ALL"]
read_only: true
tmpfs: ["/tmp"]
配套注意:docker-compose.yaml:130 的 ./backend/static/upload:/home/static/upload 挂载需要把宿主目录属主改成 uid 10001,否则上传会失败(这是改非 root 后最容易踩的坑)。
H8 [high] ./deploy.sh 失败也会报"部署完成"并返回 0;且无备份、无回滚、整站重建
证据(均在 deploy.sh)
- 无条件全量重建:第 78 行
docker compose up -d --force-recreate(未指定服务名)→ MySQL、Redis、nginx 一并被销毁重建,每次部署都是一次全站停机。 - 健康门禁形同虚设:
- 第 80-85 行等待 MySQL healthy 循环 30 次,超时后不报错,直接继续。
- 第 106-113 行
verify()无论成败,最后一条语句都是log(echo -e),必然返回 0;第 113 行$ok && log ... || log ...两个分支都是成功退出。 - 第 125-137 行
full_deploy不管verify结果继续执行show_logs、cleanup,最后打印========== 🎉 部署完成 ==========。 - 结论:4 个容器全部 down 时,
./deploy.sh; echo $?仍是0。
- 无备份无回滚:整脚本没有
mysqldump、没有静态目录备份、没有镜像版本留存;docker-compose.yaml:96的image: "backend:${BACKEND_IMAGE_TAG:-3.0.0}"是固定 tag,重新构建即覆盖上一版 → 没有可回退的镜像。 - Ctrl-C 变成全站下线:第 141 行
trap 'stop_service; exit 130' INT TERM,而stop_service(90-94)执行docker compose down。 - 清理激进:第 118-119 行
docker image prune -f+docker builder prune -f会清空构建缓存,下一次构建从零开始(且builder prune -f在 BuildKit 下会丢弃所有缓存)。
改法(可直接复用 deploy-artifacts.sh 的成熟做法)
- 部署前备份(必做):
mkdir -p backups docker compose exec -T mysql sh -c 'exec mysqldump --single-transaction --routines --triggers -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' | gzip > "backups/db-$(date +%F-%H%M%S).sql.gz" tar -czf "backups/nginx-$(date +%F-%H%M%S).tgz" docker/nginx/web/dist docker/nginx/app docker/nginx/docs cp docker/.env "backups/env-$(date +%F-%H%M%S)" - 镜像版本化:构建后
docker tag backend:3.0.0 "backend:$(date +%Y%m%d-%H%M%S)",并把当前在跑的 tag 固定为backend:prev(deploy-artifacts.sh的_rollback_image已实现)。 - 只重建变化的服务:
docker compose up -d --no-deps --force-recreate backend,不要动 mysql/redis/nginx。 - 健康门禁:轮询
docker inspect --format '{{.State.Health.Status}}' backend直到healthy,超时即exit 1并自动回滚到backend:prev。 verify()必须能失败:改为累积状态并在末尾[ "$ok" = true ] || exit 1,且增加真实 HTTP 探测(curl -fsS https://域名/api/v1/monitor/health/check)。- 改掉 trap:
trap 'log "中断,未做任何回滚" WARN; exit 130' INT TERM,不要在中断时down。 - 清理改为显式命令:
cleanup只在./deploy.sh clean时调用,且去掉docker builder prune。
3. 分项审计明细
3.1 docker-compose(docker/docker-compose.yaml,219 行)
| 审计项 | 现状 | 结论 |
|---|---|---|
| 语法与变量校验 | docker-compose config 退出码 0(实测,见第 8 节) |
✅ 通过;${VAR:?} 必填校验生效(18,21,66 行,缺变量会直接报错并给出中文提示) |
| 端口暴露 | mysql 3306 / redis 6379 / backend 8001 全部 0.0.0.0(22-23,58-59,120-121) |
❌ H1 |
| 健康检查 | 四个服务都有 healthcheck(31-37,69-75,138-147,183-187) | ⚠️ 覆盖到位,但 mysql/redis 口令进 argv(M3),nginx 只 nginx -t(M11) |
| 依赖顺序 | nginx → backend 用 service_started(178-180);backend → mysql/redis 用 service_healthy(131-135) |
⚠️ backend 的两条依赖正确,nginx 这条应改 service_healthy(M1) |
| restart 策略 | 四服务均 unless-stopped(14,54,97,165) |
✅ |
| 资源限制 | backend 1 CPU/1 GB,nginx 0.5 CPU/256 MB,mysql 1 GB,redis 512 MB(43-48,81-86,153-159,193-199) | ⚠️ mysql/redis 无 CPU 上限;compose v2 会尊重 deploy.resources.limits,但 reservations 在非 swarm 下被忽略 |
| 日志滚动 | 四服务均 json-file + max-size: 10m + max-file: 3(38-42,76-80,148-152,188-192) |
✅ 与 docker/README.md:164 一致 |
| 数据卷与权限 | mysql_data/redis_data 用 driver_opts bind 到 ./mysql/data、./redis/data(202-214);实测 config 解析为绝对路径 /Users/tao/.../docker/mysql/data |
✅ 结构正确;⚠️ 属主未初始化(README:206 需手工 chown 999:999,L7);.gitkeep 占位说明目录已在库中(docker/mysql/data/.gitkeep) |
| 时区挂载 | /etc/localtime:/etc/localtime:ro(26) |
⚠️ 与 TZ 环境变量重复,宿主缺文件会创建目录导致启动失败(M6) |
| 网络隔离 | 四个服务同一 app_network bridge(217-219) |
⚠️ mysql/redis 与 nginx 同网,缺前后端分层(M5) |
| 生产/开发分离 | 仅靠 DEPLOY_ENV 注入 backend 的 ENVIRONMENT(95,102);开发热更新挂载已注释(123-127);无 profile、无独立 override 文件 |
⚠️ 分离靠一个变量,无 docker-compose.override.yaml/profiles 机制(M4/M5 一并改造) |
| 镜像 tag | mysql:8.0、redis:7-alpine、nginx:1.25-alpine、python:3.12-slim 均浮动(13,53,164;Dockerfile:2) |
❌ M4 |
| 平台 | 四服务均强制 platform: linux/amd64(15,55,98,166) |
ℹ️ 生产若为 x86_64 无影响;在 ARM 宿主上会走 qemu 模拟(性能差、mysql 可能起不来),换 ARM 机器时需删掉 |
| 上传目录 | ./backend/static/upload:/home/static/upload(130) |
✅ 必要(setting.py:211 UPLOAD_FILE_PATH=Path("static/upload"),容器 CWD 为 /home,路径对得上);注意这是 docker/backend/static/upload,由 deploy.sh:41 创建 |
3.2 Dockerfile 与 .dockerignore
docker/backend/Dockerfile(26 行)逐项:
| 审计项 | 结论 |
|---|---|
| 多阶段构建 | ❌ 单阶段。Python 场景收益有限,不是必须修项;但见下"体积" |
| 基础镜像固定版本 | ❌ python:3.12-slim(第 2 行)浮动 tag,无 digest(M4) |
| root 运行 | ❌ 无 USER(H7) |
| 依赖安装与缓存层 | ⚠️ 思路正确(先 COPY 清单再装,第 13-17 行),但层顺序错误导致构建失败(B2);且 COPY ./backend/ . 会把 22 MiB 的 backend/dist 一起进层(L3) |
| pip 源可替换性 | ❌ 硬编码 -i https://pypi.tuna.tsinghua.edu.cn/simple(第 17 行),无 ARG(L5) |
| 镜像体积 | ⚠️ --no-cache-dir 已用;但包含 backend/dist(22 MiB)、backend/static/swagger/**/*.map、backend/tests、backend/sql;且 pip install -r requirements.txt 装全部三种数据库驱动 + 全部三种对象存储 SDK(backend/requirements.txt),而生产只用 MySQL(crypto/paramiko/boto3/esdk-obs/aliyun/cos 体量可观) |
| 安全基线 | ❌ 无 HEALTHCHECK(第 25 行只有 CMD),无 --no-new-privileges 等(配合 compose,H7) |
| 时区/环境 | ⚠️ 第 8 行 ENV TZ Asia/Shanghai 用空格分隔(旧式语法,可用但不推荐);缺 PYTHONUNBUFFERED |
| 端口/启动 | ✅ EXPOSE 8001、CMD ["python","main.py","run","--env=prod"] 为 exec 形式(正确) |
.dockerignore(58 行)逐项:已正确排除 .venv(17)、.git(31)、logs(36)、*.log(37)、frontend(44)、backend/static/upload(58)、各类缓存(11-13,7-9)。
应排除而未排除:backend/env/.env.prod/.env.dev(B3)、docker/(含私钥,H6)、backend/dist(L3/L4)、backend/tests、.agent-teams、docker/nginx/ssl/**、*.zip。
注意不要排除:backend/data/ip2region/**(path_conf.py:31-32 运行时依赖)、backend/static/swagger/** 的 js/css(setting.py:228-230 的 Swagger/ReDoc 资源)、backend/sql/** 与 backend/templates/**(初始化种子与代码生成模板)。
3.3 nginx(docker/nginx/nginx.conf,142 行)
| 审计项 | 结论 |
|---|---|
| 反代路径 | ✅ 正确。location /api/v1 { proxy_pass http://backend:8001; }(116-128)不带 URI 部分,原样透传 /api/v1/...;FastAPI 侧 root_path="/api/v1"(setting.py:311)会把 scope["root_path"] 剥掉再匹配路由(fastapi/applications.py:1160-1163 → starlette/_utils.py:90-98 → starlette/routing.py:255),所以后端内部按 /system/...、/monitor/... 注册也能命中。compose 健康检查用的 /api/v1/monitor/health/check(docker-compose.yaml:142)与此一致。 |
| 上传大小限制 | ⚠️ 仅 http 级 client_max_body_size 50m(30 行),与应用 MAX_FILE_SIZE=10MB(setting.py:223)不匹配(M10) |
| gzip | ⚠️ 已开启(42-56),级别 6;缺 gzip_static、缺 application/wasm,字体已压缩无妨(M8 一并优化) |
| SSL 证书处理 | ⚠️ 证书与私钥匹配、未过期(实测),但无 OCSP stapling、无 ssl_session_tickets off、无续期机制(H4/M7) |
| 安全响应头 | ⚠️ 有 X-Frame-Options/X-Content-Type-Options/X-XSS-Protection/Referrer-Policy(60-63);缺 HSTS、CSP、Permissions-Policy,且 X-XSS-Protection 已废弃(M7)。因 location 块内未再定义 add_header,http 级头可正常继承到各 location。 |
| SPA history 回退 | ✅ /(99)、/web(105)、/app(112)都有 try_files ... /index.html;deploy-artifacts.sh 的注释也记录了"少一层就 500 内部重定向死循环"的坑 |
| 限流 | ✅ api_limit 30r/s burst=60 nodelay + conn_limit 100(67-69,117-118)只作用于 /api/v1 |
| 日志 | ⚠️ access_log ... buffer=32k flush=5s(39);第 8 行注释称"错误日志输出到 stdout",第 9 行实际写 /var/log/nginx/error.log——官方镜像里该路径是 /dev/stderr 的软链,因此效果上仍进容器日志,但注释与代码不一致,且 buffer 会丢失崩溃前最后 5 秒 |
| 匹配过宽 | ❌ location /web(103)会匹配 /webxxx;/app(110)、/api/v1(116)同理(M9) |
| WebSocket | ⚠️ 对所有 /api/v1 请求无条件设置 Connection "upgrade"(131-133),普通 HTTP 请求也带升级头(M9) |
| 文档暴露 | ⚠️ FASTAPI_CONFIG 关闭了内置 docs(setting.py:309-310 docs_url/redoc_url=None),但 app/__init__.py:118-119 的 register_docs(app) 无条件注册自定义 /docs、/redoc。经上面的 root_path 剥离规则,外部可通过 https://域名/api/v1/docs 访问 Swagger UI,而 nginx.conf:116 对 /api/v1 不做任何限制。建议:nginx 层显式 `location ~ ^/api/v1/(docs |
| 静态资源缓存 | ❌ 无任何 Cache-Control/expires(全文件 grep 无命中),而 docker/nginx/web/dist/js/ 下是带内容 hash 的 immutable 资源(M8) |
| error_page | ✅ 500 502 503 504 → /50x.html(137-140),路径落在镜像自带的 /usr/share/nginx/html/50x.html(未被挂载覆盖,因为只挂了子目录) |
3.4 deploy.sh(161 行)
见 H8(成功误报/无备份/全站重建/trap)、M2(compose 命令不一致)、M12(无可观测性)、M13(.env eval 与占位口令)、L7(属主)。补充:
- ✅ 优点:
set -euo pipefail(第 2 行);彩色分级日志(12-22);check_deps建目录 + 检查命令(40-55);check_code校验关键文件(57-67);cleanup注释里明确写了"-a会删掉服务器上其他项目的未使用镜像,慎用"(117)——这条自我约束是对的。 - ⚠️
--build-frontend/--skip-frontend等 README 承诺的参数在case里没有分支(143-161),会落到*) full_deploy "$@"(160),被静默忽略并执行完整部署。 - ⚠️ 幂等性:可重复执行,但每次都会重建全部容器(78 行),且不校验当前是否已经有更新版本在跑。
- ⚠️ 无失败回滚:
build_image(69-74)失败会exit 1(好);但start_service(76-88)失败后的处理路径缺失健康门禁(坏)。
3.5 deploy-artifacts.sh(未纳入版本管理,但是更成熟的路径)
- ✅ 设计明显优于
deploy.sh:本地构建成品 → 服务器不跑构建;push_dir先解包到/tmp/fa-stage校验index.html与非空 → 备份旧目录 → 原子替换 →chown(push_dir函数体);后端镜像docker save | gzip | ssh docker load;切换前docker tag backend:3.0.0 backend:prev保留回滚点;rollback web|app|backend与prune轮转(备份每标签 5 份、镜像每类 2 份);verify探测公网四端 HTTP 200;构建时剔除OPENAI_*并把数据库/Redis 地址强制回localhost(避免把开发凭据烤进镜像——与 B3 正好相反,做得对)。 - ❌ 问题:
- 未提交(
git ls-files deploy-artifacts.sh为空,git status显示??)→ 真正的生产部署脚本会随工作区清理而丢失,团队其他人无法复现部署。 - 硬编码生产 IP 与 root 登录:
SERVER="${FA_SERVER:-8.137.99.5}"、ssh_do ... "root@${SERVER}"、固定KEY="$HOME/.ssh/id_ed25519"(脚本 46-53 行附近)→ 应改为必填环境变量 + 专用部署用户(sudo只放开 docker)。 - 只有一个
backend:prev回滚点,连续两次部署后回滚能力退化。 - 无数据库备份/回滚(只有静态目录与镜像),schema 变更仍不可逆。
- 备份是
cp -a全量复制,无校验和校验(tar | ssh中断可能得到不完整内容,虽set -e能兜住大部分)。
- 未提交(
3.6 CI
\.github/workflows/ 为空 → 无 CI(H5)。这直接影响本轮审计的可信度:没有任何自动步骤会在合并前发现 B2 这种"镜像构建必然失败"的问题。
3.7 生产环境变量清单(compose 未注入 → 落到默认值)
| 变量 | compose 是否注入 | 默认值 / 实际来源 | 风险 |
|---|---|---|---|
SECRET_KEY |
❌ | setting.py:66 公开默认值 |
blocker(B1) |
DATA_ENCRYPTION_KEY |
❌ | None → 由 SECRET_KEY HKDF 派生(crypto_util.py:49-52) |
blocker(B1) |
OPENAI_API_KEY |
❌ | 镜像内 backend/env/.env.prod(真实 key) |
blocker(B3) |
PROD_CORS_ORIGINS |
❌ | 空 → ALLOW_ORIGINS 返回 ["*"](setting.py:243-248),且 ALLOW_CREDENTIALS=True(第 60 行) |
high(同源部署下影响有限,但属于错误的生产配置) |
SCHEDULER_ALLOW_CODE_EXEC |
❌ | True(setting.py:127),注释写着"生产环境强烈建议设为 False" |
high(定时任务可执行用户提交的代码块) |
ALLOWED_HOSTS |
❌ | 硬编码 service.fastapiadmin.com 等(setting.py:186),换域名即全站 400 |
medium(M14) |
TRUSTED_PROXY_HOPS |
❌ | 1(setting.py:34) |
medium(H2) |
DEMO_ENABLE |
❌ | .env.prod 设 True |
medium |
WORKERS |
❌ | 1(setting.py:33) |
medium(单 worker,注意注释提示 >1 时需确认 Redis jobstore 去重) |
DATABASE_* / REDIS_* |
✅ | compose environment(104-114)优先于 env_file |
✅ 正确 |
建议:新增 docker/.env.example 中的 SECRET_KEY、DATA_ENCRYPTION_KEY、PROD_CORS_ORIGINS、SCHEDULER_ALLOW_CODE_EXEC、TRUSTED_PROXY_HOPS 键位,并在 compose 中用 ${VAR:?} 强制必填,让"忘记配置"变成启动即失败而不是静默降级。
3.8 文档一致性(docker/README.md)
| README 声明 | 实际 | 影响 |
|---|---|---|
:93-103 支持 --build-frontend/--skip-frontend、BUILD_WEB=true |
deploy.sh:143-161 无对应分支,未知参数被 *) 吞掉执行完整部署 |
⚠️ 误导,运维会以为跳过了前端构建 |
:63 NGINX_SERVER_NAME 变量(默认 service.fastapiadmin.com) |
nginx.conf 与 compose 中都不存在该变量(grep 无命中),域名在 nginx.conf:75,83 硬编码 |
⚠️ 换域名时按文档改 .env 无效 |
:87 "停止旧容器 → 更新代码 → 构建镜像" |
deploy.sh:5-6 明确不再拉取代码 |
⚠️ |
:219-221 "./deploy.sh 自动拉取最新代码" |
同上是错的 | ⚠️ 会造成"部署了但代码没更新"的误判 |
:132-135 Swagger 在 https://域名/docs、健康检查在 /health |
/ 被 nginx.conf:96-100 指向 VitePress 官网;Swagger 实际在 /api/v1/docs;健康检查实际是 /api/v1/monitor/health/check |
⚠️ 验收地址全错 |
:138 "docs 和 app 默认不参与部署,需取消注释启用" |
docker-compose.yaml:176-177 与 nginx.conf:110 都是启用状态 |
⚠️ 与实际镜像挂载不一致 |
:136 默认账号 admin/123456 |
需与种子数据核对(backend/sql/sys_user.json),属后端范围 |
ℹ️ 生产必须改 |
:211-215 安全建议(改默认密码、限制端口) |
仅文档,compose 默认值未落实(H1) | ⚠️ 建议改成默认安全 |
3.9 值得肯定的部分(避免只报问题)
docker-compose config通过,必填变量用${VAR:?错误: ...}表达,缺变量即报错并给中文提示(18,21,66 行)。- 四个服务都有 healthcheck,backend→mysql/redis 使用
service_healthy,比多数项目的service_started更严谨(131-135)。 - 日志滚动、内存/CPU 限制、时区、上传目录持久化(130 行注释点明了"不挂载会丢用户上传文件")都想到了。
.gitignore正确排除了docker/.env、docker/nginx/ssl/*、三个前端dist,实测git ls-files docker/无任何密钥入库。- nginx
proxy_pass与后端root_path的配合是对的(3.3 已证明)。 deploy-artifacts.sh的备份→原子替换→回滚→轮转链路设计完整,且主动剥离OPENAI_*,说明团队已识别部分风险——只是没回流到docker/backend/Dockerfile。- 证书与私钥配套、未过期(实测),
ssl/以:ro挂载(175 行)。
4. 从零到上线部署步骤草案(含证书与备份)
前提:先修完第 2 节的 B1/B2/B3 + H1/H8。以下命令在服务器上以部署用户执行,
$DOMAIN= 实际域名。
阶段 0:服务器与网络前置
- 服务器:≥4 vCPU / 8 GB 内存 / 100 GB 磁盘(compose 限制合计约 2.8 GB,留出 MySQL 数据增长与备份空间)。
- 安装 Docker Engine ≥ 24 与 Compose v2 插件(
docker compose version必须成功;不要只装独立版docker-compose)。 - 云安全组 / 主机防火墙:入方向只放行 80、443 与 SSH;3306、6379、8001 一律拒绝。
- 域名 A 记录指向服务器公网 IP,确认备案/HTTPS 可用。
阶段 1:代码与权限
sudo mkdir -p /home/FastapiAdmin && sudo chown "$USER" /home/FastapiAdmin
# 上传代码(scp / git)
cd /home/FastapiAdmin
chmod 700 docker/nginx/ssl
find docker/nginx/ssl -name '*.key' -exec chmod 600 {} +
阶段 2:生成密钥与 .env
cp docker/.env.example docker/.env
SECRET_KEY=$(openssl rand -hex 32)
DATA_ENCRYPTION_KEY=$(openssl rand -hex 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' )
MYSQL_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' )
REDIS_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' )
# 写入 docker/.env(含 SECRET_KEY / DATA_ENCRYPTION_KEY / PROD_CORS_ORIGINS / SCHEDULER_ALLOW_CODE_EXEC=false)
# 用编辑器填写,避免命令历史留痕:
${EDITOR:-vi} docker/.env
chmod 600 docker/.env
必须显式确认的键(对照 3.7):SECRET_KEY、DATA_ENCRYPTION_KEY、MYSQL_ROOT_PASSWORD、MYSQL_PASSWORD、REDIS_PASSWORD、DEPLOY_ENV=prod、SCHEDULER_ALLOW_CODE_EXEC=false、PROD_CORS_ORIGINS=https://$DOMAIN。
阶段 3:配置校验(不启动容器)
cd docker
docker compose config -q && echo "compose OK"
# 显式确认没有以占位口令通过
grep -qE 'your_(mysql|redis)' .env && { echo "❌ 仍是占位口令"; exit 1; } || echo ".env OK"
docker compose config | grep -E 'host_ip|published' # 人工确认没有 0.0.0.0 的 3306/6379/8001
阶段 4:构建(带版本 tag 与回滚点)
cd docker
export DOCKER_BUILDKIT=1
TAG="$(date +%Y%m%d-%H%M%S)"
docker compose build backend # 修完 B2 后必须成功
docker tag backend:3.0.0 "backend:${TAG}"
docker tag backend:"${TAG}" backend:prev # 建立回滚点
docker images 'backend:*'
阶段 5:证书
# 方案 A:CA 签发
cp /path/to/fullchain.pem docker/nginx/ssl/server.pem
cp /path/to/privkey.pem docker/nginx/ssl/server.key
chmod 600 docker/nginx/ssl/server.{pem,key}
# 方案 B:acme.sh 自动签发 + 自动续期(推荐)
curl https://get.acme.sh | sh -s email=ops@example.com
~/.acme.sh/acme.sh --issue -d "$DOMAIN" --standalone # 需 80 端口空闲
~/.acme.sh/acme.sh --install-cert -d "$DOMAIN" \
--key-file /home/FastapiAdmin/docker/nginx/ssl/server.key \
--fullchain-file /home/FastapiAdmin/docker/nginx/ssl/server.pem \
--reloadcmd "cd /home/FastapiAdmin/docker && docker compose exec nginx nginx -s reload"
# 到期巡检(cron 每天 09:00)
# 0 9 * * * openssl x509 -in /home/FastapiAdmin/docker/nginx/ssl/server.pem -noout -checkend 1209600 \
# || curl -fsS "https://<告警 webhook>" -d "证书 14 天内到期"
(同时按 H4 在 nginx.conf 的 80 端口 server 里加 location /.well-known/acme-challenge/ 例外。)
阶段 6:分层启动(不要一把梭)
cd /home/FastapiAdmin/docker
docker compose up -d mysql redis
until [ "$(docker inspect -f '{{.State.Health.Status}}' mysql)" = healthy ] \
&& [ "$(docker inspect -f '{{.State.Health.Status}}' redis)" = healthy ]; do sleep 3; done
docker compose up -d backend
until [ "$(docker inspect -f '{{.State.Health.Status}}' backend)" = healthy ]; do sleep 5; done
docker compose up -d nginx
docker compose ps
首次启动时后端会自动完成建表与种子数据(backend/app/__init__.py:30 → backend/app/scripts/initialize.py:50-86:空库 create_all + stamp head,非空库 alembic upgrade head)。
docker/mysql/init 目录为空(L1),不要期待 MySQL 容器初始化任何 SQL;若需手工导入 backend/sql/*.json 之外的 SQL,放在这里或走应用初始化。
阶段 7:备份(上线前先建好,别等出事)
mkdir -p /home/FastapiAdmin/backups
cat > /home/FastapiAdmin/backup.sh <<'EOF'
#!/bin/bash
set -euo pipefail
cd /home/FastapiAdmin/docker
set -a; source .env; set +a
STAMP=$(date +%Y%m%d-%H%M%S)
OUT=/home/FastapiAdmin/backups
docker compose exec -T mysql sh -c \
'exec mysqldump --single-transaction --routines --triggers --default-character-set=utf8mb4 -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' \
| gzip -1 > "$OUT/db-$STAMP.sql.gz"
tar -czf "$OUT/nginx-$STAMP.tgz" nginx/web/dist nginx/app nginx/docs nginx/nginx.conf
cp .env "$OUT/env-$STAMP"
cp -a redis/data "$OUT/redis-$STAMP" 2>/dev/null || true
# 保留最近 14 天
find "$OUT" -maxdepth 1 -name 'db-*' -mtime +14 -delete
find "$OUT" -maxdepth 1 -name 'nginx-*' -mtime +14 -delete
find "$OUT" -maxdepth 1 -name 'redis-*' -mtime +14 -delete
echo "backup done: $STAMP"
EOF
chmod +x /home/FastapiAdmin/backup.sh
/home/FastapiAdmin/backup.sh # 立即跑一次验证可执行
# cron:每天 02:30
# 30 2 * * * /home/FastapiAdmin/backup.sh >> /home/FastapiAdmin/backups/cron.log 2>&1
恢复演练(每季度做一次,否则备份等于没有):
# 1) 停后端(避免写入竞争),保留 MySQL
docker compose stop backend
# 2) 恢复
gunzip -c /home/FastapiAdmin/backups/db-<STAMP>.sql.gz | \
docker compose exec -T mysql sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"'
# 3) 起后端并验证
docker compose start backend
docker compose exec -T backend python -c "import urllib.request;urllib.request.urlopen('http://localhost:8001/api/v1/monitor/health/check',timeout=5);print('health OK')"
阶段 8:上线验收(每条都要有明确通过标准)
D=https://$DOMAIN
curl -sS -o /dev/null -w 'root %{http_code}\n' "$D/"
curl -sS -o /dev/null -w 'web %{http_code}\n' "$D/web/"
curl -sS -o /dev/null -w 'app %{http_code}\n' "$D/app/"
curl -sS -o /dev/null -w 'apihealth %{http_code}\n' "$D/api/v1/monitor/health/check"
curl -sS -o /dev/null -w 'swagger %{http_code}\n' "$D/api/v1/docs" # 期望 404/403,若 200 则按 3.3 在 nginx 封掉
curl -sSI "$D/" | grep -iE 'strict-transport-security|content-security-policy' # 期望有命中(修完 M7 后)
echo | openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" 2>/dev/null | openssl x509 -noout -dates
docker compose ps # 期望四服务 Up (healthy)
# 端口暴露复核(应从外部超时/拒绝)
nc -z -w3 <公网IP> 3306; nc -z -w3 <公网IP> 6379; nc -z -w3 <公网IP> 8001
阶段 9:日常发布与回滚
# 发布(推荐用 deploy-artifacts.sh,先把它提交进仓库并按 M15 去掉硬编码 IP)
./deploy-artifacts.sh web
./deploy-artifacts.sh app
./deploy-artifacts.sh backend # 内部已含备份点/镜像 tag/健康轮询
# 回滚
./deploy-artifacts.sh backups
./deploy-artifacts.sh rollback web
./deploy-artifacts.sh rollback app
./deploy-artifacts.sh rollback backend
⚠️ 回滚边界:镜像回滚不会反向执行数据库迁移。若某次发布包含 schema 变更(alembic upgrade),回滚必须同时恢复阶段 7 的数据库备份,并把后端一并停掉避免双写;上线含 schema 变更的版本前务必手工跑一次 backup.sh。
阶段 10:可观测性最小集
- 容器健康:
docker compose ps+docker events接告警(restart事件)。 - 磁盘:
df -h对/与数据卷目录做阈值告警(MySQL 数据 + 日志 + 备份三处)。 - 证书:阶段 5 的
-checkend巡检。 - 应用:nginx access/error 日志(
docker compose logs nginx)5xx 比例;后端日志在backend/logs(已挂载?当前 compose 未挂载backend/logs,容器内日志文件随容器销毁而丢失,只有 stdout 被json-file驱动轮转保留 —— 如需长期留存建议加挂载或接日志采集)。
5. 建议的处理顺序(给排期用)
- 当天(不改业务代码即可完成):B1 注入并轮换密钥 → B3 排除/轮换泄露凭据 → H3 收紧权限 → H1 收敛端口。
- 本周:B2 修 Dockerfile 层顺序(否则任何人都无法构建镜像)→ H8 给
deploy.sh加备份/健康门禁/回滚 → H5 建最小 CI(compose config + docker build + pytest)。 - 两周内:H4 证书自动化与到期告警 → H6 精简构建上下文 → H7 非 root + 容器安全基线 → M1/M3/M4/M11。
- 可排期:M7/M8/M9/M10 的 nginx 加固与性能项 → M2/M12/M13/M15 部署脚本工程化 → L1/L2 文档与注释纠偏。
6. 与其它审计角色的边界
本报告只覆盖容器与部署链路。以下发现虽在部署过程中被确认,但根因属后端代码/配置域,建议由对应负责人并入主清单:
setting.py:127SCHEDULER_ALLOW_CODE_EXEC=True(远程代码执行面)与setting.py:243-248生产 CORS 回落["*"];setting.py:66注释与实现不符(注释称必填、实现有默认值),以及backend/tests/conftest.py:32用环境变量兜底证明"必填"是设计意图;app/__init__.py:118-119无条件注册/docs、/redoc(生产是否应暴露需产品决策);backend/app/__init__.py:127-129app.frontend("/")的 catch-all 与 JSON 404 语义冲突。
7. 复现本审计的方法
cd /Users/tao/workspace/FastapiAdmin
# 组件版本
docker version # Client 29.8.0 (context: colima) / Server 29.5.2
docker compose version # ❗ 本机报 "unknown command: docker compose"
docker-compose --version # Docker Compose version 5.5.1(homebrew 独立版)
docker buildx version # ❗ 本机报 "unknown command: docker buildx"
# compose 静态校验(未启动容器)
cd docker && docker-compose config ; echo "exit=$?" # exit=0
docker-compose config | grep -c host_ip # 0 —— 无任何端口限定到 127.0.0.1
# Dockerfile 依赖层顺序(静态判定,未构建)
sed -n '13,21p' ../docker/backend/Dockerfile
# 证书有效期 / 配套性
openssl x509 -in nginx/ssl/server.pem -noout -subject -issuer -dates
openssl x509 -in nginx/ssl/server.pem -noout -pubkey | openssl md5
openssl rsa -in nginx/ssl/server.key -pubout 2>/dev/null | openssl md5
# 权限
ls -l nginx/ssl/server.key .env
# 入库情况(确认密钥未提交)
git ls-files docker/ | grep -v -E '^docker/nginx/(web|docs|app)/'
git ls-files backend/requirements/ # 空 → 依赖拆分未提交(B2 的成因之一)
# CI
ls -laR ../.github/
# 构建上下文体积(按 .dockerignore 规则静态统计)
python3 - <<'PY'
import os
root='/Users/tao/workspace/FastapiAdmin'
ig={'.git','frontend','.venv','node_modules','logs','.ruff_cache','.mypy_cache','.pytest_cache','__pycache__','.vscode','.idea','.codebuddy','.workbuddy','.agent-teams'}
t=0
for dp,dns,fns in os.walk(root):
dns[:]=[d for d in dns if d not in ig]
if 'static' in dp.split(os.sep) and 'upload' in dp.split(os.sep): dns[:]=[]; continue
for f in fns:
if f.endswith(('.pyc','.pyo','.pyd','.log','.db','.sqlite3')) or f in ('.DS_Store','.gitignore','.gitattributes'): continue
try: t+=os.path.getsize(os.path.join(dp,f))
except OSError: pass
print(f"approx context = {t/1024/1024:.1f} MiB")
PY
# → approx context = 68.4 MiB
未执行(受本轮只读约束):docker build、docker compose up/down、nginx -t、任何容器启动、依赖安装、git 提交。因此 nginx.conf 的语法正确性只做了静态审查(docker-compose config 校验的是 compose 而非 nginx.conf)。
证据时效说明:审计期间工作区被多个并行审计成员同时读写,backend/uv.lock、docker/.env.example、backend/requirements.txt 在本报告定稿前发生过变化(非本任务所为)。所有关键结论(B1/B2/B3 的引用内容、compose/Dockerfile/nginx.conf 的原始行号、证书与权限实测值)已在定稿前逐条复核,仍然成立;若后续再修改这些文件,请以“文件内容”而非行号为准。
8. 实测输出存档
| 命令 | 关键输出 |
|---|---|
docker version |
Client Docker Engine - Community 29.8.0(darwin/arm64,Context: colima);Server 29.5.2(linux/arm64,overlayfs,Cgroup v2) |
docker compose version |
docker: unknown command: docker compose(~/.docker/cli-plugins/docker-compose 软链指向 /Applications/Docker.app/...,目标不存在) |
docker-compose version |
Docker Compose version 5.5.1(/opt/homebrew/bin/docker-compose) |
docker buildx version |
docker: unknown command: docker buildx |
docker info |
Containers 0 / Images 23 / Logging Driver json-file / Cgroup Version 2 |
cd docker && docker-compose config |
退出码 0;输出含 name: docker、backend platform: linux/amd64、image: backend:3.0.0、FORWARDED_ALLOW_IPS: '*'、mysql healthcheck 中口令被展开为明文;grep -c host_ip = 0 |
openssl x509 -in docker/nginx/ssl/server.pem -noout -subject -issuer -dates |
subject= /CN=service.fastapiadmin.com;issuer= /C=PL/O=Asseco Data Systems S.A./CN=Certum DV TLS G2 R39 CA;notBefore=Aug 19 14:10:22 2026 GMT;notAfter=Mar 6 14:10:21 2027 GMT;SAN DNS:service.fastapiadmin.com;-checkend 0 → not expired |
| 证书/私钥配套性 | server.pem、service.fastapiadmin.com/nginx/*.crt、.../pem/*.pem md5 均为 f308d6601a5521014d9a4b205c89efeb;server.pem 公钥 md5 与 server.key 公钥 md5 一致(4d0165b5c7a158d519cd29fd6a6f699a) |
ls -l |
docker/.env 0644;docker/nginx/ssl/server.key 0666;.../nginx/*.key 0666;iis/password.txt、tomcat/password.txt 各 6 字节 |
git ls-files docker/(滤除前端产物) |
仅 .env.example、README.md、Dockerfile、docker-compose.yaml、mysql/.gitkeep、mysql/data/.gitkeep、mysql/init/.gitkeep、nginx.conf、nginx/ssl/.gitkeep、redis/.gitkeep、redis/conf/redis.conf、redis/data/.gitkeep —— 无 .env、无证书 |
git ls-files backend/requirements/ |
空(git status:?? backend/requirements/) |
ls -laR .github/ |
workflows/ 空目录,total 0 |
| 上下文体积静态统计 | 68.4 MiB;Top 项:backend/data/ip2region/ip2region_v4.xdb 10.61 MiB、docker/nginx/web/dist/js/iconify-icons.*.js 2.55 MiB、backend/dist/js/iconify-icons.*.js 2.55 MiB、backend/static/swagger/redoc/bundles/redoc.standalone.js.map 2.55 MiB;docker/nginx/ssl/** 命中 21 个文件 |
| 目录体积 | 仓库总计 2.0 G(.git 151 M、backend/.venv 231 M、frontend 1.5 G、docker/nginx 28 M、backend/dist 22 M、backend/data 11 M、backend/logs 6.5 M) |
| 源码判定 | backend/app/config/setting.py:311 root_path=/api/v1 → backend/.venv/.../fastapi/applications.py:1160-1163 设置 scope["root_path"] → starlette/_utils.py:90-98 + starlette/routing.py:255 在匹配前剥离 → nginx 的 proxy_pass http://backend:8001;(不重写 URI)正确 |
报告结束。本轮为只读审计,除本文件外未修改、未删除任何仓库文件,未构建、未启动任何容器,未提交 git。