Dockerfile 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. # Stage 1: Install dependencies (isolated layout, same as local dev)
  2. FROM node:22-bookworm AS deps
  3. # Mirror switch: default ON (国内镜像源) for fast local/China builds.
  4. # CI/CD on the release branch builds with --build-arg USE_CN_MIRROR=0 to use
  5. # official sources (deb.debian.org / registry.npmjs.org / pypi.org /
  6. # storage.googleapis.com). Resolved per-stage below.
  7. ARG USE_CN_MIRROR=1
  8. RUN set -eux; \
  9. if [ "$USE_CN_MIRROR" = "1" ]; then REG=https://registry.npmmirror.com; \
  10. else REG=https://registry.npmjs.org; fi; \
  11. corepack enable && \
  12. COREPACK_NPM_REGISTRY=$REG corepack prepare pnpm@10.24.0 --activate
  13. # 系统包 + Python 依赖:国内源时改 apt 源并走阿里云 pypi;官方源时全用默认
  14. RUN set -eux; \
  15. if [ "$USE_CN_MIRROR" = "1" ]; then \
  16. sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources; \
  17. PIP_INDEX=https://mirrors.aliyun.com/pypi/simple/; \
  18. else \
  19. PIP_INDEX=https://pypi.org/simple; \
  20. fi; \
  21. apt-get update && \
  22. apt-get install -y --no-install-recommends ffmpeg python3 python3-pip && \
  23. rm -rf /var/lib/apt/lists/* && \
  24. pip3 install --break-system-packages -i "$PIP_INDEX" faster-whisper
  25. WORKDIR /app
  26. COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
  27. COPY apps/cli/package.json apps/cli/
  28. COPY apps/web/package.json apps/web/
  29. COPY packages/shared/package.json packages/shared/
  30. COPY packages/core/package.json packages/core/
  31. COPY packages/tts/package.json packages/tts/
  32. COPY packages/templates/package.json packages/templates/
  33. COPY packages/collect/package.json packages/collect/
  34. COPY packages/text/package.json packages/text/
  35. COPY packages/audio/package.json packages/audio/
  36. COPY packages/renderer/package.json packages/renderer/
  37. RUN set -eux; \
  38. if [ "$USE_CN_MIRROR" = "1" ]; then REG=https://registry.npmmirror.com; \
  39. else REG=https://registry.npmjs.org; fi; \
  40. pnpm config set registry "$REG" && \
  41. pnpm install --frozen-lockfile
  42. # Stage 2: Build
  43. FROM deps AS build
  44. COPY . .
  45. # `next start` only reads .next/{server,static,types,*.json}; the 189MB
  46. # .next/cache is a webpack/swc build cache used solely by `next build`/dev.
  47. # Drop it here so it never enters the runtime COPY (and never gets re-duplicated
  48. # by the writable-dirs step below).
  49. RUN pnpm build && rm -rf apps/web/.next/cache
  50. # Stage 3: Runtime — fresh prod install with hoisted layout
  51. FROM node:22-bookworm-slim AS runtime
  52. # Mirror switch (see deps stage): default ON for local/China; CI passes
  53. # --build-arg USE_CN_MIRROR=0 for official sources.
  54. ARG USE_CN_MIRROR=1
  55. RUN set -eux; \
  56. if [ "$USE_CN_MIRROR" = "1" ]; then REG=https://registry.npmmirror.com; \
  57. else REG=https://registry.npmjs.org; fi; \
  58. corepack enable && \
  59. COREPACK_NPM_REGISTRY=$REG corepack prepare pnpm@10.24.0 --activate
  60. # 系统包(ffmpeg + Chrome 运行库 + 字体)+ Python 依赖:国内源时改 apt 源并走阿里云 pypi;官方源时全用默认
  61. RUN set -eux; \
  62. if [ "$USE_CN_MIRROR" = "1" ]; then \
  63. sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources; \
  64. PIP_INDEX=https://mirrors.aliyun.com/pypi/simple/; \
  65. else \
  66. PIP_INDEX=https://pypi.org/simple; \
  67. fi; \
  68. apt-get update && \
  69. apt-get install -y --no-install-recommends \
  70. ffmpeg python3 python3-pip fontconfig \
  71. libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
  72. libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
  73. libgbm1 libpango-1.0-0 libcairo2 libasound2 && \
  74. rm -rf /var/lib/apt/lists/* && \
  75. pip3 install --break-system-packages -i "$PIP_INDEX" faster-whisper
  76. WORKDIR /app
  77. # Copy workspace manifests + lockfile so pnpm install can resolve workspaces
  78. COPY --from=build /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml /app/turbo.json ./
  79. COPY --from=build /app/apps/cli/package.json apps/cli/
  80. COPY --from=build /app/apps/web/package.json apps/web/
  81. COPY --from=build /app/packages/shared/package.json packages/shared/
  82. COPY --from=build /app/packages/core/package.json packages/core/
  83. COPY --from=build /app/packages/tts/package.json packages/tts/
  84. COPY --from=build /app/packages/templates/package.json packages/templates/
  85. COPY --from=build /app/packages/collect/package.json packages/collect/
  86. COPY --from=build /app/packages/text/package.json packages/text/
  87. COPY --from=build /app/packages/audio/package.json packages/audio/
  88. COPY --from=build /app/packages/renderer/package.json packages/renderer/
  89. # Install production deps with hoisted layout (everything flat at root node_modules),
  90. # then re-create the workspace symlinks at root so any package can resolve @pipeline/* by walking up.
  91. RUN set -eux; \
  92. if [ "$USE_CN_MIRROR" = "1" ]; then REG=https://registry.npmmirror.com; \
  93. else REG=https://registry.npmjs.org; fi; \
  94. pnpm config set registry "$REG" && \
  95. pnpm install --frozen-lockfile --prod --config.node-linker=hoisted && \
  96. mkdir -p node_modules/@pipeline && \
  97. ln -s ../../packages/shared node_modules/@pipeline/shared && \
  98. ln -s ../../packages/core node_modules/@pipeline/core && \
  99. ln -s ../../packages/tts node_modules/@pipeline/tts && \
  100. ln -s ../../packages/templates node_modules/@pipeline/templates && \
  101. ln -s ../../packages/collect node_modules/@pipeline/collect && \
  102. ln -s ../../packages/text node_modules/@pipeline/text && \
  103. ln -s ../../packages/audio node_modules/@pipeline/audio && \
  104. ln -s ../../packages/renderer node_modules/@pipeline/renderer
  105. # Copy build artifacts (dist, .next, template src for Remotion entry, etc.)
  106. COPY --from=build /app/apps/cli/dist apps/cli/dist/
  107. COPY --from=build --chown=node:node /app/apps/web/.next apps/web/.next/
  108. COPY --from=build /app/apps/web/next.config.mjs apps/web/
  109. COPY --from=build /app/apps/web/src apps/web/src/
  110. COPY --from=build /app/packages/shared/dist packages/shared/dist/
  111. COPY --from=build /app/packages/core/dist packages/core/dist/
  112. COPY --from=build /app/packages/tts/dist packages/tts/dist/
  113. COPY --from=build /app/packages/tts/scripts packages/tts/scripts/
  114. COPY --from=build /app/packages/templates/dist packages/templates/dist/
  115. COPY --from=build /app/packages/templates/src packages/templates/src/
  116. COPY --from=build /app/packages/collect/dist packages/collect/dist/
  117. COPY --from=build /app/packages/text/dist packages/text/dist/
  118. COPY --from=build /app/packages/audio/dist packages/audio/dist/
  119. COPY --from=build /app/packages/renderer/dist packages/renderer/dist/
  120. COPY --from=build /app/assets assets/
  121. COPY --from=build /app/config config/
  122. # Install bundled Noto Sans SC as system font so Chrome can render CJK via fontconfig fallback
  123. RUN mkdir -p /usr/share/fonts/truetype/noto && \
  124. cp assets/fonts/NotoSansSC-Regular.ttf assets/fonts/NotoSansSC-Bold.ttf /usr/share/fonts/truetype/noto/ && \
  125. fc-cache -f
  126. # Bundle Remotion's headless Chrome so rendering needs NO network at runtime.
  127. # Remotion normally auto-downloads chrome-headless-shell from Google's CDN
  128. # (storage.googleapis.com / remotion.media) on first render, but that is
  129. # ~5KB/s from CN networks and stalls indefinitely. Fetch the SAME binary
  130. # Remotion wants from the Aliyun-backed npmmirror binary mirror at build time
  131. # (host flips to storage.googleapis.com/chrome-for-testing-public when
  132. # USE_CN_MIRROR=0, for the overseas CI/release build)
  133. # and lay it out exactly as Remotion's BrowserFetcher expects, so
  134. # ensureBrowser() finds it present (revision.local && existsSync(executablePath))
  135. # and skips the download. Version is read from the installed @remotion/renderer
  136. # so this auto-tracks Remotion upgrades instead of hardcoding a number.
  137. RUN set -eux; \
  138. if [ "$USE_CN_MIRROR" = "1" ]; then CHROME_BASE=https://registry.npmmirror.com/-/binary/chrome-for-testing; \
  139. else CHROME_BASE=https://storage.googleapis.com/chrome-for-testing-public; fi; \
  140. SRC=/app/node_modules/@remotion/renderer/dist/browser/get-chrome-download-url.js; \
  141. VERSION=$(grep -oE "TESTED_VERSION = '[0-9.]+'" "$SRC" | grep -oE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" | head -1); \
  142. test -n "$VERSION"; \
  143. CACHE=/app/node_modules/.remotion/chrome-headless-shell; \
  144. mkdir -p "$CACHE/linux64"; \
  145. URL="$CHROME_BASE/${VERSION}/linux64/chrome-headless-shell-linux64.zip"; \
  146. node -e "const fs=require('fs');fetch(process.argv[1]).then(r=>r.arrayBuffer()).then(b=>fs.writeFileSync(process.argv[2],Buffer.from(b)))" "$URL" /tmp/chs.zip; \
  147. python3 -m zipfile -e /tmp/chs.zip "$CACHE/linux64/"; \
  148. rm -f /tmp/chs.zip; \
  149. BIN="$CACHE/linux64/chrome-headless-shell-linux64/chrome-headless-shell"; \
  150. chmod +x "$BIN"; \
  151. printf '%s' "$VERSION" > "$CACHE/VERSION"
  152. # Run as the non-root `node` user (uid/gid 1000, shipped in the base image) so
  153. # the image is Pod Security "restricted"-friendly. We only chown the SMALL,
  154. # empty runtime-writable dirs (output, HOME, webpack cache). We deliberately do
  155. # NOT `chown -R` over .next or the Remotion chrome tree: in overlayfs a
  156. # recursive chown copies every touched file into a fresh layer, which here
  157. # duplicated ~470MB (.next + chrome) for no functional benefit.
  158. # - .next is already node-owned via `COPY --chown` above.
  159. # - The Remotion chrome under .remotion is root-owned but world a+rx (default
  160. # umask 022 plus the explicit `chmod +x` on the binary), so the node user
  161. # can read and execute it. We only chmod the .remotion *directory* 777 so a
  162. # stray write (none expected once chrome is pre-placed) can still succeed.
  163. # Remotion resolves its chrome cache to <nearest-package.json>/node_modules/.remotion
  164. # from the SERVER's runtime cwd (/app/apps/web), not the image build dir (/app).
  165. # The binary lives at /app/node_modules/.remotion (pre-placed above); expose it at the
  166. # path the server actually probes so ensureBrowser() finds it and skips the download.
  167. ENV HOME=/home/node
  168. RUN mkdir -p /app/output /home/node \
  169. /app/node_modules/.remotion \
  170. /app/packages/templates/node_modules/.cache && \
  171. chown -R node:node /app/output /home/node \
  172. /app/packages/templates/node_modules/.cache && \
  173. chmod 777 /app/node_modules/.remotion && \
  174. mkdir -p /app/apps/web/node_modules && \
  175. ln -sfnT /app/node_modules/.remotion /app/apps/web/node_modules/.remotion
  176. USER node
  177. EXPOSE 3000
  178. # Docker Compose / `docker run` health; Kubernetes uses its own probes against
  179. # the same endpoints. global fetch is available on Node 22.
  180. HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
  181. CMD node -e "fetch('http://127.0.0.1:3000/api/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
  182. WORKDIR /app/apps/web
  183. CMD ["/app/node_modules/.bin/next", "start"]