ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

NestJS 项目 Docker 化全流程:Dockerfile、Compose 与部署实战

NestJS 项目 Docker 化全流程:Dockerfile、Compose 与部署实战 我上一个项目是 NestJS 写的后端 API接手时第一件事不是排需求而是把整个项目做 Docker 化。原因很直接那个项目在同事本机跑不通在测试服跑起来又经常因为环境变量缺失直接 500排查到最后全是环境问题代码本身没什么毛病。从那时起我就意识到NestJS 项目的运行环境如果不固化开发和部署就是在跟一堆不可知的因素搏斗。Docker 化全流程要解决的就是把这个环境从个人电脑里抽出来连同数据库、缓存、启动命令一起变成可复现的东西。这篇内容会完整走一遍我在实际项目中做 NestJS 容器化的路径Dockerfile 怎么写、docker-compose 怎么编排、本地开发怎么配合容器、部署上线会遇到哪些坑以及最后怎么把镜像构建放进 CI。如果你正打算把一个 NestJS 应用容器化或者已经容器化但老碰到容器启动退出、数据库连接不上这类问题这篇应该能帮你少踩几个坑。1. 为什么对 NestJS 做容器化比想象中更值1.1 本地能跑、线上崩我踩过的环境不一致先说我踩过的真实情况。那个项目本机用的 Node 20生产服务器上的 Node 还是 14代码里某个 NestJS 依赖用到了 Node 16 之后才有的全局 API线上启动直接崩。当时所有人都说“本地跑得好好的”但本地跑得好恰恰是因为本地环境和线上完全不一致。另一个常见场景是依赖管理混乱。项目里有人用 npm 装依赖有人用 yarnlock 文件混着提交node_modules 时好时坏。新同事克隆代码后跑 npm install装完发现某个包版本错乱只能删掉重装。这种问题跟业务代码没关系但每天消耗的时间一点都不少。更隐蔽的是外部服务依赖。当时有人连本地 PostgreSQL有人连测试环境的库还有人 Redis 密码改了但配置文件没同步登录接口动不动 500。数据库连接串、缓存地址、迁移脚本全部散落在每个人自己的环境里。容器化之后这些依赖变成 docker-compose 里的服务定义任何人拉到代码都能得到一套一致的环境。1.2 容器化前后的工作流对比我把容器化前后的日常操作列了个对比环节容器化之前容器化之后环境安装手动装 Node、PostgreSQL、Redis版本易冲突docker compose up -d一条命令拉起启动应用npm run start:dev还要保证本机依赖齐全容器内启动宿主机不需要装业务依赖数据库准备手动建库建表、改密码容器创建时自动初始化数据卷持久化查看日志每个进程各自的输出分散docker compose logs -f聚合查看环境一致性每个人本机都不一样同一份镜像构建产物一致客观说容器化不是银弹它不会让性能变好也不会减少代码本身的 Bug。但“这个应用需要哪些运行时、依赖什么服务、怎么启动”这些原来靠文档靠嘴的问题现在直接被代码库记录下来了。对于多人和多环境协作这个价值比想象中大得多。1.3 哪些场景不需要容器化别盲目上不是所有 NestJS 项目都必须上容器。如果你只是本地写个 Demo跑完就删那容器化纯属给自己加戏。如果你只有一台内存很小的服务器跑 Node 进程占用不高但再叠一个数据库容器可能就吃力这时候不如直接用宿主机原生服务。另外如果你对网络延迟和吞吐量极其敏感容器网络相对于宿主机进程有一层额外的转发路径虽然一般场景下影响可以忽略但在极端性能要求的项目中需要先压测再决定。团队项目、需要频繁交付、需要部署到多台机器这种场景才值得把 Docker 化当成优先事项。2. 镜像构建全记录Dockerfile 从 0 到 12.1 基础镜像选择node:20-alpine 的取舍NestJS 项目的 Dockerfile 第一行就是基础镜像大多数人随手写FROM node:20但这一步值得多想一下。基础镜像直接决定最终镜像体积也决定后期遇到原生依赖时的心态。我用几个镜像做过对比基础镜像体积libc 类型适用场景node:20约 1GBglibc本地调试方便但不适合做生产镜像node:20-slim约 200MBglibc原生依赖兼容性好镜像体积适中node:20-alpine约 120MBmusl无原生依赖时体积最优但部分 npm 包需要额外处理如果你的 NestJS 项目只是标准依赖比如 nestjs/core、nestjs/typeorm、class-validator 这些纯 JS 的包用 node:20-alpine 完全没有问题。但如果引入过 sharp、bcrypt、canvas 这类带原生二进制的包alpine 的 musl libc 偶尔会让你在安装阶段就编译失败。遇到这种情况我建议不要死磕 alpine直接切 node:20-slim 更省心。2.2 多阶段构建与依赖层缓存NestJS 是 TypeScript 项目构建过程需要完整的构建工具链和 devDependencies但运行时只需要编译后的 dist 目录和生产依赖。如果所有东西都塞进同一个镜像最终镜像会包含 tsc、eslint、prettier 等一堆无关文件体积白白变大。这就是多阶段构建的动机。多阶段构建还有个容易被忽略的好处依赖缓存。Docker 构建每一层都有缓存如果先复制 package.json 和 lock 文件并安装依赖再复制源码那源码文件的变更不会让依赖层缓存失效。反过来如果先把整个项目复制进去再装依赖任何一行代码变动都会导致依赖重新安装构建慢得让人崩溃。在我的 Dockerfile 里构建流程拆成三个阶段builder 阶段装全量依赖并构建deps 阶段只装生产依赖runner 阶段只放编译产物和生产依赖。构建产物和依赖解耦镜像体积和构建速度都能兼顾。2.3 非 root 用户、时区与健康检查生产级细节很多人容器化第一步能跑起来但离“生产可用”还差几个细节。容器默认以 root 运行这是安全大忌。一旦容器进程被攻破攻击者拿到的是容器内最高权限。更好的做法是创建一个普通用户在 Dockerfile 里用adduser创建然后在启动前USER切换过去。NestJS 应用本身不需要写系统目录普通用户完全够用。时区也是容易漏的点。alpine 基础镜像默认是 UTC如果不在镜像里设置时区NestJS 打出来的日志时间会比北京时间慢 8 小时跟业务数据一对照就很容易怀疑人生。设置方式是在镜像里安装 tzdata 包并设置ENV TZAsia/Shanghai。健康检查属于容易被忽略但非常实用的配置。给容器加HEALTHCHECK后Docker 会定期访问应用的健康检查接口容器状态能从 starting 变成 healthy。这在编排系统里尤其有价值。2.4 最终 Dockerfile 完整示例与逐段解读下面是我实际项目里使用的 Dockerfile基于 pnpm Prisma 的组合# ---------- 阶段 1构建 ---------- FROM node:20-alpine AS builder WORKDIR /app # libc6-compat 兼容某些原生依赖openssl 是 prisma 在 alpine 上的运行依赖 RUN apk add --no-cache libc6-compat openssl COPY package.json pnpm-lock.yaml ./ RUN corepack enable pnpm install --frozen-lockfile COPY prisma ./prisma RUN npx prisma generate COPY . . RUN pnpm build # ---------- 阶段 2生产依赖 ---------- FROM node:20-alpine AS deps WORKDIR /app RUN apk add --no-cache libc6-compat openssl COPY package.json pnpm-lock.yaml ./ RUN corepack enable pnpm install --frozen-lockfile --prod # ---------- 阶段 3运行 ---------- FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENVproduction ENV TZAsia/Shanghai RUN apk add --no-cache tzdata \ addgroup -S nodejs adduser -S nodejs -G nodejs COPY --fromdeps --chownnodejs:nodejs /app/node_modules ./node_modules COPY --frombuilder --chownnodejs:nodejs /app/dist ./dist COPY --frombuilder --chownnodejs:nodejs /app/prisma ./prisma COPY package.json ./ COPY --frombuilder /app/node_modules/.prisma ./node_modules/.prisma USER nodejs EXPOSE 3000 HEALTHCHECK --interval30s --timeout5s --start-period10s --retries3 \ CMD node -e fetch(http://127.0.0.1:3000/health).then(r{if(!r.ok)process.exit(1)}).catch(()process.exit(1)) CMD [node, dist/main.js]逐段说明几个关键点。COPY package.json pnpm-lock.yaml ./放在源码复制之前是为了让依赖层命中 Docker 缓存源码改动不用重新跑 pnpm install。RUN npx prisma generate放在复制源码之前因为 Prisma 生成 client 只需要 schema 文件不需要项目源码这样 schema 没变时也能命中缓存。如果项目没用 Prisma这两行可以直接删掉改用你自己的 ORM 迁移文件。deps 阶段只装 production dependencies体积会小很多。但要注意prisma/client在安装时可能会自动尝试生成 client而 prisma CLI 在 devDependencies 里--prod模式下不一定可用。所以我把 builder 阶段生成好的.prisma目录显式复制到 runner 阶段这样运行时不需要 CLI 也能找到 client 文件。如果你的项目不使用 Prisma就忽略这一行。runner 阶段用adduser创建了非 root 用户所有复制文件都加了--chownnodejs:nodejs。这样应用进程只有一个普通用户权限安全性和稳定性都比直接 root 跑要好。3. docker-compose 编排数据库、缓存与 API 的协作3.1 用 compose 代替一堆 docker run 的原因NestJS 应用很少只有自己一个容器一般还要接 PostgreSQL、Redis甚至 RabbitMQ 或 MongoDB。如果每次都用docker run启动命令会又长又容易漏参数还要手动创建网络、配置重启策略。docker-compose 把服务定义集中到一个 yaml 文件里一个docker compose up -d就能把整套环境起来。Compose 另一个价值是环境隔离。每个项目默认创建独立的网络和命名空间两个项目的数据库容器即使端口相同也不会冲突。相比直接在宿主机上跑服务这是一种天然的隔离方式。3.2 postgres redis api 的编排示例下面这个 docker-compose.yml 是我常用的模板去掉了已经废弃的 version 字段因为新版 Docker Compose 不再需要它services: api: build: context: . dockerfile: Dockerfile container_name: nest-api restart: unless-stopped ports: - ${API_PORT:-3000}:3000 env_file: - .env environment: NODE_ENV: production DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}postgres:5432/${POSTGRES_DB} REDIS_URL: redis://redis:6379 depends_on: postgres: condition: service_healthy redis: condition: service_healthy networks: - nest-network postgres: image: postgres:16-alpine container_name: nest-postgres restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} ports: - ${POSTGRES_PORT:-15432}:5432 volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}] interval: 10s timeout: 5s retries: 5 networks: - nest-network redis: image: redis:7-alpine container_name: nest-redis restart: unless-stopped ports: - ${REDIS_PORT:-16379}:6379 volumes: - redis_data:/data healthcheck: test: [CMD, redis-cli, ping] interval: 10s timeout: 5s retries: 5 networks: - nest-network volumes: postgres_data: redis_data: networks: nest-network: driver: bridge这里最关键的是 api 容器连接数据库时用了服务名postgres而不是localhost。在 Compose 网络里每个服务名就是一个可解析的主机名api 容器连接 postgres 容器时写postgres:5432即可。另一个细节是端口映射。我把 postgres 的宿主机端口默认设为 15432避免和宿主机已安装的原生 PostgreSQL 冲突。如果你的机器 5432 没被占用可以在 .env 里设POSTGRES_PORT5432。但要注意容器内的 api 访问 postgres 走的是容器网络不走宿主机端口映射所以这个端口映射主要为了方便本机用数据库客户端连接查看。3.3 depends_on 不是万能的健康检查与启动顺序很多人在 compose 里写了depends_on: - postgres就以为万事大吉实际上这个字段只保证 postgres 容器先启动不保证 postgres 已经能接受连接。容器从启动到数据库真正就绪之间有一个时间窗口api 在这个窗口内尝试连接大概率会报 connection refused。解决办法是给数据库服务配置健康检查然后在 api 的depends_on里换成condition: service_healthy。这样 Docker 会先等 postgres 的健康检查通过再启动 api。postgres 的健康检查用pg_isreadyredis 用redis-cli ping都是官方镜像自带的工具不需要额外安装。即使这样我还是建议在应用层做一次数据库连接重试。健康检查能解决启动顺序问题但部署过程中数据库可能重启、网络可能闪断NestJS 应用如果一点容错都没有遇到瞬间抖动就退出。Prisma 或 TypeORM 的连接池都支持重试机制配置好之后再遇到数据库短暂不可用应用会自动恢复而不是直接宕机。3.4 数据卷、网络与 .env 的加载细节容器是临时性的容器删除后容器内写入的数据也会消失。所以数据库和缓存的数据目录必须挂载到命名卷里比如这里的postgres_data和redis_data。命名卷由 Docker 管理容器重建后数据依旧保留。另一个选项是 bind mount 到宿主机目录但跨平台时权限问题比较烦命名卷更省心。.env 文件在 Compose 里有双重作用。第一Compose 在解析 yaml 时会读取同目录的 .env 文件替换${POSTGRES_USER}这类变量。第二env_file: - .env把 .env 里的变量注入 api 容器作为环境变量。这两者是不同层面的机制很多人混在一起导致变量渲染结果和预期不一致排查时可以用docker compose config看最终的渲染结果。无论项目是否容器化都不要把真实密码和连接串提交进 Git 仓库。我的习惯是提交一个.env.example模板里面写占用名称和示例值然后让每个环境复制成.env再改。4. 本地开发与容器内调试的实操笔记4.1 热重载和 node_modules 挂载的矛盾容器化之后本地开发最大的痛点就是热重载。NestJS 开发模式常用pnpm run start:dev它监听源码变化自动重启。如果要把这个开发模式跑在容器里就需要把源码目录挂载进容器但 node_modules 怎么办直接挂载整个项目目录的话宿主机没有 node_modules或者宿主机的 node_modules 平台不一致都会导致容器内应用起不来。我在项目里用的方案是在 docker-compose.dev.yml 中挂载源码目录然后给容器内的/app/node_modules定义一个匿名卷。匿名卷会保留容器镜像内已经安装好的 node_modules宿主机目录挂载不会覆盖它。services: api: build: context: . dockerfile: Dockerfile.dev command: pnpm run start:dev volumes: - ./src:/app/src - /app/node_modules ports: - 3000:3000不过说实话在 Windows 和 macOS 上通过文件共享挂载源码NestJS 的文件监听性能不算好。文件变更到容器内生效有明显的延迟有时候改了代码要等好几秒才触发重启。所以我在实际开发中更推荐另一个做法数据库、Redis 这些依赖放容器里跑NestJS 应用直接在本机用pnpm run start:dev跑。这样既有依赖隔离热重载又是全速的开发体验最好。4.2 数据库迁移脚本在容器里执行的正确时机数据库迁移是容器化项目里最容易出幺蛾子的环节。有些人图省事在容器启动命令里直接跑prisma migrate deploy然后再启动应用。单实例测试时还行一旦以后水平扩展多个 API 容器同时启动去跑迁移会发生竞争轻则报 database is being accessed by other users重则产生不可预知的迁移混乱。我现在做的是把迁移从容器启动流程里剥离作为一个显式的部署步骤。比如要部署新版本时先执行docker compose run --rm api pnpm prisma migrate deploy确认迁移成功后再更新 api 容器。这样迁移和扩容彻底解耦回滚也容易判断。如果你用的是 TypeORM逻辑一样只是命令换成typeorm migration:run做法没有本质区别。4.3 日志、执行容器命令与其他调试技巧本地联调阶段我高频使用的几个命令可以列一下docker compose logs -f --tail200 api跟随查看 api 容器日志排查启动报错最快。docker compose exec api sh进入容器内手动执行命令检查环境变量和文件是否存在。docker compose ps查看所有服务状态和端口映射。docker compose run --rm api node -e console.log(process.env.DATABASE_URL)一次性运行命令确认容器内环境变量是否正确。如果 NestJS 应用里用了 winston 或 pino 这类日志库记得把日志输出到 stdout不要写文件。容器一旦重启写进容器本地文件系统的日志就丢了输出到 stdout 后可以直接用docker compose logs查看后续接日志采集系统也更方便。5. 部署过程中容易翻车的几个点5.1 Docker Desktop 启动失败类的本地环境问题很多项目本地容器化失败卡在 Docker 本身没起来而不是应用的问题。Windows 上第一次安装 Docker Desktop 后常见报错是virtualization support not detected或failed to connect to the docker api at npipe:////./pipe/docker-desktop-linux。这类问题先别急着重装。第一检查 Windows 的虚拟化支持是否开启BIOS 里的 Virtualization Technology 和 Windows 功能里的 Virtual Machine Platform、Windows Hypervisor Platform、WSL2 都需要启用。第二安装完 Docker Desktop 后一定要完全退出重开首次启动需要初始化 WSL2 内核任务栏图标转圈时间长是正常的。第三如果连接 docker api 失败多数原因是 Docker Desktop 根本没启动完成或者当前用户不在 docker 用户组。Linux 服务器上安装 Docker 后如果systemctl start docker起不来第一时间看journalctl -u docker或systemctl status docker。别在搜索引擎里盲猜错误日志会直接告诉你失败原因。还有一个高频坑是用户权限问题普通用户直接执行docker ps会报 permission denied把当前用户加入 docker 组并重新登录即可不建议一直用 sudo 前缀。5.2 容器启动即退出的排查链路容器启动后立刻退出是新人使用 Docker 时最常遇到的问题排查路径其实很固定。第一步看docker ps -a找到容器退出码。退出码是 1说明应用本身启动失败退出码是 0大概率是命令没有保持前台进程。有人写 Dockerfile 时把CMD [node, dist/main.js]写成了CMD [sh, -c, node dist/main.js ]把主进程放到了后台容器内没有前台进程Docker 认为任务已完成直接退出。第二步docker logs --tail 200 container看日志。NestJS 常见的启动失败原因也就那几类端口被占用、环境变量缺失、数据库连接失败、模块找不到。日志会直接告诉你是哪一种。第三步docker compose config检查环境变量渲染。很多时候配置写漏了变量比如 .env 里没有定义POSTGRES_PASSWORDCompose 解析后会把空值传进容器应用当然起不来。config 命令会把最终渲染出来的完整配置打出来一眼就能看出来。第四步如果日志还不够直接进容器手动跑启动命令。docker compose run --rm api sh进入容器手动执行node dist/main.js看它在交互式终端里会输出什么往往能复现出被日志截断的错误。5.3 数据库连接被拒与网络排查容器里连接数据库被拒第一反应不要怀疑网络信号先确认连接串对不对。最常见的坑就是用了localhost。在容器内部localhost指向容器自己而不是宿主机更不是 postgres 容器。正确写法是用 Compose 里的服务名比如postgres:5432。如果连接串看起来没问题但依然报错排查路径是先确认 postgres 容器是不是真的活着docker compose ps看状态再用docker compose logs postgres看数据库容器的日志确认它有没有正常初始化然后进 api 容器测试连通性比如docker compose exec api sh后执行wget -qO- http://postgres:5432或nc -zv postgres 5432。如果网络通再看认证信息日志里的password authentication failed会和连接被拒区分开。还有一个很容易忽略的点如果在 compose 里把 postgres 端口映射成5432:5432但宿主机已经有一个原生 PostgreSQL 占了 5432postgres 容器根本起不来。解决方式就是我在模板里写的宿主机映射端口默认用 15432或干脆不映射数据库端口只在容器网络内使用。5.4 镜像下载慢与构建慢的提速思路拉取官方镜像慢这个问题在不少地区都真实存在。最直接的方案是给 Docker daemon 配置镜像加速器。Linux 上修改/etc/docker/daemon.json{ registry-mirrors: [https://docker.mirrors.example.com] }配置后需要重启 Docker 服务。镜像加速器地址以你实际使用的服务商提供为准不同服务商配置格式一致。Docker Desktop 则可以在设置界面的 Docker Engine 配置区域里直接改同样的 JSON。构建慢的问题更多是缓存策略不当。构建阶段一定要把 lock 文件复制放在源码复制之前否则每改一行代码依赖层整个失效。要是你已经配好了 Dockerfile 的层顺序但 CI 里每次从零构建可以启用 BuildKit 的远程缓存把构建层缓存推送到仓库。GitHub Actions 里用docker/build-push-action配合cache-from: typegha能显著减少重复构建时间。6. 从单机到自动构建把坑沉淀进工作流6.1 镜像瘦身与体积对比我做过的 NestJS Prisma 项目镜像体积经历了几个阶段。最开始直接用 node:20 完整镜像把整个 node_modules 复制进去镜像体积 1.2GB。后来改成多阶段构建只用生产依赖镜像降到 500MB 左右。再换到 alpine 基础镜像压到 260MB。如果再把不需要的依赖清干净用 pnpm 的 production deploy 模式200MB 上下完全可行。用一个表格对比更直观方案镜像体积说明node:20 完整镜像 全部依赖约 1.2GB包含构建工具和 devDependencies不推荐多阶段 node:20-slim 生产依赖约 500MB兼容性好体积适中多阶段 node:20-alpine 生产依赖约 260MB体积明显减小注意原生依赖进一步精简依赖和清理无用文件约 200MB适合无特殊原生依赖的项目体积小不只是省磁盘拉取镜像、容器启动、安全漏洞面都会跟着受益。每次构建完跑一下docker images如果发现镜像体积异常膨胀第一时间检查是不是 devDependencies 被带进了生产镜像。6.2 GitHub Actions 自动构建与推送镜像容器化折腾完之后下一步就是别让构建停留在本地。我习惯把镜像构建推进 GitHub Actions推送 main 分支时自动构建并推到镜像仓库服务器只需要定期拉取最新镜像重启容器。一个最小可用的 workflow 长这样name: build-and-push on: push: branches: [main] jobs: docker: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: docker/setup-buildx-actionv3 - uses: docker/login-actionv3 with: registry: registry.example.com username: ${{ secrets.REGISTRY_USERNAME }} password: ${{ secrets.REGISTRY_PASSWORD }} - uses: docker/build-push-actionv5 with: context: . file: ./Dockerfile push: true tags: | registry.example.com/nest-app:latest registry.example.com/nest-app:${{ github.sha }} cache-from: typegha cache-to: typegha,modemaxcache-from和cache-to是关键优化它们把构建缓存放进 GitHub Actions 的缓存空间里第二次构建时依赖层能直接命中速度提升非常明显。镜像标签用latest加commit SHA既能快速部署又能精确回滚到某个版本。6.3 后续演进环境差异管理与 K8s 前的准备等这套流程跑顺之后再往多环境走就顺理成章。NestJS 的 ConfigModule 支持通过NODE_ENV区分环境加载不同配置Compose 里也可以用不同 env_file 做开发、测试、生产的环境隔离。但配置的原则是镜像不带环境差异环境差异全部通过运行时变量注入。同一个镜像放在开发环境就是开发配置放在生产环境就是生产配置这样才能保证验证过的镜像本身就是线上要跑的镜像。如果以后要上 Kubernetescompose 里的很多概念可以平移服务对应 Deployment网络对应 Serviceenv_file 对应 ConfigMap敏感变量对应 Secret。数据库这类有状态服务到了 K8s 里通常建议外置或用托管数据库而不是在集群里跑有状态副本。应用容器本身应该尽量做到无状态日志走 stdout上传文件走对象存储会话状态放 Redis这些在上 K8s 之前就打好基础后面迁移会顺利很多。最后分享一个我个人的小习惯现在每次新建 NestJS 项目我会在一开始就把 Dockerfile 和 docker-compose.yml 放进去而不是等项目写到一半再补。因为后续补容器化往往要处理历史包袱比如已经混入的依赖版本、固定写死的配置路径、没有拆分的单体目录结构而从一开始就按容器友好的方式组织整个容器化过程会顺畅得多。这个习惯帮我省下的时间远比写这两份文件本身多。
返回列表