Next.js 使用 Makefile 将项目 docker 构建和版本控制自动化

179 阅读5分钟

Next.js 团队在其官方示例中,就多环境的开发问题,有一个示例为 With Docker - Multiple Deployment Environments,其核心是通过在一个 Makefile 来进行管理的。

基于这个启发,本案例使用 Makefile 作为一个总控,实现了我日常工作流程的自动化,同时设计了一个 一致的标签 来便于版本验证。

本文就 设计思路不同上下文之间传递参数的途径 进行了详细的阐述。


本文的英文版本:dev.io

代码仓库:with-makefile-docker-automation

系 PragmaticFrontEnd 原创内容


痛点和思考

当管理多个环境的部署时,很容易加载错误的配置。

为了更轻松地进行 版本验证,应该在构建阶段向所有 输出 注入一个 一致的标签

一个 标签 应该显示:

  1. 公共版本标签
  2. 用于构建的环境文件名称
  3. 构建时的 git 提交哈希码

该标签应以 一致 的方式被记录在记录:

  1. 在终端输出中打印,构建 Docker 镜像时
  2. 在后端日志文件中打印,一旦启动节点服务
  3. 在浏览器控制台或一个页面上显示,一旦 HTML 页面被获取

预期效果

当前项目的 package.json 如下所示:

"name": "x-app",
"version": "1.0.9",

构建阶段后

  • package.json 中的版本字段将自动增加。
"version": "1.1.0",
  • Terminal 会输出:
# docker image
x-app-production     v1.1.0       xxx     Less than a second ago      152MB

部署后

  • 服务器端
// 在 Node 服务器中记录Next.js 14.2.3
  - Local:        http://localhost:3000
  - Network:      http://0.0.0.0:3000Starting...
 🚀 version: v1.1.0-75a44a7-production
 ✓ Ready in 799ms
  • 前端
// 浏览器控制台 / 页面上
Release: v1.1.0-75a44a7-production

通过查看这些信息,我们可以自信地宣布:x-app 项目 v1.1.0 生产环境 已经准备就绪 🎉

如何使用

克隆项目并安装依赖项

# 选择您喜欢的软件包管理器,我们在这里使用 pnpm
pnpm install
  • 在 .env.development、.env.staging、.env.production 文件中输入要在每个环境中使用的值。

通过在项目的根路径上, 运行 make 命令来检查所有可用命令:

make

用于生产部署

# 构建 Docker 镜像
make build

# 将 Docker 镜像推送到注册表
# 如果在 shell 环境中有 DOCKER_ACCOUNT,只需运行 `make push`
make push DOCKER_ACCOUNT=<YOUR_DOCKER_ACCOUNT>

# 提交更改到 Git
make commit

# 顺序运行上述命令的快捷方式
make all

用于灰度测试环境部署

make <command> NODE_ENV=staging

用于本地部署

make <command> NODE_ENV=development

用于本地开发

make dev

工作原理

Make a Demo

根据 维基百科 的描述:

  • Make 是一个构建自动化工具,用于 构建可执行程序,同时也是一个 依赖跟踪 构建实用程序。
  • 于 1976 年在贝尔实验室创建。至今仍广泛使用。
  • Makefile 控制,其中指定了如何生成目标程序。

其语法如下所示:

target: pre­req­ui­sites
<TAB> recipe
  • target: 命令名称或需要构建的文件/目录。以下翻译为目标或者命令
  • pre­req­ui­sites: 依赖项,即在目标构建之前需要构建的 其他目标 或文件。
  • receipt: 以<TAB>开头,由一系列任意数量的 shell 命令 组成。

让我们动手尝试一个 hello world 示例: 首先,在一个目录下创建一个 Makefile 文件:

touch Makefile

其次,将一个 echo 示例写入文件中:

project=x-app

# syntax
# target:
# <TAB> recipe

echo1:
  @echo "hello"

echo2:
  @echo "world"

start: echo1 echo2
  @echo "start..." 
  @echo "$(project)"
  • 注意:默认情况下,一个receipt必须以一个 <TAB> 开始,而不是空格,否则会出现错误:*** missing separator. Stop.
  • @ 字符开头是告诉 make,不要将原始规则代码打印到控制台上。

最后,打开一个终端并键入 make start,输出应该是

hello
world
start...
x-app

到目前为止,我们已经获得了足够的知识来继续前进。

以下是一些关于 makeMakefile 的教程::

上下文及其变量

在继续定义 make 命令之前,我们应该意识到工作流程涉及三个运行时上下文:

  1. Shell 上下文
  2. Docker 构建上下文
  3. Node 运行时上下文

Shell 上下文

在打开终端窗口时,变量可以最初在 .zshrc.bashrc 中定义和公开。有关 shell 配置,请参阅 how-do-zsh-configuration-files-work 以获取更多详细信息。

make 作为一个 shell 脚本运行,它可以读取和写入现有的 shell 变量,也可以为上下文创建新的成员。

Shell 上下文暴露给:

  • npm 脚本
  • docker-compose.yml
  • docker .env
  • 项目 .env

Docker 构建上下文

此上下文与 Docker 构建生命周期一起创建和销毁,它与 shell 上下文隔离。

  • 参数通过 shell 上下文中的 --build-arg 传递:
# title="./Makefile"
  @docker compose -f docker/$(NODE_ENV)/docker-compose.yml build \
  --build-arg GIT_COMMIT=$(GIT_COMMIT) \
  --build-arg TAG=$(TAG) \
  --build-arg ENV=$(NODE_ENV) \
  --build-arg DOCKER_CONTAINER_PORT=$(DOCKER_CONTAINER_PORT)
  • 在其自己的上下文中通过 ARG 接受:
