Docker

构建 Docker 镜像是一种部署各种应用程序的常用方法。然而,从单体仓库(monorepo)这样做会带来一些挑战。

须知: 

本指南假设您正在使用 create-turbo 或具有类似结构的仓库。

问题

在单体仓库中,不相关的更改可能会导致 Docker 在部署应用程序时执行不必要的工作。

让我们设想一个单体仓库,它看起来像这样

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

您想使用 Docker 部署 apps/api,因此您创建了一个 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 和根 lockfile 复制到 Docker 镜像中。然后,它会安装依赖项,复制应用程序源代码并启动应用程序。

您还应该创建一个 .dockerignore 文件,以防止 node_modules 与应用程序的源代码一起被复制进去。

.dockerignore
node_modules
npm-debug.log

Lockfile 更改过于频繁

Docker 在部署应用程序方面非常智能。就像 Turborepo 一样,它会尽量 减少工作量

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

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

这听起来不错——直到我们意识到一点。package-lock.json 对于单体仓库来说是“全局”的。这意味着“**如果我们要在 apps/web 中安装一个新包,我们将导致 apps/api 重新部署**”。

在大型单体仓库中,这可能会导致大量时间浪费,因为单体仓库 lockfile 的任何更改都会级联到数十甚至数百次部署。

解决方案

解决方案是裁剪 Dockerfile 的输入,使其仅包含绝对必要的内容。Turborepo 提供了一个简单的解决方案——turbo prune

终端
turbo prune api --docker

运行此命令会在 ./out 目录中创建一个**精简版的单体仓库**。它仅包含 api 所依赖的工作区。它还会**裁剪 lockfile**,以便只下载相关的 node_modules

--docker 标志

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

首先,我们只想复制安装包所需的文件。运行 --docker 时,您将在 ./out/json 中找到这些文件。

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

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

这样将**依赖项**和**源代码文件**分开,使我们能够**仅在依赖项更改时运行 npm install**——大大提高了速度。

没有 --docker,所有精简后的文件都放在 ./out 中。

示例

我们详细的 with-docker 示例 深入介绍了如何充分利用 prune。为了方便起见,这里复制了 Dockerfile。

从单体仓库的根目录构建 Dockerfile

终端
docker build -f apps/web/Dockerfile .

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

./apps/web/Dockerfile
FROM node:18-alpine AS base
RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
 
# ---
FROM base AS prepare
# 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 . .
# Add lockfile and package.json's of isolated subworkspace
# 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
 
# ---
FROM base AS builder
# First install the dependencies (as they change less often)
COPY --from=prepare /app/out/json/ .
RUN yarn install
# Build the project
COPY --from=prepare /app/out/full/ .
 
# Uncomment and use build args to enable remote caching
# ARG TURBO_TEAM
# ENV TURBO_TEAM=$TURBO_TEAM
 
# ARG TURBO_TOKEN
# ENV TURBO_TOKEN=$TURBO_TOKEN
 
RUN yarn turbo build
 
# ---
FROM base AS runner
# Don't run production as root for security reasons
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
 
# Automatically leverage output traces to reduce image size
# https://nextjs.net.cn/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
 
CMD node apps/web/server.js

远程缓存

为了在 Docker 构建过程中利用远程缓存,您需要确保构建容器有权访问您的 远程缓存

有多种方法可以在 Docker 镜像中处理机密信息。我们将在此处使用一种简单的策略,即使用多阶段构建,将机密信息作为构建参数,这些参数在最终镜像中会被隐藏。

假设您使用的是与上面类似的 Dockerfile,我们将在 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 缓存命中,请从您的项目根目录运行如下命令

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