Nestjs | 实践:如何编写生产环境下的Dockerfile?

lxf2023-04-12 21:51:01

在 Nest.js中编写 Dockerfile 的最佳实践~

原文地址见「参考资料」部分,文章采用意译,而非直译,如有不准确之处,请在评论区指出!

本文将详细地介绍在一个 NestJS 项目中,如何使用 Dockerfile 构建一个最优的、可用于生产环境的 docker image。

使用我们编写的 Dockerfile 可以进行本地开发和容器化部署

准备好了吗?让我们开始吧~

编写 Dockerfile

首先,我们怎么理解一个镜像容器呢?简单来说,它是一个隔离的软件包,包含了应用代码运行时所有的环境配置

通过编写一个 Dockerfile 文件来定义一个容器镜像并通过几个简单的指令来构建、运行以及管理镜像。

使用下面命令新建一个 Dockerfile 文件:

touch Dockerfile

在文件中添加一些指令,可以查看指令对应的注释弄清楚它们的作用:

# 基础镜像
FROM node:18

# 创建应用目录
WORKDIR /usr/src/app

# 使用通配符来确保 package.json 和 package-lock.json 被复制
COPY package*.json ./

# 安装应用依赖
RUN npm install

# 复制源代码
COPY . .

# 构建应用,并创建「dist」目录
RUN npm run build

# 启动服务
CMD [ "node", "dist/main.js" ]

与 .gitignore 文件的作用类似,我们可以添加一个 .dockerignore 文件来避免不需要的文件被打包到镜像中。

touch .dockerignore

填写不需要的文件和文件夹:

Dockerfile
.dockerignore
node_modules
npm-debug.log
dist

测试本地容器

现在,我们在本地测试下 Dockerfile 是否配置正确。

在终端中,切换到项目的根目录执行下面的命令,构建项目的镜像,注意:不要忘记了最后的「.」。

docker build -t nest-app .

构建完成后,在本地机器上运行docker images 来查看打包好的镜像:

docker images
REPOSITORY                   TAG       IMAGE ID       CREATED          SIZE
nest-app                           latest    004f7f222139   31 seconds ago   1.24GB

使用下面的命令运行容器:

端口可以自行指定,不一定要使用80端口

docker run -p 80:3000 nest-app 

浏览器中输入 http:localhost 就可以访问 NestJS 应用的返回结果了。

如果在运行过程中碰到端口冲突,可以尝试使用docker rm -f $(docker ps -aq) 命令来停止并删除所有运行的容器。

优化 Dockerfile

现在,我们知道镜像是可以在本地运行的,接下来我们尝试减小镜像包的大小使它在生产环境中更易于分发。同时我们需要尽可能的保证镜像的安全。

另外,一些商用的部署工具是通过镜像的大小来进行收费的,所以尽可能地减小镜像包的大小是必要的。

在上面我们可以看到构建的镜像包有1.24GB,这实在是太大了,我们需要回到 Dockerfile 文件做一些优化。

使用 Alpine 版本的 node 镜像

通常,当我们想要减小镜像包的大小时,建议使用 Alpine 版本的 node 镜像。如:仅仅是将 node:18 替换成node:18-alpine就可以将镜像包的大小从1.24GB 减小到 466MB。

添加 NODE_ENV 环境变量

前端项目中,当环境变量 NODE_ENV 被设置为 production 时,许多的 npm 库都有一个内置的优化,所以,我们可以使用下面的指令在 Dockerfile 文件中设置环境变量:

ENV NODE_ENV production

使用 npm ci 代替 npm install

当构建一个docker 镜像时,「npm」建议使用npm ci而不是npm install,下面是摘录官网的一段话,说明了其中的原因:

npm ci 的作用与 npm install 类似,只不过它是为了在「自动化环境」中使用,如:测试平台, 持续集成和部署,或任何需要纯粹安装依赖的场景中。

而此时的应用场景在合适不过了,所以在 Dockerfile 文件中,我们使用 npm ci 代替 npm install

RUN npm ci

使用 USER 指令

默认情况下,如果不在 Dockerfile 文件中使用 USER 指令,镜像将使用 root 权限运行。这是有安全隐患的,我们在 Dockerfile 文件中添加 USER 指令。

我们使用的 node 镜像已经为我们创建好了一个名为「node」的用户了,直接使用即可:

USER node

记住,无论什么时候使用 COPY 指令时,添加一个标志位来确保用户有一个正确的权限是一个很好的实践。

你可以使用 --chown=node:node 来实现它,如:

COPY --chown=node:node package*.json ./

使用多阶段构建

在 Dockerfile 中,你可以定义多阶段构建,它是一种循序地构建方式,通过构建多个镜像来最大程度地优化镜像。

除了使用一个小的镜像之外,多阶段构建应该是我们可以做的最大的优化了:

###################
# BUILD FOR LOCAL DEVELOPMENT
###################

FROM node:18-alpine As development

# ... your development build instructions here

###################
# BUILD FOR PRODUCTION
###################

# 生产环境下的基础镜像
FROM node:18-alpine As build

# ... your build instructions here

###################
# PRODUCTION
###################

# 生产环境下的基础镜像
FROM node:18-alpine As production

# ... your production instructions here