# title="./Dockerfile"
  ARG GIT_COMMIT \ 
      TAG \
      ENV \ 
      DOCKER_CONTAINER_PORT

Node 运行时上下文

此上下文随 next build 进程创建和销毁,它从 Dockerfile 中调用:

# title="./Dockerfile"
...
  elif [ -f package-lock.json ]; then npm run build; \
...

这将触发构建 npm 脚本:

// title="./package.json"
"scripts" : {
    "build": "cross-env NEXT_PUBLIC_VERSION=$TAG NEXT_PUBLIC_GIT_COMMIT_ID=$GIT_COMMIT NEXT_PUBLIC_ENV_FILE=$ENV next build",
}
  • cross-env 包在这里用于设置变量到 Node 运行时,而不用担心操作系统设置变量方式不同的问题。
  • 如以上设计的一致的标签,我们在向 Node 运行时传递三个关键变量。
  • NEXT_PUBLIC 前缀是 Next.js 中的一种约定,允许 node 在构建过程中将变量传递给前端,即代表是是公开的。

图形化阐述

变量传递流程示例

以变量 A 为例,它在 shell 配置中定义为 "0",然后由 Makefile 更新为 "1",并保持为 "1" 以供后续使用:

  • 在 docker-compose.yml 和 docker env 中读取和使用
  • 通过 --build-argARG 传递到 docker 构建上下文
  • 传递到 Node 运行时并暴露给公共访问

设计 Make 命令

通过上面的知识,定义 Makefile 中的目标 / 命令很简单:

  • 定义变量和辅助目标
# 定义暴露的变量
export NODE_ENV := production
export APP_NAME := $(shell npm pkg get name | xargs)# 从 package.json 配置中获取项目名称
export TAG :=# 最新版本标签
export DOCKER_HOST_PORT :=3000
export DOCKER_CONTAINER_PORT :=3000

# 内部变量
DOCKER_ACCOUNT :=$(DOCKER_ACCOUNT)# 将镜像推送到注册表时的 Docker 账号名称
GIT_COMMIT :=$(shell git rev-parse --short HEAD)# 获取最新提交的哈希码
DOCKER_IMAGE :=# 最新的 Docker 镜像
RECEIPT :=# 用于 echo

# 通过触发 npm 脚本 `version:update` 更新项目版本
version-update: 
  @sh -c "npx cross-env NODE_ENV=${NODE_ENV} npm run version:update || (echo 'Version update failed!' && exit 1)"

# 获取最新的变量
variable-update: 
  $(eval TAG := v$(shell npm pkg get version | xargs))
  $(eval DOCKER_IMAGE :=$(APP_NAME)-$(NODE_ENV):$(TAG))

  • 组合核心目标命令
# 一键发布:构建新镜像、推送到注册表、提交更改
# 依赖于 build、push 和 commit
all: build push commit

# 构建最新的 Docker 镜像
# 依赖于 version-update 和 variable-update
build: version-update variable-update 
  @docker compose -f docker/$(NODE_ENV)/docker-compose.yml build \
    --build-arg GIT_COMMIT=$(GIT_COMMIT) ...

# 标记并推送最新镜像到 Docker 注册表
push: variable-update 
  @docker tag $(DOCKER_IMAGE) $(DOCKER_ACCOUNT)/$(DOCKER_IMAGE)
  @docker push $(DOCKER_ACCOUNT)/$(DOCKER_IMAGE)
  @docker image rm $(DOCKER_ACCOUNT)/$(DOCKER_IMAGE)

# 在本地运行最新的 Docker 镜像
run: variable-update 
  @docker container run -it -p $(DOCKER_HOST_PORT):$(DOCKER_CONTAINER_PORT) $(DOCKER_IMAGE)
  

# 启动开发模式
dev: export NODE_ENV = development
dev: 
# 如果已启用 pnpm,则只需 `@pnpm install`
  @corepack enable pnpm && pnpm install 
  @pnpm run dev

如上所示,辅助目标被重用于我的核心目标。Makefile 真的可以让它变得干净简洁!

在 Node 和前端显示

接下来,让我们在服务器和前端显示一致的标签。

  • 为 Node 进程环境添加类型定义
// title="typings/index.d.ts"
namespace NodeJS {
  interface ProcessEnv {
    // for version control
    NEXT_PUBLIC_VERSION: string;
    NEXT_PUBLIC_GIT_COMMIT_ID: string;
    NEXT_PUBLIC_ENV_FILE: string;

    // from env file
    NEXT_PUBLIC_BASE_URL: string;
  }
}
  • 包含类型定义
// title="tsconfig"
"include": [
    ...,
    "typings/*.d.ts"
  ],

要在 Node 服务器启动的时候输入 log,Next.js 提供了一种方法,可以使用 Instrumentation

  • 启用 Next.js 的 Instrumentation 功能
// title="next.config.mjs"
const nextConfig = {
    experimental: {
      instrumentationHook: true,
    },
    ...
}
  • 当服务器启动时注册辅助函数
// title="instrumentation.ts"
export async function register() {
  logProjectVersion();
}

要在浏览器上显示,只需在页面上调用 logProjectVersion 函数即可。

至此,我们已经完成最繁琐的部分!

是时候部署和验证了

这一部分留给你来完成,选择你喜欢的部署 Docker 镜像的方式。检查是否能够获得与上面的 预期效果 类似的输出。

Cheers !