Docker

构建 Docker 镜像是部署各种应用的常用方法。但是,从 monorepo 执行此操作有几个挑战。

¥Building a Docker image is a common way to deploy all sorts of applications. However, doing so from a monorepo has several challenges.

Good to know: 

This guide assumes you're using create-turbo or a repository with a similar structure.

问题

¥The problem

在 monorepo 中,不相关的更改可能会导致 Docker 在部署应用时执行不必要的工作。

¥In a monorepo, unrelated changes can make Docker do unnecessary work when deploying your app.

假设你有一个如下所示的 monorepo:

¥Let's imagine you have a monorepo that looks like this:

server.js
package.json
package.json
package.json
package-lock.json

你希望使用 Docker 部署 apps/api,因此你创建了一个 Dockerfile:

¥You want to deploy apps/api using Docker, so you create a Dockerfile:

./apps/api/Dockerfile
FROM node:16
 
WORKDIR /usr/src/app
 
# Copy root package.json and lockfile
COPY package.json ./
COPY package-lock.json ./
 
# Copy the api package.json
COPY apps/api/package.json ./apps/api/package.json
 
RUN npm install
 
# Copy app source
COPY . .
 
EXPOSE 8080
 
CMD [ "node", "apps/api/server.js" ]

这会将根 package.json 和根锁定文件复制到 Docker 镜像。然后,它将安装依赖,复制应用源代码并启动应用。

¥This will copy the root package.json and the root lockfile to the Docker image. Then, it'll install dependencies, copy the app source and start the app.

你还应该创建一个 .dockerignore 文件,以防止 node_modules 与应用源代码一起复制。

¥You should also create a .dockerignore file to prevent node_modules from being copied in with the app's source.

.dockerignore
node_modules
npm-debug.log

锁文件更改过于频繁

¥The lockfile changes too often

Docker 在部署应用方面非常智能。就像 Turborepo 一样,它会尝试执行 尽可能少的工作 的操作。

¥Docker is pretty smart about how it deploys your apps. Just like Turborepo, it tries to do as little work as possible.

在我们的 Dockerfile 中,只有当其镜像中的文件与上次运行的文件不同时,它才会运行 npm install。如果没有,它将恢复之前的 node_modules 目录。

¥In our Dockerfile's case, it will only run npm install if the files it has in its image are different from the previous run. If not, it'll restore the node_modules directory it had before.

这意味着每当 package.jsonapps/api/package.jsonpackage-lock.json 发生更改时,Docker 镜像都会运行 npm install

¥This means that whenever package.json, apps/api/package.json or package-lock.json change, the Docker image will run npm install.

听起来很棒 - 直到我们意识到一些事情。package-lock.json 对于 monorepo 来说是全局的。这意味着如果我们在 apps/web 中安装一个新包,就会导致 apps/api 重新部署。

¥This sounds great - until we realize something. The package-lock.json is global for the monorepo. That means that if we install a new package inside apps/web, we'll cause apps/api to redeploy.

在大型的 monorepo 中,这可能会导致大量时间损失,因为对 monorepo 的 lockfile 的任何更改都会级联到数十或数百个部署中。

¥In a large monorepo, this can result in a huge amount of lost time, as any change to a monorepo's lockfile cascades into tens or hundreds of deploys.

解决方案

¥The solution

解决方案是将 Dockerfile 的输入精简为仅包含绝对必要的内容。Turborepo 提供了一个简单的解决方案 - turbo prune

¥The solution is to prune the inputs to the Dockerfile to only what is strictly necessary. Turborepo provides a simple solution - turbo prune.

Terminal
turbo prune api --docker

运行此命令会在 ./out 目录中创建一个精简版本的 monorepo。它仅包含 api 所依赖的工作区。它还会修剪锁定文件,以便只下载相关的 node_modules

¥Running this command creates a pruned version of your monorepo inside an ./out directory. It only includes workspaces which api depends on. It also prunes the lockfile so that only the relevant node_modules will be downloaded.

--docker 标志

¥The --docker flag

默认情况下,turbo prune 将所有相关文件放在 ./out 中。但为了优化 Docker 的缓存,理想情况下我们希望分两个阶段复制文件。

