本篇接續 Yarn 2 和 Monorepo 提到的部屬的部分,因為 monorepo 裡包含了很多套件和網站,如果直接在根目錄執行 docker build 把整個 monorepo 打包成 Docker image 的話,勢必會做出大於 1 GB 而且內含一堆無用垃圾的 Docker image;為了要讓 Docker image 能夠最小化,必須只打包正式環境會需要用到的套件,確保不會浪費任何空間和時間。

我把建構 Docker image 的步驟分為:

  • 解析正式環境需要用到的套件
  • 複製 workspaces
  • 建構 Docker image

解析套件的相依關係

這部分對於 Yarn 2 來說非常容易,在擴充套件裡可以直接讀取整個 monorepo 的狀態,可以參考 yarn workspaces focus 的原始碼,這個指令是用來只安裝特定 workspace 需要用到的套件,剛好和我需要的功能相同。

import { Configuration, Project, Cache } from '@yarnpkg/core';

// 讀取設定
const configuration = await Configuration.find(this.context.cwd, this.context.plugins);
const { project } = await Project.find(configuration, this.context.cwd);

// 取得指定的 workspace
const workspace = project.getWorkspaceByIdent(
  structUtils.parseIdent('@foo/bar'),
);

const requiredWorkspaces = new Set([workspace]);

for (const ws of requiredWorkspaces) {
  // scope 可以是 `dependencies`, `devDependencies`
  // 因為我們只需要正式環境會用到的套件,所以這邊只用 `dependencies`
  const deps = ws.manifest.getForScope('dependencies').values();

  // 把相依的 workspace 新增到 `requiredWorkspaces` 裡
  for (const dep of deps) {
    const workspace = project.tryWorkspaceByDescriptor(dep);

    if (workspace) {
      requiredWorkspaces.add(workspace);
    }
  }
}

// 接著把 project 裡所有 workspace 的 manifest (package.json) 都清理一遍
for (const ws of project.workspaces) {
  // 如果這個 workspace 在正式環境會用到,那麼只清掉 `devDependencies`
  if (requiredWorkspaces.has(ws)) {
    ws.manifest.devDependencies.clear();
  } else {
    // 否則就把所有的 dependencies 都清掉
    ws.manifest.dependencies.clear();
    ws.manifest.devDependencies.clear();
    ws.manifest.peerDependencies.clear();
  }
}

透過上面的程式碼,就能得到正式環境需要用到的 workspaces。接下來,我會重跑一遍 yarn install,因為已經有快取了,所以不需要花多少時間,這是為了產生更新後的 yarn.lock,並了解有哪些 .yarn/cache 的檔案會被用到。

// 讀取現有快取
const cache = await Cache.find(configuration);

// 解析 dependencies
// 這部分相當於 `yarn install` 裡的 `Resolution Step`
await project.resolveEverything({ report, cache });

// 下載 dependencies
// 這部分相當於 `yarn install` 裡的 `Fetch Step`
await project.fetchEverything({ report, cache });

// 執行完上面兩個步驟後,就能產生新的 `yarn.lock`
const newLockFile = project.generateLockFile();

// 也能知道有哪些 cache 會被用到
for (const file of cache.markedFiles) {}

複製 workspaces

除了相依套件外,還需要把 workspaces 的原始碼也複製到 Docker image 裡,為了精簡需要複製的檔案量,可以參考 yarn pack 的原始碼,我在這邊用 packUtils 取得檔案列表,然後再複製到指定的資料夾裡。

import { packUtils } from '@yarnpkg/plugin-pack';

// `prepareForPack` 是用來執行 `prepack` 和 `postpack` 等 lifecycle hooks 的
await packUtils.prepareForPack(workspace, { report }, async () => {
  // 取得檔案列表
  const files = await packUtils.genPackList(workspace);

  // 如果想要把檔案壓成壓縮檔的話,可以用 `genPackStream`
  const stream = await packutils.genPackStream(workspace);
});

建構 Docker image

最後,檔案會被分成兩個部分複製到不同的資料夾,一個是 manifests,用來儲存 yarn install 需要用到的檔案,像是 package.json, yarn.lock 和快取;另一個部分則是 workspaces 的原始碼,也就是上面 yarn pack 產生的結果。

以下是 Dockerfile 的範例:

FROM node:12-alpine AS builder
WORKDIR /workspace
COPY manifests ./
RUN yarn install --immutable
RUN rm -rf .yarn/cache

FROM node:12-alpine
WORKDIR /workspace
COPY --from=builder /workspace ./
COPY packs ./
CMD yarn workspace @foo/bar start

結論

在寫完 Yarn 2 那篇文章後,我花了一些時間把內部使用的 Yarn 擴充套件整理了一下並開源,各位可以試用看看:yarn-plugin-docker-build