232 lines
13 KiB
Docker
232 lines
13 KiB
Docker
# 基础镜像(使用 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"
|
||
|
||
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
|
||
# 线上音频/视频就会直接 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" && \
|
||
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=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"]
|