在无构建错误的情况下,将 NestJS 部署到基于 Bun 和 Prisma 7 的 Cloud Run 上
一个可用的 GitHub Actions 流水线,用于将基于 Prisma 7 和 Neon 的 NestJS 应用部署到 Cloud Run,同时解决团队常遇到的 Docker 及连接问题。
来源: Zero to Production: Deploying NestJS with Bun, Prisma 7, and Neon DB on Google Cloud Run
在 Bun 上运行 NestJS,依托 Prisma 7 以及 Neon 这类无服务器 PostgreSQL 数据库,并通过 GitHub Actions 将其部署到 Google Cloud Run,从各方面来看都极为高效。不过问题在于这种组合尚属新颖,相关故障也较少见,大多数团队在首次成功部署前都会因 Docker 构建错误或 Prisma 连接问题而浪费一天时间。本指南会为你提供一个可用的部署流程,并解释其背后的技巧,帮助你省去那段等待时间。
部署流程的构成
我们的目标是实现全自动化的流程:只需将代码推送到部署分支,自动化脚本就会自动执行数据库迁移、构建精简版的 Docker 镜像、将其推送到 Artifact Registry,最后将其部署到无服务器服务中。各步骤的顺序十分重要,而且由于 Prisma 和 Bun 在容器内的运行方式,有几个步骤需要特殊处理。
在 Docker 构建过程中让 Prisma 生成器正常工作
将 Prisma 容器化存在一个长期存在的难题。当运行 prisma generate 命令时,它需要数据库连接字符串以便解析架构并生成客户端代码。但在构建过程中没有必要提供真实的生产环境凭证,而且将敏感信息嵌入镜像层也是应当避免的,因为任何存在于可分发镜像中的内容都可能在未来被提取出来。
解决方法是使用一个在语法上有效但实际并不存在的假 URL,仅用于构建过程。下面的多阶段 Dockerfile 会根据冻结后的锁文件安装依赖项,设置一个指向本地不存在数据库的临时 DB__URL 值,只为让生成器能够正常工作,随后构建应用程序,并仅将编译后的输出复制到精简的运行时镜像中。
# Stage 1: Builder
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lock ./
COPY prisma ./prisma
COPY prisma.config.ts ./
RUN bun install --frozen-lockfile
# The "Prisma Satisfaction" Trick:
# Prisma 7 needs to see a URL format during the build phase to generate the client.
ENV DB__URL="postgresql://dummy:dummy@localhost:5432/dummy"
RUN bunx prisma generate
COPY . .
RUN bun run build
# Stage 2: Runner (Slim)
FROM oven/bun:1-slim AS runner
WORKDIR /app
# Only copy what is strictly necessary to keep the image small
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./
ENV NODE_ENV=production
ENV PORT=8080
CMD ["bun", "run", "dist/main.js"]
有两个关键决策。首先,采用多阶段构建方式,使用完整的Bun镜像进行编译,然后在bun:1-slim环境下运行,这样既能保持最终生成镜像的体积较小,又能减少攻击者可利用的接口。其次,通过虚拟字符串让生成器满足需求,而无需任何真实凭证进入相关环节,因为生成器在构建时只需要Postgres URL的格式,而非可访问的服务器地址。
限制服务账户的权限
该流水线通过服务账户向Google Cloud进行身份验证,人们很容易为了避免出错而授予其广泛的访问权限,但应坚决抵制这种做法。遵循最小权限原则,仅赋予其部署任务所需的角色,这样即使密钥泄露,也无法被用来控制整个项目——仅能用于将镜像推送到Artifact Registry以及部署到Cloud Run,除此之外别无他用。
GitHub Actions工作流的完整流程
在首次运行之前,应将工作流所依赖的数值,如服务账户密钥和数据库URL,作为加密后的仓库机密存储起来。该任务会获取代码、配置Bun环境、向Google Cloud进行身份验证、执行Prisma迁移、让Docker与Artifact Registry完成认证、构建并推送镜像、将应用部署到Cloud Run,同时清理旧镜像。源代码将此片段标记为SQL;实际上这是GitHub Actions的YAML工作流,下方的提示已对其进行了正确标注。
name: Deploy Server to Cloud Run
on:
push:
branches: ['main']
env:
PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
REGION: asia-south1
REPO_NAME: cloud-run-source-deploy
SERVICE_NAME: server
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Google Auth
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v2
# STEP: Database Migration
# Run migrations BEFORE deployment to ensure the DB is ready for new code.
- name: Run Prisma Migrations
run: |
bun install --frozen-lockfile
bunx prisma generate
bunx prisma migrate deploy
env:
DB__URL: ${{ secrets.DB__URL }}
- name: Docker Auth
run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev
- name: Build and Push
run: |
IMAGE_URI=${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPO_NAME }}/${{ env.SERVICE_NAME }}:latest
docker build -t $IMAGE_URI .
docker push $IMAGE_URI
echo "IMAGE_URI=$IMAGE_URI" >> $GITHUB_ENV
- name: Deploy to Cloud Run
uses: google-github-actions/deploy-cloudrun@v2
with:
service: ${{ env.SERVICE_NAME }}
region: ${{ env.REGION }}
image: ${{ env.IMAGE_URI }}
flags: '--port=8080 --allow-unauthenticated'
env_vars: |
DB__URL=${{ secrets.DB__URL }}
# Add placeholders for other secrets below:
# SMTP__HOST=${{ secrets.SMTP__HOST }}
# AUTH__TOKEN_SECRET=${{ secrets.AUTH__TOKEN_SECRET }}
# STEP: Housekeeping (Optional)
# Deletes old image digests to save on storage costs.
- name: Delete Old Images
shell: bash
run: |
IMAGE_PATH="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPO_NAME }}/${{ env.SERVICE_NAME }}"
gcloud artifacts docker images list "$IMAGE_PATH" --sort-by=~create_time > all_images.txt
awk 'NR>2 {print $2}' all_images.txt > digests_to_delete.txt
if [ -s digests_to_delete.txt ]; then
while read digest; do
if [[ "$digest" == sha256:* ]]; then
echo "Deleting old image: $digest"
gcloud artifacts docker images delete "$IMAGE_PATH@$digest" --delete-tags --quiet || true
fi
done < digests_to_delete.txt
fi
为何这些复杂的步骤必不可少
在新的镜像正式上线之前,将其作为独立步骤执行 prisma migrate deploy 比在容器启动时进行迁移要安全得多。如果迁移过程出现故障,部署将会停止,当前版本仍可继续处理请求;而如果在容器启动时进行迁移,一次失败的迁移就可能导致服务中断,尤其是在新代码已经开始运行的情况下。
最后的清理步骤是删除旧的镜像摘要,因为在高负载环境中,存储的构建产物会不断堆积,从而导致存储费用上升。这其实是个可选操作:如果您不需要保留所有历史构建版本以便回退,那就可以省略此步骤,承担相应的存储费用。
最后一种常见的故障模式是连接耗尽。Cloud Run 能快速扩展实例,且每个实例都会建立自己的连接,因此直接使用数据库的地址会导致突发流量超出其连接限制。在 Neon 中,应使用 DB__URL 中的池化连接字符串,这样多个生命周期短暂的实例就可以共享有限的连接池。这一方法适用于所有无服务器架构与 PostgreSQL 的集成场景。
关键要点
- 在构建时为 Prisma 生成器提供一个格式正确的虚拟数据库地址,避免真实的敏感信息被包含在镜像层中。
- 使用完整的 Bun 镜像进行构建,再在体积更小的版本上运行,以保持镜像大小适中。
- 在部署之前将迁移操作作为独立步骤执行,这样一旦出现故障只会中断发布流程,而不会影响正在运行的服务。
- 对于无服务器目标,应使用池化连接字符串,并将部署服务账户的权限限制在最低必要范围内。