¥By default, turbo prune puts all relevant files inside ./out. But to optimize caching with Docker, we ideally want to copy the files over in two stages.

首先,我们只想复制安装包所需的内容。运行 --docker 时,你会在 ./out/json 中找到它。

¥First, we want to copy over only what we need to install the packages. When running --docker, you'll find this inside ./out/json.

package.json
package.json
server.js
package.json
package.json
turbo.json
package-lock.json

之后,你可以复制 ./out/full 中的文件以添加源文件。

¥Afterwards, you can copy the files in ./out/full to add the source files.

以这种方式拆分依赖和源文件,可以让我们仅在依赖发生变化时运行 npm install。 - 带来更大的加速。

¥Splitting up dependencies and source files in this way lets us only run npm install when dependencies change - giving us a much larger speedup.

如果没有 --docker,所有修剪的文件都将放置在 ./out 中。

¥Without --docker, all pruned files are placed inside ./out.

示例

¥Example

我们详细的 with-docker 示例 深入介绍了如何充分发挥 prune 的潜力。以下是 Dockerfile,为方便起见,已复制过来。

¥Our detailed with-docker example goes into depth on how to use prune to its full potential. Here's the Dockerfile, copied over for convenience.

从 monorepo 的根目录构建 Dockerfile:

¥Build the Dockerfile from the root of your monorepo:

Terminal
docker build -f apps/web/Dockerfile .

此 Dockerfile 是为使用 standalone 输出模式Next.js 应用编写的。

¥This Dockerfile is written for a Next.js app that is using the standalone output mode.

./apps/web/Dockerfile
FROM node:18-alpine AS base
 
FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
# Replace <your-major-version> with the major version installed in your repository. For example:
# RUN yarn global add turbo@^2
RUN yarn global add turbo@^<your-major-version>
COPY . .
 
# Generate a partial monorepo with a pruned lockfile for a target workspace.
# Assuming "web" is the name entered in the project's package.json: { name: "web" }
RUN turbo prune web --docker
 
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app
 
# First install the dependencies (as they change less often)
COPY --from=builder /app/out/json/ .
RUN yarn install --frozen-lockfile
 
# Build the project
COPY --from=builder /app/out/full/ .
RUN yarn turbo run build
 
FROM base AS runner
WORKDIR /app
 
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
 
# Automatically leverage output traces to reduce image size
# https://next.nodejs.cn/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
 
CMD node apps/web/server.js

远程缓存

¥Remote Caching

要在 Docker 构建期间利用远程缓存,你需要确保你的构建容器具有访问 远程缓存 的凭据。

¥To take advantage of remote caches during Docker builds, you will need to make sure your build container has credentials to access your Remote Cache.

有很多方法可以保护 Docker 镜像中的密钥信息。我们将在此使用一种简单的策略,即使用 secret 作为构建参数进行多阶段构建,这些参数将在最终镜像中隐藏。

¥There are many ways to take care of secrets in a Docker image. We will use a simple strategy here with multi-stage builds using secrets as build arguments that will get hidden for the final image.

假设你使用的 Dockerfile 与上述类似,我们将在 turbo build 之前从构建参数中引入一些环境变量:

¥Assuming you are using a Dockerfile similar to the one above, we will bring in some environment variables from build arguments right before turbo build:

./apps/api/Dockerfile
ARG TURBO_TEAM
ENV TURBO_TEAM=$TURBO_TEAM
 
ARG TURBO_TOKEN
ENV TURBO_TOKEN=$TURBO_TOKEN
 
RUN yarn turbo run build

turbo 现在将能够访问远程缓存。要查看非缓存 Docker 构建镜像的 Turborepo 缓存命中情况,请从项目根目录运行如下命令:

¥turbo will now be able to hit your Remote Cache. To see a Turborepo cache hit for a non-cached Docker build image, run a command like this one from your project root:

Terminal
docker build -f apps/web/Dockerfile . --build-arg TURBO_TEAM=“your-team-name” --build-arg TURBO_TOKEN=“your-token“ --no-cache