gongxiangp2/Dockerfile

242 lines
15 KiB
Docker
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 基础镜像(使用 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
FROM ${BASE_IMAGE} AS base
# 安装依赖阶段
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
ARG VCS_REF
# 安装构建依赖
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 . .
# 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"
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
# 线上音频/视频就会直接 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 "清理完成"
# 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
ARG VCS_REF
WORKDIR /app
LABEL org.opencontainers.image.revision=$VCS_REF
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
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/scripts/safe-production-migrate.mjs ./scripts/safe-production-migrate.mjs
COPY --from=builder /app/scripts/create-verified-production-backup.mjs ./scripts/create-verified-production-backup.mjs
COPY --from=builder /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 && \
chown -R node:node /app/node_modules/prisma/node_modules/@prisma/engines && \
chmod -R a-w /app/prisma /app/scripts /app/DEPLOYMENT_COMMIT
USER node
ENTRYPOINT ["node", "scripts/safe-production-migrate.mjs"]
# 生产运行阶段
FROM base AS runner
ARG VCS_REF
WORKDIR /app
LABEL org.opencontainers.image.revision=$VCS_REF
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
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
RUN mkdir -p /app/scripts
# 复制必要文件
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/app ./app
COPY --from=builder --chown=root:root /app/public ./public
COPY --from=builder --chown=root:root /app/DEPLOYMENT_COMMIT /app/DEPLOYMENT_COMMIT
# 只复制运行时必需的 node_modules
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/ag-psd ./node_modules/ag-psd
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/ioredis ./node_modules/ioredis
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@ioredis ./node_modules/@ioredis
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@aws-sdk ./node_modules/@aws-sdk
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@smithy ./node_modules/@smithy
# 复制运行时 schema,以及切流前只读校验所需的精确 migration;仍不复制 engine-cache。
COPY --from=builder --chown=nextjs:nodejs /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=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs
# 创建必要目录并收紧权限。
# 应用代码归 root 所有,运行账号只能写上传目录和 Next.js 运行时缓存;
# 即使上层文件路径校验再次出现缺陷,也不能覆盖 server.js 或业务源码。
RUN mkdir -p /app/public /app/uploads && \
mkdir -p /app/scripts && \
mkdir -p /app/.next/cache && \
mkdir -p /app/uploads/temp && \
mkdir -p /app/uploads/assembled && \
mkdir -p /app/uploads/materials && \
mkdir -p /app/uploads/cleanup && \
chown -R root:root /app && \
chmod -R a+rX /app/public /app/prisma/migrations /app/scripts && \
chown -R nextjs:nodejs /app/.next/cache /app/uploads && \
chmod 0750 /app/uploads /app/uploads/temp /app/uploads/assembled /app/uploads/materials /app/uploads/cleanup && \
test "$(cat /app/DEPLOYMENT_COMMIT)" = "$VCS_REF" && \
chmod 0444 /app/DEPLOYMENT_COMMIT && \
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"]