24 KiB
生产数据库安全增量迁移 SOP(2026-08-12)
结论
本次应用发布依赖七份增量 migration。普通蓝绿部署脚本只读验证,不会自动修改数据库。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 prisma migrate deploy。
目标 migration:
20260811_add_video_transcode_lease20260811_update_brand_logo_defaults20260812_add_product_package_upload_job_lease20260812_align_material_upload_idempotency_index20260813_add_material_custody_department20260813_add_organization_units_and_employment_state20260813_add_user_employee_binding
它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 system_settings 品牌两列中的退役 s3.1080p.hk 值改为本地品牌图,并把 materials_uploadId_key 重建为“uploadId 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段和账号—人员绑定表,并在同一个事务中按钉钉唯一值做确定性回填。索引迁移会先检测未删除素材中的重复 uploadId;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。
强制前提
- 使用将要发布的精确 40 位 Git SHA;在新建的隔离检出目录构建,不清理或复用现有工作树。
- 已用
pg_dump --format=custom创建生产数据库备份,并在隔离数据库做过真实恢复验证;备份产物与 receipt 一起只读挂载。 - 明确生产数据库的配置 host/port/database/user,以及数据库实际返回的
inet_server_addr()/inet_server_port();不允许从旧文档猜测。 - 本轮首次旧镜像发布必须先把 runner 与 migrator 固定到同一个精确 SHA/digest;先按
scripts/legacy-worker-bootstrap-deploy.ps1完成 Audit,再 Apply 进入外部实测 503 维护态,并证明旧容器已停止但仍保留可回退。只有这些证据全部成立,才允许设置CONFIRM_MAINTENANCE_WINDOW=NO_WRITES_DURING_SNAPSHOT。 - 不使用历史
execute-migration.ps1/.sh;它们不登记本轮 Prisma migration。
不能以“当前无人使用”“已经通知大家”或只改一个维护开关代替 503 和旧容器停止证据。正确顺序固定为:
- 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
- legacy bootstrap
Audit; - legacy bootstrap
Apply,外部验收 503,确认旧容器停止且保留; - 在同一维护窗内运行本文的真实备份、隔离恢复、七条 migration 和迁移后审计;
- 仅在所有门禁通过后,运行普通同 SHA 应用部署。
顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。
1. 构建并传输精确 runner 与 migrator
runner 和 migrator 必须来自同一个 TARGET_SHA,但它们是两个不同的镜像,必须分别记录 image ID/digest。普通发布脚本的 -SkipDeploy 只构建并推送 runner,不会构建 migrator。
1.1 先准备 runner
在正式维护窗之前,用普通发布脚本的生产参数和 -SkipDeploy 构建、推送 runner。脚本会固定 Git SHA、基础镜像 digest、最终 image ID 和 registry manifest digest,但不会连接生产机或改流量。记录它输出的 runner repo@sha256:... 和 image ID;生产机只能按该 digest 预拉取并复核,不能按可变 tag 猜测。
1.2 独立构建 migrator
在构建机使用一次性的隔离 clone。下面不会执行 reset --hard 或 clean,因此不会破坏构建机已有工作区:
set -Eeuo pipefail
TARGET_SHA=<40位目标SHA>
SOURCE_REPO=<仓库地址>
BUILD_ROOT="$(mktemp -d /root/gongxiangp2-migrator-build.XXXXXX)"
trap 'rm -rf -- "$BUILD_ROOT"' EXIT
git clone --no-checkout --branch main --single-branch "$SOURCE_REPO" "$BUILD_ROOT/repo"
git -C "$BUILD_ROOT/repo" checkout --detach "$TARGET_SHA"
test "$(git -C "$BUILD_ROOT/repo" rev-parse HEAD)" = "$TARGET_SHA"
test -z "$(git -C "$BUILD_ROOT/repo" status --porcelain --untracked-files=all)"
cd "$BUILD_ROOT/repo"
DOCKER_BUILDKIT=1 docker build \
--target migrator \
--build-arg VCS_REF="$TARGET_SHA" \
-t "youyidian-3d-migrator:$TARGET_SHA" .
docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \
"youyidian-3d-migrator:$TARGET_SHA"
MIGRATOR_BAKED_SHA="$(docker run --rm --network none --read-only \
--entrypoint cat "youyidian-3d-migrator:$TARGET_SHA" /app/DEPLOYMENT_COMMIT)"
test "$MIGRATOR_BAKED_SHA" = "$TARGET_SHA"
revision label 和 baked SHA 必须精确等于目标 SHA。构建机和生产机可能运行不同版本的 Docker,docker image inspect .Id 对带 provenance 的镜像可能分别显示 manifest-list digest 与 config digest,因此不能直接把构建机 .Id 当成跨主机身份凭证。下一步必须从 docker save 的可信归档中提取该镜像唯一的 config digest,再与生产装载后的 .Id 比较。
1.3 通过已固定主机指纹的 SSH 传输 migrator
不能经未加密 HTTP registry 或临时关闭 SSH 主机校验。下面使用构建机上已经由发布脚本预置并核验过的生产 known_hosts;实际用户名、主机名和文件路径按现场受控配置填写:
set -Eeuo pipefail
MIGRATOR_IMAGE="youyidian-3d-migrator:$TARGET_SHA"
MIGRATOR_ARCHIVE="$(mktemp /root/gongxiangp2-migrator-${TARGET_SHA}.XXXXXX.tar)"
trap 'rm -f -- "$MIGRATOR_ARCHIVE"' EXIT
docker save --output "$MIGRATOR_ARCHIVE" "$MIGRATOR_IMAGE"
MIGRATOR_ARCHIVE_SHA256="$(sha256sum "$MIGRATOR_ARCHIVE" | awk '{print $1}')"
test -n "$MIGRATOR_ARCHIVE_SHA256"
MIGRATOR_IMAGE_ID="$(tar -xOf "$MIGRATOR_ARCHIVE" manifest.json | node -e '
const fs = require("node:fs")
const image = process.argv[1]
const entries = JSON.parse(fs.readFileSync(0, "utf8"))
const matches = entries.filter((entry) => Array.isArray(entry.RepoTags) && entry.RepoTags.includes(image))
if (matches.length !== 1 || typeof matches[0].Config !== "string") process.exit(2)
const configMatch = /^blobs\/sha256\/([0-9a-f]{64})$/.exec(matches[0].Config)
if (!configMatch) process.exit(3)
process.stdout.write(`sha256:${configMatch[1]}`)
' "$MIGRATOR_IMAGE")"
test -n "$MIGRATOR_IMAGE_ID"
SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=/root/.ssh/gongxiangp2-production-known_hosts)
PRODUCTION_SSH=<受控用户@生产主机>
REMOTE_ARCHIVE="/root/gongxiangp2-migrator-${TARGET_SHA}.tar"
scp "${SSH_OPTS[@]}" -- "$MIGRATOR_ARCHIVE" "$PRODUCTION_SSH:$REMOTE_ARCHIVE"
ssh "${SSH_OPTS[@]}" "$PRODUCTION_SSH" bash -s -- \
"$REMOTE_ARCHIVE" "$MIGRATOR_ARCHIVE_SHA256" "$MIGRATOR_IMAGE" \
"$MIGRATOR_IMAGE_ID" "$TARGET_SHA" <<'PRODUCTION_VERIFY'
set -Eeuo pipefail
archive="$1"; expected_archive_sha="$2"; image="$3"; expected_image_id="$4"; expected_sha="$5"
trap 'rm -f -- "$archive"' EXIT
[ -f "$archive" ] && [ ! -L "$archive" ]
[ "$(stat -c '%a' "$archive")" = "600" ]
[ "$(sha256sum "$archive" | awk '{print $1}')" = "$expected_archive_sha" ]
docker load --input "$archive" >/tmp/gongxiangp2-migrator-load.log
[ "$(docker image inspect --format '{{.Id}}' "$image")" = "$expected_image_id" ]
[ "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image")" = "$expected_sha" ]
[ "$(docker run --rm --network none --read-only --entrypoint cat "$image" /app/DEPLOYMENT_COMMIT)" = "$expected_sha" ]
PRODUCTION_VERIFY
scp 目标目录必须预先由 root 创建且不可由其他账号写入;传输完成后生产端会重新核对 archive SHA、migrator image ID、OCI revision 和 baked commit。任一不一致都在进入维护窗前停止。
2. 用工具生成可核验的备份凭证
禁止手写或补填 receipt。scripts/create-verified-production-backup.mjs 会在同一次受控运行中自动完成:
- 只读核对生产库与隔离库的配置身份和数据库实际身份;
- 在生产库读取迁移表、素材、用户、系统设置、产品包作业的有界摘要;
- 用
pg_dump --format=custom写入本次唯一的.partial文件,刷新到磁盘后原子改名; - 再读一次生产摘要。维护窗内前后摘要不同就停止,不生成 receipt;
- 在第二道明确授权下,只清空符合命名规则的隔离库
publicschema,再用pg_restore恢复同一备份; - 在 30 秒 statement timeout 和每表最多 32 条样本的边界内,对比关键表计数与样本摘要;
- 全部一致后,原子生成 receipt,并输出 receipt 自身 SHA-256。
2.1 准备真正隔离的恢复目标
隔离目标必须同时满足:
- 数据库名匹配
gongxiangp2_restore_<唯一小写后缀>; - 数据库名与生产库不同;
- 配置 host/port 与生产不同;
inet_server_addr()/inet_server_port()返回的实际 PostgreSQL 服务器也与生产不同;- 只供本次恢复验证,账号允许在这个隔离库内重建
publicschema,不拥有生产库写权限。
因此,同一台生产 PostgreSQL 上另建一个数据库也不合格。先进入维护窗口,停止会修改关键业务表的入口;工具的前后摘要只是近似验证,不能代替维护窗口。
2.2 准备两份 root-only 环境文件
实际密码、证书路径和连接值不写在命令、Git、聊天或运行日志里。由密码管理工具在生产机生成两份 0600 环境文件;下面只列变量名,不展示值:
- 生产连接:
PRODUCTION_PGHOST、PRODUCTION_PGPORT、PRODUCTION_PGDATABASE、PRODUCTION_PGUSER,以及PRODUCTION_PGPASSWORD或PRODUCTION_PGPASSFILE; - 生产期望身份:
EXPECTED_DATABASE_HOST、EXPECTED_DATABASE_PORT、EXPECTED_DATABASE_NAME、EXPECTED_DATABASE_USER、EXPECTED_DATABASE_SERVER_ADDRESS、EXPECTED_DATABASE_SERVER_PORT; - 隔离连接:
ISOLATED_PGHOST、ISOLATED_PGPORT、ISOLATED_PGDATABASE、ISOLATED_PGUSER,以及ISOLATED_PGPASSWORD或ISOLATED_PGPASSFILE; - 隔离期望身份:
EXPECTED_ISOLATED_DATABASE_HOST、EXPECTED_ISOLATED_DATABASE_PORT、EXPECTED_ISOLATED_DATABASE_NAME、EXPECTED_ISOLATED_DATABASE_USER、EXPECTED_ISOLATED_DATABASE_SERVER_ADDRESS、EXPECTED_ISOLATED_DATABASE_SERVER_PORT。
若现场要求 TLS,可在各自前缀下增加 PGSSLMODE、PGSSLROOTCERT、PGSSLCERT、PGSSLKEY。PGPASSFILE 和证书若是宿主机路径,还要只读挂载到容器内,并在环境文件中填写容器内路径。
期望的 server address/port 必须来自当次现场只读查询。使用已安全注入的标准 PG* 环境变量执行,不在命令行传密码或连接 URL:
psql --no-psqlrc --no-align --tuples-only --set=ON_ERROR_STOP=1 <<'SQL'
SELECT current_database(), current_user,
COALESCE(inet_server_addr()::text, ''),
COALESCE(inet_server_port()::text, '');
SQL
2.3 先运行备份工具的只读审计
下面的网络必须能访问生产 PostgreSQL 和真正隔离的 PostgreSQL,但隔离账号不得拥有生产写权限:
set -Eeuo pipefail
TARGET_SHA=<40位目标SHA>
MIGRATOR_IMAGE="youyidian-3d-migrator:$TARGET_SHA"
PRODUCTION_BACKUP_ENV=/root/secure/gongxiangp2-production-backup.env
ISOLATED_RESTORE_ENV=/root/secure/gongxiangp2-isolated-restore.env
test "$(stat -c '%a' "$PRODUCTION_BACKUP_ENV")" = 600
test "$(stat -c '%a' "$ISOLATED_RESTORE_ENV")" = 600
docker run --rm \
--network <可同时访问两套数据库的受控网络> \
--env-file "$PRODUCTION_BACKUP_ENV" \
--env-file "$ISOLATED_RESTORE_ENV" \
-e EXPECTED_COMMIT_SHA="$TARGET_SHA" \
--entrypoint node \
"$MIGRATOR_IMAGE" \
scripts/create-verified-production-backup.mjs --audit
审计模式只执行客户端版本探测和两套身份查询,不创建 dump、不清空隔离库、不恢复、不生成 receipt。输出只包含去密后的身份;任何字段不一致都先修正环境文件,不能绕过。
2.4 显式创建并验证备份
为本次发布创建一个全新的宿主机目录。工具会拒绝符号链接、已有同名文件和非目录;不会复用历史 receipt:
set -Eeuo pipefail
BACKUP_BATCH="$(date -u +%Y%m%dT%H%M%SZ)-${TARGET_SHA}"
HOST_BACKUP_DIR="/root/gongxiangp2-db-backup-receipt/$BACKUP_BATCH"
HOST_RESULT_DIR="/root/gongxiangp2-migration-audit/$BACKUP_BATCH"
HOST_RESULT_FILE="$HOST_RESULT_DIR/verified-backup-result.txt"
MIGRATOR_UID="$(docker run --rm --entrypoint id "$MIGRATOR_IMAGE" -u)"
install -d -m 0750 -o "$MIGRATOR_UID" -g "$MIGRATOR_UID" "$HOST_BACKUP_DIR"
install -d -m 0750 -o "$MIGRATOR_UID" -g "$MIGRATOR_UID" "$HOST_RESULT_DIR"
test ! -L "$HOST_BACKUP_DIR"
test -z "$(find "$HOST_BACKUP_DIR" -mindepth 1 -maxdepth 1 -print -quit)"
test ! -e "$HOST_RESULT_FILE"
docker run --rm \
--network <可同时访问两套数据库的受控网络> \
--env-file "$PRODUCTION_BACKUP_ENV" \
--env-file "$ISOLATED_RESTORE_ENV" \
-e EXPECTED_COMMIT_SHA="$TARGET_SHA" \
-e BACKUP_OUTPUT_DIR=/backup \
-e ALLOW_VERIFIED_BACKUP=CREATE_AND_RESTORE \
-e ALLOW_ISOLATED_DATABASE_RESET=RESET_VERIFIED_ISOLATED_TARGET \
-e CONFIRM_MAINTENANCE_WINDOW=NO_WRITES_DURING_SNAPSHOT \
-v "$HOST_BACKUP_DIR:/backup" \
--entrypoint node \
"$MIGRATOR_IMAGE" \
scripts/create-verified-production-backup.mjs --create-and-restore \
| tee "$HOST_RESULT_FILE"
只有末尾出现 Production backup was created, restored to the isolated server and verified. 才算成功。保存工具输出的 artifactPath、artifactSize、artifactSha256、receiptPath、receiptSize、receiptSha256。receipt 自动绑定:
- 精确
sourceCommit; - 生产 configured/server database fingerprint;
/backup/...dump容器路径、字节数、SHA-256 和起止时间;- 隔离库 fingerprint、恢复时间和同一 artifact SHA-256;
- 生产前后与恢复库的关键表计数、有限样本摘要及整体摘要 SHA-256。
pg_dump、pg_restore、摘要查询、身份核对或原子写入任一步失败,都不会产生 .receipt.json。可能已生成但未通过恢复验证的 .dump 只能作为失败现场证据,不能交给 migrator。运行 migrator 时把同一个 HOST_BACKUP_DIR 以 /backup:ro 挂载;工具会重新读取完整 artifact 和 receipt,核对类型、路径、字节数、SHA-256 与有效期。receipt 最多允许 BACKUP_MAX_AGE_MINUTES 指定的时长(范围 1–1440 分钟)。
在退出维护窗前,先从 helper 的最终 JSON 结果提取本批次精确路径和 SHA;不要按时间猜文件名,也不要把上级备份目录整体挂进 migrator。下面的 node 只读取结果和 receipt,不接触数据库:
readarray -t BACKUP_EVIDENCE < <(node --input-type=commonjs - "$HOST_RESULT_FILE" "$HOST_BACKUP_DIR" <<'NODE'
const fs = require('node:fs')
const path = require('node:path')
const [resultPath, hostBackupDir] = process.argv.slice(2)
const resultText = fs.readFileSync(resultPath, 'utf8')
const marker = '\nProduction backup was created, restored to the isolated server and verified.\n'
const markerIndex = resultText.lastIndexOf(marker)
if (markerIndex < 0) throw new Error('helper success marker is missing')
const resultJson = resultText.slice(0, markerIndex).trim()
if (!resultJson.startsWith('{') || !resultJson.endsWith('}')) throw new Error('helper final JSON is missing')
const result = JSON.parse(resultJson)
const entries = fs.readdirSync(hostBackupDir, { withFileTypes: true })
const receiptNames = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.receipt.json')).map((entry) => entry.name)
const dumpNames = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.dump')).map((entry) => entry.name)
if (receiptNames.length !== 1 || dumpNames.length !== 1 || entries.length !== 2) {
throw new Error('batch directory must contain exactly one regular receipt and one regular dump')
}
for (const name of [receiptNames[0], dumpNames[0]]) {
const candidate = path.join(hostBackupDir, name)
const stat = fs.lstatSync(candidate)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('backup evidence must be regular non-symlink files')
}
const receiptPath = path.join(hostBackupDir, receiptNames[0])
const dumpPath = path.join(hostBackupDir, dumpNames[0])
const receiptBytes = fs.readFileSync(receiptPath)
const receipt = JSON.parse(receiptBytes.toString('utf8'))
const crypto = require('node:crypto')
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex')
const receiptSha256 = sha256(receiptBytes)
const dumpStat = fs.lstatSync(dumpPath)
const dumpHash = crypto.createHash('sha256')
const dumpStream = fs.createReadStream(dumpPath)
dumpStream.on('data', (chunk) => dumpHash.update(chunk))
dumpStream.once('error', (error) => { throw error })
dumpStream.once('end', () => {
const dumpSha256 = dumpHash.digest('hex')
if (
result.receiptPath !== `/backup/${receiptNames[0]}`
|| result.receiptSize !== receiptBytes.length
|| result.receiptSha256 !== receiptSha256
|| result.artifactPath !== `/backup/${dumpNames[0]}`
|| result.artifactSize !== dumpStat.size
|| result.artifactSha256 !== dumpSha256
|| receipt.backup.artifactPath !== result.artifactPath
|| receipt.backup.artifactSize !== result.artifactSize
|| receipt.backup.artifactSha256 !== result.artifactSha256
) throw new Error('helper output, receipt and artifact evidence do not match')
process.stdout.write(`${receiptNames[0]}\n${receiptSha256}\n`)
})
NODE
)
test "${#BACKUP_EVIDENCE[@]}" -eq 2
BACKUP_RECEIPT_BASENAME="${BACKUP_EVIDENCE[0]}"
EXPECTED_BACKUP_RECEIPT_SHA256="${BACKUP_EVIDENCE[1]}"
test -n "$BACKUP_RECEIPT_BASENAME"
test -n "$EXPECTED_BACKUP_RECEIPT_SHA256"
当前状态(2026-08-12):代码和伪 PostgreSQL 测试已就绪,但尚未对真实生产库执行
pg_dump、隔离恢复或任何 migration。本文步骤不是已完成证明。
3. 先做只读审计
下例只展示参数结构;真实值从生产现场读取,不写进 Git:
PREFLIGHT_AUDIT_FILE="$HOST_RESULT_DIR/preflight-$(date -u +%Y%m%dT%H%M%SZ).json"
MIGRATION_AUDIT_FILE="$HOST_RESULT_DIR/applied-$(date -u +%Y%m%dT%H%M%SZ).json"
test ! -e "$PREFLIGHT_AUDIT_FILE"
test ! -e "$MIGRATION_AUDIT_FILE"
docker run --rm \
--network youyidian-3d_app-network \
--env-file /mnt/PoolOne/apps/youyidian-3d/.env \
-e EXPECTED_COMMIT_SHA=<40位目标SHA> \
-e MIGRATION_AUDIT_OUTPUT="/audit/$(basename "$PREFLIGHT_AUDIT_FILE")" \
-v "$HOST_RESULT_DIR:/audit" \
--entrypoint node \
youyidian-3d-migrator:<40位目标SHA> \
scripts/safe-production-migrate.mjs --audit
test -f "$PREFLIGHT_AUDIT_FILE" && test ! -L "$PREFLIGHT_AUDIT_FILE"
test "$(stat -c '%a' "$PREFLIGHT_AUDIT_FILE")" = "600"
审计只挂载前一步已按 migrator UID 创建的本批次 HOST_RESULT_DIR,不挂载其 root 所有的上级目录。审计输出不得包含数据库密码。确认目标库、账号、服务器地址和当前 migration 状态,若出现失败 migration 或本轮以外的未执行 migration,立即停止。
先确认 migrator 容器内的 node 用户 UID,并预创建只属于本次发布的审计目录;目录必须可写,已存在的审计文件不得复用:
MIGRATOR_UID="$(docker run --rm --entrypoint id youyidian-3d-migrator:<40位目标SHA> -u)"
install -d -m 0750 -o "$MIGRATOR_UID" -g "$MIGRATOR_UID" /root/gongxiangp2-migration-audit/<发布批次>
4. 带备份凭证执行
把备份 receipt 只读挂载到容器,显式提供生产身份:
docker run --rm \
--network youyidian-3d_app-network \
--env-file /mnt/PoolOne/apps/youyidian-3d/.env \
-e ALLOW_PRODUCTION_MIGRATIONS=APPLY \
-e EXPECTED_COMMIT_SHA=<40位目标SHA> \
-e EXPECTED_DATABASE_HOST=<DATABASE_URL中的主机> \
-e EXPECTED_DATABASE_PORT=<DATABASE_URL中的端口> \
-e EXPECTED_DATABASE_NAME=<数据库名> \
-e EXPECTED_DATABASE_USER=<数据库账号> \
-e EXPECTED_DATABASE_SERVER_ADDRESS=<inet_server_addr结果> \
-e EXPECTED_DATABASE_SERVER_PORT=<inet_server_port结果> \
-e BACKUP_RECEIPT_PATH="/backup/$BACKUP_RECEIPT_BASENAME" \
-e BACKUP_ARTIFACT_ROOT=/backup \
-e BACKUP_MAX_AGE_MINUTES=240 \
-e EXPECTED_BACKUP_RECEIPT_SHA256="$EXPECTED_BACKUP_RECEIPT_SHA256" \
-e MIGRATION_AUDIT_OUTPUT="/audit/$(basename "$MIGRATION_AUDIT_FILE")" \
-v "$HOST_BACKUP_DIR:/backup:ro" \
-v "$HOST_RESULT_DIR:/audit" \
"$MIGRATOR_IMAGE" --apply
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 prisma migrate deploy,最后核对七条 migration 的数据库 checksum、完成状态、关键人员组织表/字段类型与可空性、租约索引的精确列顺序、materials_uploadId_key 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及每条现存 Logo 地址均为本地品牌图或当前可信桶地址,并原子写审计 JSON。
apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:
test -f "$MIGRATION_AUDIT_FILE" && test ! -L "$MIGRATION_AUDIT_FILE"
chmod 0600 "$MIGRATION_AUDIT_FILE"
MIGRATION_AUDIT_SHA256="$(sha256sum "$MIGRATION_AUDIT_FILE" | awk '{print $1}')"
test -n "$MIGRATION_AUDIT_SHA256"
5. 放行条件
只有以下全部成立,才进入应用首次安全上线步骤:
applied.json的verified=true;- 七条 target migration 均
completed=true,source/database checksum 一致; _prisma_migrations.finished_at非空、rolled_back_at为空;- 视频租约列/索引、任务详情作业租约列/索引、active-only
uploadId唯一索引、品牌默认值精确匹配; - 再次运行
--audit仍通过; - 备份 receipt、迁移审计文件、目标 SHA 和 migrator image ID 被记录在当次发布记录中。
任意一项不成立:不部署应用、不手工修改 _prisma_migrations、不批量改业务状态;先保留现场并依据备份制定恢复方案。
6. 用同一 SHA 执行迁移批次的蓝绿部署
数据库迁移与日常应用发布是两条独立流程。只有本节这种“刚完成迁移、要上线依赖该 schema 的代码”的批次,才向部署脚本传入 -RequireMigrationAudit 和审计凭证。日常代码、前端与配置发布不传这些参数,不会创建 dump、恢复隔离库或要求历史审计文件。
保持 bootstrap 维护入口和全局部署锁不变,使用第 1 步固定的 runner,并把第 4 步真实生成的审计文件与 SHA-256 显式交给迁移批次部署脚本:
.\scripts\git-incremental-deploy.ps1 `
-Target production `
-Commit '<与 runner/migrator 完全相同的 40 位 SHA>' `
-MigrationAuditFile '/root/gongxiangp2-migration-audit/<发布批次>/applied-<UTC时间>.json' `
-MigrationAuditSha256 '<该文件的 64 位小写 SHA-256>' `
-RequireMigrationAudit `
-KeepOldImages
脚本会在候选创建前核对审计文件的路径、普通文件属性、0600 权限和哈希;再把其中的数据库实际身份与候选容器真实连接逐项比较。切流前还必须通过 S3 认证、数据库配置 Logo 与镜像内品牌图完整解码。对于显式带 -RequireMigrationAudit 的迁移批次,缺少审计参数、审计不属于同一提交、数据库身份变化或任一资源门禁失败,都不得切流。