# 基础镜像(使用 Alpine,手动处理 OpenSSL 兼容性) # 2026-08-12 通过 `docker buildx imagetools inspect node:18-alpine` 核验的 # Docker Hub 官方多架构清单 digest。生产默认值必须是不可变引用;本地测试 # 如需改镜像源,仍可显式传入 BASE_IMAGE,但发布脚本不得覆盖此默认值。 ARG BASE_IMAGE=node:18-alpine@sha256:8d6421d663b4c28fd3ebc498332f249011d118945588d0a35cb9bc4b8ca09d9e ARG ALPINE_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/alpine # Local/CI builds remain possible without Git metadata. Production deployment # supplies the exact SHA and rejects this sentinel before any container starts. ARG VCS_REF=0000000000000000000000000000000000000000 ARG DETAIL_RENDERER_IMAGE=node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 FROM ${BASE_IMAGE} AS base # Fabric's Node renderer has a separate dependency tree and Node runtime. # The platform's existing Canvas 2 installation remains independent. FROM ${DETAIL_RENDERER_IMAGE} AS detail-renderer-deps ARG ALPINE_MIRROR RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \ apk add --no-cache python3 make g++ cairo-dev jpeg-dev pango-dev giflib-dev WORKDIR /opt/detail-renderer COPY workers/detail-renderer/package.json workers/detail-renderer/package-lock.json ./ RUN --mount=type=cache,target=/root/.npm CXXFLAGS="-include cstdint" npm_config_build_from_source=canvas npm ci --no-audit --no-fund COPY workers/detail-renderer/ ./ RUN node -e "require('canvas').createCanvas(1,1); console.log('isolated Canvas smoke passed')" # 安装依赖阶段 FROM base AS deps ARG ALPINE_MIRROR RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \ apk add --no-cache libc6-compat python3 make g++ cairo-dev jpeg-dev pango-dev giflib-dev WORKDIR /app # package-lock.json 是生产构建的必需输入,缺失时 COPY 直接失败。 COPY package.json package-lock.json ./ # 安装依赖(使用淘宝镜像 + BuildKit缓存) RUN --mount=type=cache,target=/root/.npm \ manifest_sha="$(sha256sum package.json | awk '{print $1}')" && \ lock_sha="$(sha256sum package-lock.json | awk '{print $1}')" && \ echo "Installing dependencies (using cache if available)..." && \ npm config set registry https://registry.npmmirror.com && \ npm_config_build_from_source=canvas npm_config_nodedir=/usr/local npm ci --prefer-offline --no-audit --no-fund --legacy-peer-deps && \ test "$manifest_sha" = "$(sha256sum package.json | awk '{print $1}')" && \ test "$lock_sha" = "$(sha256sum package-lock.json | awk '{print $1}')" && \ node -e "const sharp=require('sharp'); sharp(Buffer.alloc(4),{raw:{width:1,height:1,channels:4}}).png().toBuffer().then(()=>console.log('sharp dependency smoke passed')).catch((error)=>{console.error(error);process.exit(1)})" && \ echo "Dependencies installed ($(du -sh /app/node_modules | cut -f1))" # 本地开发阶段:复用依赖层,并补齐项目运行时会调用的媒体处理工具。 # 源码由 Compose 绑定挂载,避免每次修改代码都重新构建镜像。 FROM deps AS dev ARG ALPINE_MIRROR RUN apk add --no-cache ffmpeg openssl poppler-utils ENV NODE_ENV=development ENV NEXT_TELEMETRY_DISABLED=1 ENV HOSTNAME=0.0.0.0 EXPOSE 3000 CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"] # 构建阶段 FROM base AS builder ARG ALPINE_MIRROR # 安装构建依赖 RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \ apk add --no-cache libc6-compat python3 make g++ cairo-dev jpeg-dev pango-dev giflib-dev ffmpeg openssl WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ARG VCS_REF # The source revision is immutable image metadata, not a runtime environment # assertion. Production builds must provide the exact 40-character Git SHA. RUN if ! printf '%s\n' "$VCS_REF" | grep -Eq '^[0-9a-f]{40}$'; then \ echo "VCS_REF must be the exact 40-character lowercase Git SHA" >&2; \ exit 1; \ fi && \ printf '%s\n' "$VCS_REF" > /app/DEPLOYMENT_COMMIT && \ chmod 0444 /app/DEPLOYMENT_COMMIT # 品牌资源是页面和 favicon 的离线兜底;缺失或空文件时禁止继续构建。 RUN test -s "/app/public/brand/header-logo.png" && \ test -s "/app/public/brand/login-logo.png" && \ test -s "/app/public/brand/favicon.png" && \ node -e "const sharp=require('sharp'); Promise.all(['header-logo.png','login-logo.png','favicon.png'].map((name)=>sharp('/app/public/brand/'+name,{failOn:'error',limitInputPixels:25000000,animated:false}).rotate().toBuffer())).then(()=>console.log('builder brand image decode smoke passed')).catch((error)=>{console.error(error);process.exit(1)})" && \ test -f "/app/prisma/migrations/20251118000000_add_upload_id_to_materials/migration.sql" && \ test -f "/app/prisma/migrations/20260811_add_video_transcode_lease/migration.sql" && \ test -f "/app/prisma/migrations/20260811_update_brand_logo_defaults/migration.sql" && \ test -f "/app/prisma/migrations/20260812_add_product_package_upload_job_lease/migration.sql" && \ test -f "/app/prisma/migrations/20260812_align_material_upload_idempotency_index/migration.sql" && \ test -f "/app/prisma/migrations/20260813_add_material_custody_department/migration.sql" && \ test -f "/app/prisma/migrations/20260813_add_organization_units_and_employment_state/migration.sql" && \ test -f "/app/prisma/migrations/20260813_add_user_employee_binding/migration.sql" && \ test -f "/app/prisma/migrations/20260817_add_material_is_incomplete_shell/migration.sql" && \ test -f "/app/prisma/migrations/20260819_add_user_permission_inherit/migration.sql" && \ test -f "/app/prisma/migrations/20260820_add_permission_group_members/migration.sql" && \ test -f "/app/prisma/migrations/20260820_unique_permission_group_department/migration.sql" && \ test -f "/app/prisma/migrations/20260820_add_ding_workflow_snapshots/migration.sql" && \ test -f "/app/prisma/migrations/20260824_expand_ding_workflow_insights/migration.sql" && \ test -f "/app/prisma/migrations/20260826_add_ding_workflow_demand_type/migration.sql" && \ test -f "/app/prisma/migrations/20260827_add_ding_workflow_detail_fields/migration.sql" && \ test -f "/app/prisma/migrations/20260826_add_pack_video_early_access/migration.sql" && \ test -f "/app/prisma/migrations/20260828_add_pack_item_can_edit_assets/migration.sql" && \ test -f "/app/prisma/migrations/20260828_add_task_detail_change_approvals/migration.sql" && \ test -f "/app/prisma/migrations/20260829_add_recoverable_api_key_secret/migration.sql" && \ test -f "/app/prisma/migrations/20260831_add_private_task_detail/migration.sql" && \ test -f "/app/prisma/migrations/20260902_add_employee_roster_history/migration.sql" && \ test -f "/app/prisma/migrations/20260903_add_work_review_brand/migration.sql" && \ test -f "/app/prisma/migrations/20260908_add_brand_knowledge/migration.sql" && \ test -f "/app/prisma/migrations/20260909_expand_brand_knowledge_upload/migration.sql" && \ test -f "/app/prisma/migrations/20260914_add_brand_knowledge_engagement/migration.sql" && \ test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_download_count/migration.sql" && \ test -f "/app/prisma/migrations/20260914_expand_brand_knowledge_preview_slices/migration.sql" && \ test -f "/app/prisma/migrations/20260915_add_brand_knowledge_soft_delete/migration.sql" && \ test -f "/app/prisma/migrations/20260918_add_performance_conversion_sync/migration.sql" && \ test -f "/app/prisma/migrations/20260923_add_certificate_report_metadata/migration.sql" && \ test -f "/app/prisma/migrations/20260924_add_certificate_tested_on/migration.sql" RUN test -f "/app/prisma/migrations/20261008_permission_group_department_identity/migration.sql" && \ test -f "/app/prisma/migrations/20261008_team_function_configs/migration.sql" && \ test -f "/app/prisma/migrations/20261009_material_purge_outbox/migration.sql" && \ test -f "/app/prisma/migrations/20261009_material_purge_reference_indexes/migration.sql" && \ test -f "/app/prisma/migrations/20261012_ai_model_registry/migration.sql" && \ test -f "/app/prisma/migrations/20261013_ai_model_capabilities/migration.sql" && \ test -f "/app/prisma/migrations/20261013_ai_model_verifications/migration.sql" # 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失, # 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。 RUN test -f "/app/app/api/uploads/[...path]/route.ts" && \ test -f "/app/app/uploads/[...path]/route.ts" # 设置环境变量 ENV NEXT_TELEMETRY_DISABLED=1 # 构建时使用假的环境变量(只用于构建,运行时会被容器的真实环境变量覆盖) # 这样 Prisma 和其他模块在构建时不会报错 ENV DATABASE_URL="postgresql://build:build@localhost:5432/build" ENV NEXTAUTH_SECRET="build-time-secret" ENV NEXTAUTH_URL="http://localhost:3000" ENV ADMIN_API_KEY="build-time-key" ENV DINGTALK_CLIENT_ID="build-time-dingtalk-id" ENV DINGTALK_CLIENT_SECRET="build-time-dingtalk-secret" # 构建应用(复用 Next.js 构建缓存) RUN --mount=type=cache,target=/app/.next/cache \ NODE_ENV=production npm run build # 清理 standalone 目录中不需要的文件(减少 1GB+ 体积) RUN echo "清理 standalone 目录..." && \ rm -rf .next/standalone/uploads && \ rm -rf .next/standalone/.build-cache && \ rm -rf .next/standalone/.docker-cache && \ rm -rf .next/standalone/.git && \ rm -rf .next/standalone/__tests__ && \ rm -rf .next/standalone/docs && \ rm -rf .next/standalone/backups && \ rm -rf .next/standalone/.kiro && \ rm -rf .next/standalone/scripts && \ echo "清理完成" # Install only the migration/backup/seed dependency closure. Its lock is derived # from the application lock and checked before installation to prevent drift. FROM base AS migrator-deps ARG ALPINE_MIRROR WORKDIR /app RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \ apk add --no-cache openssl COPY package-lock.json ./package-lock.json COPY workers/database-migrator/package.json workers/database-migrator/package-lock.json ./workers/database-migrator/ COPY scripts/database-migrator-lock.mjs ./scripts/database-migrator-lock.mjs RUN node scripts/database-migrator-lock.mjs --check # The matching generated client is copied from builder; do not generate a second # client during installation. The CLI engines still run their normal postinstall. RUN --mount=type=cache,target=/root/.npm \ cd workers/database-migrator && PRISMA_SKIP_POSTINSTALL_GENERATE=1 npm ci --no-audit --no-fund # Explicit, one-shot database migrator. It is never used by the application # container or the normal deploy entry. The operator must supply production DB # identity, exact source SHA and a hashed backup receipt before --apply works. FROM base AS migrator ARG ALPINE_MIRROR WORKDIR /app ENV NODE_ENV=production RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \ apk add --no-cache openssl postgresql-client && \ command -v openssl >/dev/null && \ command -v pg_dump >/dev/null && \ command -v pg_restore >/dev/null && \ command -v psql >/dev/null ARG VCS_REF LABEL org.opencontainers.image.revision=$VCS_REF COPY --from=migrator-deps --chown=root:root /app/workers/database-migrator/node_modules ./node_modules COPY --from=builder --chown=root:root /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder --chown=root:root /app/prisma ./prisma COPY --from=builder --chown=root:root /app/scripts/safe-production-migrate.mjs ./scripts/safe-production-migrate.mjs COPY --from=builder --chown=root:root /app/scripts/create-verified-production-backup.mjs ./scripts/create-verified-production-backup.mjs COPY --from=builder --chown=root:root /app/scripts/seed-team-function-config.ts ./scripts/seed-team-function-config.ts # Source closure for the team-function seed; no application routes or services. COPY --from=builder --chown=root:root /app/lib/auto-personnel-rules.ts /app/lib/organization-material-permissions.ts /app/lib/prisma.ts /app/lib/team-function-contract.ts /app/lib/department-permission.ts /app/lib/employee-directory.ts /app/lib/env-validation.ts /app/lib/material-type-mapper.ts /app/lib/material-type-options.ts /app/lib/permission-groups.ts /app/lib/team-functions.ts /app/lib/user-permission-inherit.ts /app/lib/video-material-types.ts ./lib/ COPY --from=builder --chown=root:root /app/tsconfig.json ./tsconfig.json COPY --from=builder --chown=root:root /app/data/team-function-initial-content.json ./data/team-function-initial-content.json COPY --from=builder --chown=root:root /app/DEPLOYMENT_COMMIT /app/DEPLOYMENT_COMMIT RUN test "$(cat /app/DEPLOYMENT_COMMIT)" = "$VCS_REF" && \ test -x /app/node_modules/prisma/node_modules/@prisma/engines/schema-engine-linux-musl-openssl-3.0.x && \ test "$(stat -c '%a' /app/DEPLOYMENT_COMMIT)" = 444 USER node # Prove the CLI can use the bundled native engine without downloading or writing. RUN --network=none ./node_modules/.bin/prisma -v ENTRYPOINT ["node", "scripts/safe-production-migrate.mjs"] # 生产运行阶段 FROM base AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ARG ALPINE_MIRROR RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \ apk add --no-cache ffmpeg python3 cairo jpeg pango giflib openssl poppler-utils flock && \ command -v flock >/dev/null && \ flock --exclusive --timeout 1 --conflict-exit-code 75 --no-fork \ /tmp/material-upload-flock-probe.lock sh -c 'true' && \ rm -f /tmp/material-upload-flock-probe.lock ARG VCS_REF LABEL org.opencontainers.image.revision=$VCS_REF RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 --ingroup nodejs nextjs RUN mkdir -p /app/scripts # 复制必要文件 COPY --from=builder --chown=root:root /app/.next/standalone ./ COPY --from=builder --chown=root:root /app/.next/static ./.next/static COPY --from=builder --chown=root:root /app/app ./app COPY --from=builder --chown=root:root /app/public ./public COPY --from=builder --chown=root:root /app/DEPLOYMENT_COMMIT /app/DEPLOYMENT_COMMIT COPY --from=detail-renderer-deps --chown=root:root /opt/detail-renderer /opt/detail-renderer COPY --from=detail-renderer-deps --chown=root:root /usr/local/bin/node /opt/detail-renderer/bin/node ENV DETAIL_RENDERER_NODE=/opt/detail-renderer/bin/node ENV DETAIL_RENDERER_ENTRY=/opt/detail-renderer/cli.mjs # 只复制运行时必需的 node_modules COPY --from=builder --chown=root:root /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder --chown=root:root /app/node_modules/ag-psd ./node_modules/ag-psd COPY --from=builder --chown=root:root /app/node_modules/ioredis ./node_modules/ioredis COPY --from=builder --chown=root:root /app/node_modules/@ioredis ./node_modules/@ioredis COPY --from=builder --chown=root:root /app/node_modules/@aws-sdk ./node_modules/@aws-sdk COPY --from=builder --chown=root:root /app/node_modules/@smithy ./node_modules/@smithy # 复制运行时 schema,以及切流前只读校验所需的精确 migration;仍不复制 engine-cache。 COPY --from=builder --chown=root:root /app/prisma/schema.prisma ./prisma/schema.prisma COPY --from=builder --chown=root:root /app/prisma/migrations/20251118000000_add_upload_id_to_materials ./prisma/migrations/20251118000000_add_upload_id_to_materials COPY --from=builder --chown=root:root /app/prisma/migrations/20260811_add_video_transcode_lease ./prisma/migrations/20260811_add_video_transcode_lease COPY --from=builder --chown=root:root /app/prisma/migrations/20260811_update_brand_logo_defaults ./prisma/migrations/20260811_update_brand_logo_defaults COPY --from=builder --chown=root:root /app/prisma/migrations/20260812_add_product_package_upload_job_lease ./prisma/migrations/20260812_add_product_package_upload_job_lease COPY --from=builder --chown=root:root /app/prisma/migrations/20260812_align_material_upload_idempotency_index ./prisma/migrations/20260812_align_material_upload_idempotency_index COPY --from=builder --chown=root:root /app/prisma/migrations/20260813_add_material_custody_department ./prisma/migrations/20260813_add_material_custody_department COPY --from=builder --chown=root:root /app/prisma/migrations/20260813_add_organization_units_and_employment_state ./prisma/migrations/20260813_add_organization_units_and_employment_state COPY --from=builder --chown=root:root /app/prisma/migrations/20260813_add_user_employee_binding ./prisma/migrations/20260813_add_user_employee_binding COPY --from=builder --chown=root:root /app/prisma/migrations/20260817_add_material_is_incomplete_shell ./prisma/migrations/20260817_add_material_is_incomplete_shell COPY --from=builder --chown=root:root /app/prisma/migrations/20260819_add_user_permission_inherit ./prisma/migrations/20260819_add_user_permission_inherit COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_add_permission_group_members ./prisma/migrations/20260820_add_permission_group_members COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_unique_permission_group_department ./prisma/migrations/20260820_unique_permission_group_department COPY --from=builder --chown=root:root /app/prisma/migrations/20260820_add_ding_workflow_snapshots ./prisma/migrations/20260820_add_ding_workflow_snapshots COPY --from=builder --chown=root:root /app/prisma/migrations/20260824_expand_ding_workflow_insights ./prisma/migrations/20260824_expand_ding_workflow_insights COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_ding_workflow_demand_type ./prisma/migrations/20260826_add_ding_workflow_demand_type COPY --from=builder --chown=root:root /app/prisma/migrations/20260827_add_ding_workflow_detail_fields ./prisma/migrations/20260827_add_ding_workflow_detail_fields COPY --from=builder --chown=root:root /app/prisma/migrations/20260826_add_pack_video_early_access ./prisma/migrations/20260826_add_pack_video_early_access COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_pack_item_can_edit_assets ./prisma/migrations/20260828_add_pack_item_can_edit_assets COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_task_detail_change_approvals ./prisma/migrations/20260828_add_task_detail_change_approvals COPY --from=builder --chown=root:root /app/prisma/migrations/20260829_add_recoverable_api_key_secret ./prisma/migrations/20260829_add_recoverable_api_key_secret COPY --from=builder --chown=root:root /app/prisma/migrations/20260831_add_private_task_detail ./prisma/migrations/20260831_add_private_task_detail COPY --from=builder --chown=root:root /app/prisma/migrations/20260902_add_employee_roster_history ./prisma/migrations/20260902_add_employee_roster_history COPY --from=builder --chown=root:root /app/prisma/migrations/20260903_add_work_review_brand ./prisma/migrations/20260903_add_work_review_brand COPY --from=builder --chown=root:root /app/prisma/migrations/20260908_add_brand_knowledge ./prisma/migrations/20260908_add_brand_knowledge COPY --from=builder --chown=root:root /app/prisma/migrations/20260909_expand_brand_knowledge_upload ./prisma/migrations/20260909_expand_brand_knowledge_upload COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_add_brand_knowledge_engagement ./prisma/migrations/20260914_add_brand_knowledge_engagement COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_expand_brand_knowledge_download_count ./prisma/migrations/20260914_expand_brand_knowledge_download_count COPY --from=builder --chown=root:root /app/prisma/migrations/20260914_expand_brand_knowledge_preview_slices ./prisma/migrations/20260914_expand_brand_knowledge_preview_slices COPY --from=builder --chown=root:root /app/prisma/migrations/20260915_add_brand_knowledge_soft_delete ./prisma/migrations/20260915_add_brand_knowledge_soft_delete COPY --from=builder --chown=root:root /app/prisma/migrations/20260918_add_performance_conversion_sync ./prisma/migrations/20260918_add_performance_conversion_sync COPY --from=builder --chown=root:root /app/prisma/migrations/20260923_add_certificate_report_metadata ./prisma/migrations/20260923_add_certificate_report_metadata COPY --from=builder --chown=root:root /app/prisma/migrations/20260924_add_certificate_tested_on ./prisma/migrations/20260924_add_certificate_tested_on COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_detail_generator ./prisma/migrations/20261012_detail_generator COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_ai_model_registry ./prisma/migrations/20261012_ai_model_registry COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_capabilities ./prisma/migrations/20261013_ai_model_capabilities COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_verifications ./prisma/migrations/20261013_ai_model_verifications COPY --from=builder --chown=root:root /app/prisma/migrations/20261020090000_desktop_client_workspace ./prisma/migrations/20261020090000_desktop_client_workspace COPY --from=builder --chown=root:root /app/lib ./lib COPY --from=builder --chown=root:root /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py COPY --from=builder --chown=root:root /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs # 代码在 COPY 时直接归 root,避免再次改动整棵目录产生重复镜像层。 # 仅运行时缓存和存储目录允许 nextjs 写入,代码权限保持只读。 RUN install -d -o nextjs -g nodejs -m 0755 /app/.next/cache && \ install -d -o nextjs -g nodejs -m 0750 /app/uploads /app/uploads/temp /app/uploads/assembled /app/uploads/materials /app/uploads/cleanup && \ install -d -o nextjs -g nodejs -m 0700 /app/detail-generator-private && \ test "$(cat /app/DEPLOYMENT_COMMIT)" = "$VCS_REF" && \ test "$(stat -c '%a' /app/DEPLOYMENT_COMMIT)" = 444 && \ test -s /app/public/brand/header-logo.png && \ test -s /app/public/brand/login-logo.png && \ test -s /app/public/brand/favicon.png && \ node -e "const sharp=require('sharp'); Promise.all(['header-logo.png','login-logo.png','favicon.png'].map((name)=>sharp('/app/public/brand/'+name,{failOn:'error',limitInputPixels:25000000,animated:false}).rotate().toBuffer())).then(()=>console.log('runner brand image decode smoke passed')).catch((error)=>{console.error(error);process.exit(1)})" USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" CMD ["node", "server.js"]