我做过不少个人项目,早期最头疼的永远是那几件事:代码分散在好几个仓库里,改个接口要两边同步;依赖版本一多就乱;构建从零开始,慢得让人不想动。后来我把整套技术栈理了一遍,用 Monorepo 把代码统一收口,pnpm workspace 管依赖,Turborepo 做任务调度和缓存,Prisma 管数据库层,再用 Docker 把交付环境固定下来,才算找到一个真正省心的组合。
这篇内容不是科普五件套分别是什么,而是聚焦它们如何在同一个个人项目里协作:哪些事情该谁来干、边界画在哪、配置怎么落、Dockerfile 怎么写才不踩 Prisma 二进制和 workspace 链接的坑。我会连带把实际踩过的坑和排查过程一起写出来,项目结构可以照着抄,改一改就能用。
1. 为什么是这五件套?先理清边界
1.1 各自解决什么问题,别把它们当成一个整体
很多教程喜欢把 Monorepo、pnpm、Turbo、Prisma、Docker 打包成一个概念来讲,结果新手搭完以后,根本分不清哪个报错该找谁。我自己一开始也是这状态,直到把它们的职责边界在脑子里划清楚,才真正用顺了。
- Monorepo:代码组织策略,解决“多个项目放哪里、怎么共享代码”的问题。
- pnpm workspace:依赖管理工具,解决“多个包之间如何安装依赖、如何本地联动”的问题。
- Turborepo:任务编排工具,解决“哪些任务需要先跑、哪些可以跳过缓存”的问题。
- Prisma:数据访问层,解决“数据模型定义在哪、迁移怎么做、客户端怎么生成”的问题。
- Docker:交付运行环境,解决“本地能跑不算数,别的地方也能一键跑起来”的问题。
从依赖关系看是层层向下的:Monorepo 提供目录和包结构,pnpm 在这个结构上装依赖,Turborepo 基于这些包执行脚本并生成缓存,Prisma 是其中一个工作区的核心依赖,Docker 再把最终的构建产物和运行环境一起打镜像。任何一个环节出问题,都能通过这个分工定位。
1.2 我为什么放弃多个仓库和过度拆分
个人项目最初用多仓库,最大的痛点是共享代码靠复制粘贴:改动一个公共类型,要在 API 和 Web 两个仓库里各改一遍;版本号不一致还容易翻车。后来拆微服务,又发现单人项目根本不需要那么重的服务治理,光维护多个服务的部署配置就够累的。
所以我把项目收敛成一个 Monorepo,内部按“应用 + 包”来分。应用是真正能跑起来的服务,比如 API 服务、Web 前端、后台任务;包则是被应用共享的代码单元,比如数据库访问层、公共配置、类型定义。这样一个仓库里,改一次数据库层,所有应用都能同步拿到新逻辑,不需要发版等同步。
要特别说明的是,Monorepo 不是银弹,它适合“关联性强、共享需求多、单人维护成本可控”的项目。如果你的多个子项目之间毫无关联,塞进一个仓库反而会让 CI 变慢。对我来说,一个中大型个人项目正好在甜区里,收益远大于成本。
2. 从零搭一个可运行的仓库骨架
2.1 目录划分和 package 配置
我的推荐目录结构是这样的:
my-project/ ├── apps/ │ ├── api/ # API 服务,Express / Fastify / Nest 等 │ └── web/ # 前端,Next.js / Vite ├── packages/ │ ├── db/ # Prisma schema、迁移文件、生成的 Client │ └── config/ # 共享的 eslint、tsconfig 等 ├── package.json ├── pnpm-workspace.yaml ├── turbo.json ├── docker-compose.yml └── .npmrcapps放应用,packages放库,这是最经典的分法。刚上手不需要整太复杂,先把这两层立住,后续再加新应用的时候,往apps里加目录就行。
每个包的package.json里的name建议用@项目名/包名的格式,例如@myproject/db、@myproject/api。这个命名有讲究:后续pnpm --filter @myproject/api ...可以直接用名字精确定位到包,Turborepo的dependsOn也依赖这个命名来识别依赖关系。
2.2 pnpm workspace 的配置与踩坑记录
pnpm-workspace.yaml是整个依赖管理的锚点,内容不长,但特别关键:
packages: - "apps/*" - "packages/*"上面告诉 pnpm:apps和packages下的一级子目录都是 workspace 包。这样执行pnpm add lodash --filter @myproject/api,pnpm 只给这个子包安装,而公共依赖可以提升到根目录,从根节点统一管理。
这里有一个特别容易踩的坑:pnpm 默认会对依赖的安装脚本做白名单校验。比如 Prisma 这类依赖需要在postinstall阶段自动执行prisma generate(生成客户端的操作),一旦没放行脚本,安装完以后你会发现 Prisma Client 根本没生成,应用一跑就报“Cannot find module @prisma/client”。在 pnpm 新版本下,可以在根目录package.json添加:
{ "pnpm": { "onlyBuiltDependencies": [ "@prisma/client", "prisma", "esbuild" ] } }要理解这个机制,可以把它想象成:pnpm 默认有保留设置,只有白名单里的依赖才允许跑安装脚本,否则就是“装进来但不执行附带动作”。Prisma 编译生成原生查询引擎,恰好就在这类依赖里。如果发现prisma generate没自动跑,优先检查这里。
3. Turborepo 接管任务调度,缓存是最大红利
3.1 pipeline 配置到底在表达什么
Turborepo 的配置集中在turbo.json,核心是一个叫pipeline的字段,用于定义“任务怎么执行、哪些任务能缓存”。我先给一个实际用过的版本:
{ "$schema": "https://turbo.build/schema.json", "globalDependencies": [".env"], "globalEnv": ["NODE_ENV", "DATABASE_URL"], "pipeline": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"] }, "dev": { "cache": false, "persistent": true }, "lint": {}, "test": { "dependsOn": ["build"] } } }dependsOn里的^build是个非常有威力的标记。^表示“上游依赖包的 build 任务”。假设apps/api依赖packages/db,当 Turborepo 执行apps/api的 build 时,会先确保packages/db的 build 已经执行过。这个调度关系是自动推导的,不用你手动写死顺序。
outputs表示这个任务会产生哪些产物。Turborepo 会缓存这些产物以及运行日志,下次如果输入没变,直接跳过任务、恢复缓存。比如packages/db的 build 产物是dist/**,只要 schema 和代码没变化,后续所有依赖它的应用构建都不用重新执行数据层编译。globalEnv则声明哪些环境变量会影响缓存:如果DATABASE_URL变了,相关任务会破坏缓存重新执行,这地方不小心漏了配置,就会出现“改了环境变量但一直命中旧缓存”的诡异问题。
3.2 开发、构建、部署三个阶段 turbo 的用法
开发阶段通常不需要缓存,所以我上面的dev设置了"cache": false并且"persistent": true。开发时热更新本来就快,缓存反而可能拿旧结果,关闭是明智的。如果启动多个服务的 watch 模式,persistent让它不会因为“没有退出”而被 Turborepo 判定为异常。
构建阶段才是 Turbo 的主场。跑全量构建的时候,我通常只跑需要的应用:
pnpm turbo run build --filter=@myproject/api这样只构建 API 应用及其依赖链,不会把整个仓库全都编译一遍。在 CI 上全量构建也可以用:
pnpm turbo run build lint testTurbo 会自动按依赖拓扑排序,比如先执行packages/db的 build,再并行执行两个应用的 build,比串行快很多。
部署阶段更简单,直接构建完镜像就行。配合 Docker 时,Turborepo 还支持--dry-run查看任务依赖关系,写得不对随时能发现。换包管理器或构建工具的场景下,Turborepo 也兼容得很好,我同事的项目还把它接到 npm workspaces 上,照样能跑,灵活性够高。
4. Prisma 作为数据层,模块化才是正解
4.1 把 schema 放到 packages/db 里的意义
很多人用 Prisma 时直接把 schema 放在某个业务应用里,短期能跑,但 Monorepo 场景下会很尴尬:两个应用都要查用户表,总不能各自维护一份 schema 吧?把 Prisma 相关文件摘出来放到packages/db,本身就是在做“数据层和应用层解耦”。
在packages/db里,目录结构大致是:
packages/db/ ├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── src/ │ └── index.ts ├── package.json └── tsconfig.jsonschema.prisma定义数据模型和数据库连接。模型改动后,在数据库里同步的方式是迁移:本地开发用prisma migrate dev自动创建一次迁移文件并应用到数据库,生产环境用prisma migrate deploy只应用已有迁移文件,不会自动创建新的,避免在交付环境里改动模型导致不可控。
src/index.ts导出 PrismaClient 实例,这个实例是跨模块共享的。如果每个文件都new PrismaClient(),连接数会很高,开发期没问题,生产容易把数据库连接池打满。这个点在单体应用里也成立,但在 Monorepo 里更容易被忽略,因为不同应用各自有入口。
4.2 generate 与 migrate 分开理解
Prisma 有两个经常被混淆的动作:generate和migrate。用生活化的类比来说,generate是“根据数据模型生成代码客户端”,类似前端根据接口文档自动生成了类型定义和调用函数,它不碰数据库;migrate是“根据模型改动去改数据库表结构”,类似真的去执行 DDL 建表改字段。
所以典型流程是:
- 改
schema.prisma。 - 本地执行
pnpm --filter @myproject/db exec prisma migrate dev --name add_user_table,这一步同时完成“文件迁移 + 客户端生成”。 - 把生成的客户端提交到 Git 或镜像中(生产环境一定再跑一次
prisma generate,避免库里没带客户端导致运行时报错)。 - 生产环境启动前执行
prisma migrate deploy,只应用迁移文件,不动代码生成。
generate是一个偏构建期的操作,所以它非常适合被写进 Docker 的构建阶段;migrate deploy则偏运行时操作,适合放在容器启动前执行。这两个动作千万别都放在启动命令里,否则每次启动都生成客户端,不仅慢,还可能覆盖构建期生成好的版本。
4.3 和 Docker 结合时最容易踩的坑:原生二进制
Prisma 不是纯 JS,默认引擎类型包含了查询引擎二进制文件。这个二进制和 Node 版本、操作系统、CPU 架构强相关。本地是 macOS ARM,生产是 Linux x64,不重新安装对应引擎,就会在容器启动时报找不到引擎或架构不匹配。
在 Docker 里我通常明确配置engineType。如果是服务端要求轻量部署,可以使用binary引擎,它会把二进制文件独立出来,不需要占用过多内存加载 WASM。如果是简单部署、追求速度,用默认的library也可以,但要注意镜像里必须存在对应系统依赖。
一个更现实的问题是基础镜像选择。Prisma 二进制运行时依赖 OpenSSL,如果直接用精简到极致的alpine镜像,里面可能没有 Prisma 需要的那部分系统库,典型报错是“Unable to find OpenSSL”或“libssl.so.3 not found”。我踩过一次之后,老老实实改用node:20-bookworm-slim,一次性解决了。
此外,在 Monorepo 里给 Prisma 打镜像,不能把整个仓库塞进去,那样镜像会非常大。正确思路是多阶段构建,下一部分细说。
5. 用 Docker 把整套东西封装成“点餐式”服务
5.1 单个服务镜像的多阶段构建范式
多阶段构建的意义就像“饭店点餐”:前台只负责端菜上桌,后厨的锅碗瓢盆和备菜过程不能全都摆到餐厅里。在 Docker 里,构建阶段的“锅碗瓢盆”是编译工具链、源码、依赖缓存;运行阶段只需要已经构建好的代码和运行时依赖,这样镜像体积能小很多。
下面是apps/api/Dockerfile的一个可参考版本:
# 第一阶段:安装依赖并构建 FROM node:20-bookworm-slim AS builder WORKDIR /repo # 安装 pnpm 和构建所需工具 RUN corepack enable && corepack prepare pnpm@latest --activate # 复制 monorepo 的清单文件 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY turbo.json ./ COPY apps/api/package.json ./apps/api/ COPY packages/db/package.json ./packages/db/ COPY packages/config/package.json ./packages/config/ # 安装依赖(利用缓存层) RUN pnpm install --frozen-lockfile # 复制源码 COPY apps/api ./apps/api COPY packages/db ./packages/db COPY packages/config ./packages/config # 先对 db 包执行 prisma generate RUN pnpm --filter @myproject/db exec prisma generate # 构建应用 RUN pnpm turbo run build --filter=@myproject/api # 第二阶段:精简运行环境 FROM node:20-bookworm-slim AS runner WORKDIR /app ENV NODE_ENV=production # 复制构建产物和运行所需依赖 COPY --from=builder /repo/apps/api/dist ./dist COPY --from=builder /repo/node_modules/.pnpm ./node_modules/.pnpm COPY --from=builder /repo/apps/api/node_modules ./node_modules COPY --from=builder /repo/packages/db/prisma ./prisma COPY --from=builder /repo/packages/db/node_modules/.prisma ./node_modules/.prisma # 启动前执行迁移,再启动服务 CMD ["sh", "-c", "pnpm --filter @myproject/db exec prisma migrate deploy && node dist/index.js"]细看这个 Dockerfile,有几个故意写成这样的地方。先复制package.json、pnpm-lock.yaml、pnpm-workspace.yaml再装依赖,是为了充分利用 Docker 的层缓存。只要依赖清单没变,后面哪怕源码改了,pnpm install这层不会被重新执行,构建会快非常多。
运行阶段我保留了packages/db/prisma目录,是因为生产环境执行prisma migrate deploy时必须读取迁移文件。如果只复制了编译后的客户端,迁移文件缺失,启动时讲道理会失败。
5.2 Docker Compose 编排数据库与应用
单应用镜像搞定后,数据库、缓存和服务之间的互相配合,我建议直接用docker-compose.yml串起来。下面是一个简化的组合:
version: "3.8" services: postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: app POSTGRES_PASSWORD: app POSTGRES_DB: app_db ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U app"] interval: 5s timeout: 5s retries: 10 api: build: context: . dockerfile: apps/api/Dockerfile environment: DATABASE_URL: postgresql://app:app@postgres:5432/app_db depends_on: postgres: condition: service_healthy ports: - "3000:3000"这里有一个从真实事故里学到的点:服务启动顺序不能单靠depends_on的简单行为,因为那只是“先启动容器”,不代表数据库已经准备好。给数据库上healthcheck并让 API 应用等到service_healthy状态再启动,能很大程度减少“应用启动时数据库还没就绪”的报错,也就是前面提到的depends_on条件。
DATABASE_URL里用的是服务名postgres而不是localhost,在容器网络中这是固定规则:服务之间通过 compose 的服务名互相访问,localhost指的是容器自己,不是你本机。我第一次在容器里连接数据库时,用localhost:5432卡了很长时间,排查到最后才发现是这个问题。
5.3 本地开发 vs 生产交付的两种组合
开发和生产环境的跑法不一样,可以分开处理:
本地开发时,我推荐只把基础设施放进 Docker,应用代码留在宿主机跑。这样改动代码不用重新打镜像,热更新速度也快。命令大概是:
# 启动数据库等依赖服务 docker compose up -d postgres # 宿主机上跑应用开发模式 pnpm turbo run dev --filter=@myproject/api本地开发时DATABASE_URL可以写成localhost:5432,因为你跑的是宿主机进程,不是容器进程。
生产交付时,执行整栈构建并启动:
docker compose up -d --buildCompose 会依次构建镜像、启动数据库健康检查、等服务就绪后再启动 API 应用,整个过程是“点餐式”的:服务员把菜端到桌上,你只管吃完签字。相比手工执行docker run一长串参数,这样的封装清晰可靠得多。
6. 实操现场:我的整个开发与部署流程
6.1 从新建功能到上线的一次完整操作
一次完整迭代的流程,比单独看某个配置更直观。我以“给文章表增加一个 slug 字段”为例:
第一步:在packages/db/prisma/schema.prisma中修改模型:
model Post { id Int @id @default(autoincrement()) title String slug String @unique content String? createdAt DateTime @default(now()) }第二步:生成迁移并同步到本地数据库:
pnpm --filter @myproject/db exec prisma migrate dev --name add_post_slug这条命令会生成一个新的迁移文件到packages/db/prisma/migrations/,同时更新本地的 Prisma Client 和数据库表结构。
第三步:因为slug是@unique,如果表里已有历史数据,migrate 时可能触发唯一约束冲突。Prisma 会生成一个包含CREATE UNIQUE INDEX的 SQL,如果失败,可以在生成的迁移文件里先写一段数据去重逻辑,再执行prisma migrate dev。
第四步:在 API 应用里写代码,通过新客户端查询:
import { prisma } from "@myproject/db"; export async function getPostBySlug(slug: string) { return prisma.post.findUnique({ where: { slug } }); }第五步:本地验证通过后,提交代码,构建镜像并发布:
docker compose up -d --build api容器启动命令会自动执行prisma migrate deploy,应用新迁移,然后启动服务。整个流程端到端跑下来,不需要额外手工操作数据库。
6.2 变量与密钥的传递
在 Monorepo + Docker 的组合里,环境变量容易变成一个大坑,因为不同工具的变量读取位置不完全一样。我现在的策略是分三层:
- 本地开发:
.env文件按需放在各个包目录下,Prisma 默认读取packages/db/.env,API 应用可以读取apps/api/.env。 - Docker Compose:在 compose 文件里的
environment直接注入,例如数据库地址会设置为postgresql://app:app@postgres:5432/app_db。 - 生产密钥:不要写进 Git,使用部署平台的密钥管理或者
.env文件挂载到容器里。
特别注意DATABASE_URL这个变量:在本地和容器里指向不同地址。本地是localhost,容器里是 compose 的服务名postgres。如果你同时有.env和 compose 环境变量,优先搞清楚哪个在生效,我在这个问题上踩过好几次,最后统一规则:本地统一用.env,容器统一走 compose 的environment。
7. 常见问题与排查技巧实录
7.1 错误一览表
| 错误现象 | 原因 | 解决办法 |
|---|---|---|
PrismaClient is not a constructor | @prisma/client 未正确生成 | 执行pnpm --filter @myproject/db exec prisma generate,检查onlyBuiltDependencies是否包含 prisma |
Query engine library for current platform "debian-openssl-3.0.x" could not be found. | 基础镜像缺少 Prisma 原生二进制运行环境,或引擎未重新安装 | 使用node:20-bookworm-slim,构建阶段务必重新prisma generate |
Can't reach database server at db:5432 | 应用启动太早,数据库还没就绪;或DATABASE_URL地址有误 | 加 healthcheck +depends_on.condition,检查数据库服务名和端口 |
Error: P3014 Prisma Migrate could not create the shadow database | migrate dev需要 shadow database,但数据库账号没有创建权限 | 给开发用数据库账号足够权限,或配置shadowDatabaseUrl |
package.json中找不到 workspace 包 | pnpm-workspace.yaml 的 glob 没写对 | 检查packages/*是否匹配到目标目录,仓库存放位置是否越界 |
| Docker 构建特别慢,代码改动也要重装依赖 | 依赖层缓存被破坏 | 先复制 lockfile 和 package.json,再安装依赖,最后复制源码 |
| 容器内执行 pnpm 提示 command not found | 运行阶段镜像没有 pnpm | 在 Dockerfile 里启用 corepack,或者启动命令改用npx prisma migrate deploy,也可以把迁移命令放到 entrypoint 脚本里 |
| Turborepo 缓存一直命中,但产物是旧的 | outputs配置缺失,或inputs没包含相关源码 | 按实际产物目录补充outputs,必要时声明对应inputs |
7.2 独家避坑技巧
第一个技巧:Docker 构建阶段一定不要省略prisma generate。我知道有人觉得本地构建过了,镜像里带一份生成的客户端就行,结果容器平台一换就崩。Prisma 生成器绑定的平台和二进制在 CI/容器里经常是不同的,在 Dockerfile 的 builder 阶段显式执行一次,成本低、效果直观。
第二个技巧:Compose 中启动命令不要直接写旧的package.jsonscripts 里的 launch 脚本。更好的做法是单独写一个 entrypoint 脚本,比如:
#!/bin/sh set -e npx prisma migrate deploy node dist/index.js这个脚本可以挂载到容器里,也可以 COPY 进镜像。好处是启动逻辑和 Dockerfile 解耦,想在生产环境临时改东西,也不用重新打镜像。
第三个技巧:如果数据库迁移时间较长,应用容器启动可能因为等待时间过长而 fail,可以让 Prisma 迁移脚本带重试逻辑。比如在 entrypoint 里做有限次重试:
for i in 1 2 3 4 5; do npx prisma migrate deploy && break echo "migrate failed, retry $i/5" sleep 3 done exec node dist/index.js这是我被线上环境坑过一次后加进来的,后来再没出现过迁移阶段偶发失败导致整体启动失败的情况。
第四个技巧:尽量把packages/db作为独立包构建,而不是在应用里直接引用相对路径../packages/db。这样每个包都能独立构建、独立测试、独立发版,Turborepo 也能更好地判断缓存是否生效。如果图省事用了相对路径,代码是能跑,但构建依赖和缓存优化都打了折扣。
第五个技巧:数据库迁移文件一定要纳入版本管理。prisma/migrations目录里的文件记录的是数据库演进历史,多人协作或换机器时可以无缝恢复,这是数据库层最重要的资产之一。我见过不少人把 migrations 目录加进.gitignore,后面环境重建时完全不知道表结构是怎么来的,等于把项目的“数据库族谱”丢了。
写在最后的一点实际体会
这套组合用下来,我最直观的感受是:构建速度上来了,环境问题变少了。Turborepo 在最顺的时候能跳过几乎所有无关任务,pnpm 让依赖安装快且磁盘占用小,Prisma 把数据模型的改动变成可追踪的迁移记录,Docker 又让交付配置和环境彻底固定下来。中途踩坑不少,尤其是 Prisma 二进制和 pnpm 脚本执行这两块,但把它们逐个击破之后,整个仓库变得非常顺。
如果你也是单人维护一个多端项目,我建议可以从“Monorepo + pnpm + Turbo”先起步,等应用要部署了再把 Docker 加进来。Prisma 作为数据层,越早模块化越好,别等业务代码写多了再重构。后面如果你想接 CI/CD,这套结构和缓存机制也能直接平移过去,扩展空间留得很足。