Chapter 09

CI 秒级 install,Docker 极致瘦身

CI 的目标:install 不是瓶颈。Docker 的目标:镜像小、构建层可复用。pnpm 的 store 天然适合缓存——只要配得对,install 从 2 分钟降到 10 秒;Docker 镜像从 600MB 降到 80MB。

CI 的三件套

  1. pnpm install --frozen-lockfile 严格按 lockfile,不升版本
  2. 缓存 ~/.local/share/pnpm/store,重跑时不重下
  3. 缓存 node_modules(有条件),跳过 link 步骤

GitHub Actions 标准模板

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0       # changed-since 需要

      - uses: pnpm/action-setup@v4
        with:
          version: 9
          run_install: false

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'     # 自动缓存 pnpm store

      - run: pnpm install --frozen-lockfile
      - run: pnpm -r lint
      - run: pnpm -r test
      - run: pnpm -r build
setup-node 的 cache: 'pnpm'
v4+ 支持,自动根据 pnpm-lock.yaml 的哈希当作 cache key——命中率高。以前要手写 actions/cache,现在一行搞定。

手写缓存(更细的控制)

- name: Get pnpm store directory
  id: pnpm-cache
  shell: bash
  run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT

- uses: actions/cache@v4
  with:
    path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
    key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
    restore-keys: |
      ${{ runner.os }}-pnpm-store-

适合单独管理 store 缓存或多 workflow 共享。

--frozen-lockfile 必不可少

pnpm install --frozen-lockfile
# 如果 package.json 和 lockfile 不一致 → 直接报错退出
# 防止 CI 偷偷升级依赖

本地开发不用这个——否则 pnpm add 改完 package.json 再 install 就装不上。CI/生产必用。

只装生产依赖

pnpm install --prod
pnpm install --production   # 同义
# 跳过 devDependencies,镜像更小

pnpm install --prod --frozen-lockfile   # CI 生产镜像推荐

deploy:为部署单独打包

pnpm deploy --filter=@myorg/web /tmp/deploy-web
# 把 @myorg/web 和它的依赖"实体化"到独立目录
# 目标目录是自包含的,可直接 tar 打包部署

pnpm deploy 把 workspace 内部的 symlink 依赖变成真实文件,生成可独立运行的目录——对 Docker/k8s 部署尤其有价值。

Docker 多阶段 + BuildKit 缓存

# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app

# ---- 依赖层(变化最少,缓存最久)
FROM base AS deps
COPY pnpm-lock.yaml package.json ./
COPY pnpm-workspace.yaml ./
COPY apps/web/package.json ./apps/web/
COPY packages/*/package.json ./packages/*/

RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --frozen-lockfile

# ---- build 层
FROM deps AS build
COPY . .
RUN pnpm -F @myorg/web build

# ---- 只保留生产依赖 → 部署用
FROM base AS runtime
COPY --from=build /app/apps/web/.next ./.next
COPY --from=build /app/apps/web/package.json ./

RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --prod --frozen-lockfile

EXPOSE 3000
CMD ["node", ".next/standalone/server.js"]

关键点:

deploy 子包到 Docker

FROM base AS build
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm -F @myorg/web build
RUN pnpm deploy --filter=@myorg/web --prod /out

FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=build /out .
CMD ["node", "dist/server.js"]

pnpm deploy 把该子包的运行时依赖实体化到 /out——不需要拷贝整个 monorepo,镜像瘦身显著。

使用 Turborepo 做任务缓存

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    }
  }
}
pnpm turbo run build
# Turbo 根据输入文件哈希缓存构建产物
# 第二次跑:未改动的包直接从缓存拷贝,0 构建

pnpm turbo run build --filter="...[HEAD^1]"
# 配合 filter,只构建改动影响到的包

远程缓存

# GitHub Actions
- name: Build
  run: pnpm turbo build
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: myorg

Turbo 的远程缓存把构建产物上传到 Vercel/自建 s3——同事在本地拉 PR 分支,跑 build 直接下载你 CI 的缓存,30 秒出结果。

monorepo-friendly Dockerfile(完整)

# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS base
RUN corepack enable
WORKDIR /app

FROM base AS pruner
RUN npm i -g turbo
COPY . .
RUN turbo prune @myorg/web --docker
# 生成 /app/out/{json,full},只含 web 及其依赖

FROM base AS installer
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --frozen-lockfile

COPY --from=pruner /app/out/full/ .
RUN pnpm turbo run build --filter=@myorg/web

FROM base AS runner
COPY --from=installer /app/apps/web/.next/standalone ./
EXPOSE 3000
CMD ["node", "apps/web/server.js"]

turbo prune 剔除所有不相关的 package,镜像构建上下文从 800MB 降到 80MB——Docker 层缓存命中率大幅提升。

registry 配置:国内镜像 + token

# .npmrc(项目根)
registry=https://registry.npmmirror.com/
@myorg:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
# CI 设置 token
- env:
    NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  run: pnpm install --frozen-lockfile

不同 scope 走不同 registry——公司私有包走 GitHub Packages,开源包走公共镜像。

监控 install 耗时

pnpm install --reporter=ndjson > install.log
# ndjson 每行一个 JSON 事件,便于分析

time pnpm install
# real 0m12.3s  ← 记录下来定期对比

CI 时间变长常因 store cache 失效——定期看日志里的「downloading」条目数。

常见 CI 问题

ERR_PNPM_OUTDATED_LOCKFILE
package.json 改了但 lockfile 没更新——本地跑 pnpm install 重新生成 lockfile 并提交。
ETIMEDOUT / ECONNRESET
registry 网络不稳——配国内镜像,或加 fetch-retries=5fetch-timeout=60000
ENOSPC(磁盘满)
CI runner 磁盘小——删 node_modules 再装,或用 self-hosted runner 扩容。
缓存命中率低
cache key 里应该只 hash pnpm-lock.yaml,别带 package.json 之类频繁变的。

本章小结