上面使用了3个阶段:

  • development - 构建一个用于本地开发的镜像;
  • build - 构建一个用于生产环境下的镜像;
  • production - 复制生产环境中相关的文件并启动服务。

如果不需要在本地 Docker 中运行 NestJS 应用,你可以将步骤1和步骤2合并成一步。

但上面多阶段构建的好处是,使用一个 Dockerfile 文件,既可以用于本地容器化开发(需要结合 docker-compose.yml),也可以创建一个用于生产环境下的 Docker 镜像。

如果你对本地容器开发(多步构建 + docker compose + 热重载)感兴趣,可以参考这篇文章。

完成 Dockerfile

使用上面描述的技术编写 Dockerfile 文件,并构建一个用于生产环境中的镜像:

###################
# BUILD FOR LOCAL DEVELOPMENT
###################

FROM node:18-alpine As development

# 创建应用目录
WORKDIR /usr/src/app

# 复制应用依赖列表到容器镜像
# 使用通配符来确保 package.json 和 package-lock.json 被复制
# 在最开始执行复制操作是为阻止每次代码改变时都再次执行  npm install 命令
COPY --chown=node:node package*.json ./

# 使用 npm ci 安装应用依赖
RUN npm ci

# 复制源代码
COPY --chown=node:node . .

# 使用镜像中的node用户(而不是 root 用户)
USER node

###################
# BUILD FOR PRODUCTION
###################

FROM node:18-alpine As build

WORKDIR /usr/src/app

COPY --chown=node:node package*.json ./

# 为了运行`npm run build`命令,我们需要使用 Nest CLI,它是一个开发依赖。 
# 在上一步的构建中,我们使用了`npm ci`安装了所有依赖,
# 所以我们可以从 development 镜像中直接复制 node_modules
COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modules

COPY --chown=node:node . .

# 运行构建命令,创建生产环境下的 bundle
RUN npm run build

# 设置 NODE_ENV 环境变量
ENV NODE_ENV production

# 运行 `npm ci` 命令移除存在的 node_modules 目录并传递 `--only=production`参数,以确保只安装生产依赖。
# 这样我们就可以保证 node_modules 目录尽可能得被优化。
RUN npm ci --only=production && npm cache clean --force

USER node

###################
# PRODUCTION
###################

FROM node:18-alpine As production

# 从 build 镜像中复制构建好的代码到最终的镜像中
COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=build /usr/src/app/dist ./dist

# 启动服务
CMD [ "node", "dist/main.js" ]

注意:只要你更新了 Dockerfile 文件,你就需要运行下面的命令再次构建镜像:

docker build -t nest-app .

然后,使用下面的命令启动容器:

docker run -p 80:3000 nest-app

运行docker images 查看镜像列表,可以发现镜像减小了很多。

疑难解答

在实践开发过程中,你可能会遇到下面的异常:

找不到模块"webpack"

如果你遇到了下面的错误,有可能是你使用了错误的 node 版本导致的:

  • Error: Cannot find module 'webpack'

最简单的解决方案是使用FROM node:18-alpine 代替 FROM node:14-alpine

找不到 nest 命令

当运行 npm run build 命令时,应用会使用 Nest CLI 来生成编译文件。

但是,Nest CLI 是一个开发环境依赖,在部署时只安装了生产环境的依赖,所以导致这个问题,可以尝试下面的解决方案:

  • (推荐): 在多阶段构建镜像的配置中,可以在build 阶段运行npm run build命令,在该阶段会安装生产和开发依赖(使用 npm ci);
  • 更新 package.json 文件将 Nest CLI 包移到生产依赖中,这种方法的不足之处是会增加 node_modules 的大小从而增加镜像的大小。

这推荐的方法我们在上面的 Dockerfile 中已经说明了。

在 Dockerfile 中使用 pnpm

如果要在项目中使用 pnpm 包管理器,Dockerfile 内容如下:

###################
# BUILD FOR LOCAL DEVELOPMENT
###################

FROM node:18 As development
RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm

WORKDIR /usr/src/app

COPY --chown=node:node pnpm-lock.yaml ./

RUN pnpm fetch --prod

COPY --chown=node:node . .
RUN pnpm install

USER node

###################
# BUILD FOR PRODUCTION
###################

FROM node:18 As build
RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm

WORKDIR /usr/src/app

COPY --chown=node:node pnpm-lock.yaml ./

COPY --chown=node:node --from=development /usr/src/app/node_modules ./node_modules

COPY --chown=node:node . .

RUN pnpm build

ENV NODE_ENV production

RUN pnpm install --prod

USER node

###################
# PRODUCTION
###################

FROM node:18-alpine As production

COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=build /usr/src/app/dist ./dist

CMD [ "node", "dist/main.js" ]

使用 Fastify

如果你使用 Fastify 作为 NestJS 的服务器,你需要修改服务器的监听地址为: 0.0.0.0。

示例如下,修改 main.ts 文件中的 bootstrap() 函数:

import { NestFactory } from '@nestjs/core';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );
  await app.listen(process.env.PORT || 3000, '0.0.0.0');
}
bootstrap();

如果你想了解更多关于 Fastify 的内容,点击查阅相关文档

参考资料

  1. How to write a NestJS Dockerfile optimized for production - www.tomray.dev/nestjs-dock… 。

大家加油:)