🐭

【Node.js/Next.js】Cloud Runで動作する軽量なDockerを構築してみた

2021/06/04に公開

概要

本記事では、Next.jsをコンテナをサーバーレスで実行するサービスであるCloud Runで動作する軽量のDocker環境構築について紹介します。
ネットにある様々な記事を見てきましたが、動作目的でDockerイメージが大きくなっており、パフォーマンスとセキュリティに課題がありました。そこでDockerの軽量化および最適化を試みました。

Docker環境

シングルステージビルド(slim)

シングルステージビルドしたDockerイメージです。本番環境に必要無いデータを多く含んでいるため、イメージサイズも大きくなっています。構成はシンプルのため、理解しやすいですがパフォーマンス/セキュリティ面には課題があります。

Dockerfile
FROM node:14.17.0-slim
WORKDIR /app

COPY package.json yarn.lock ./
RUN yarn --frozen-lockfile

COPY . .
RUN yarn build:next

EXPOSE 8080

CMD ["yarn", "start:next"]
package.json
package.json
"scripts": {
    "start:next": "next start -p 8080",
    "build:next": "rm -rf .next && next build"
}

マルチステージビルド(slim)

Dockerのマルチステージビルドを使用したイメージです。マルチビルドステージを使用することで、ランタイムに必要なデータだけになり、シングルステージビルドに比べて大幅にイメージサイズを削減できました。個人的にはこれでも十分だったのですが、まだ軽量化の余地があったので、もうひと踏ん張りしてみました。(楽しくなってきた)

https://matsuand.github.io/docs.docker.jp.onthefly/develop/develop-images/multistage-build/

Dockerfile
#==================================================
# Base Layer
FROM node:14.17.0-slim AS base
WORKDIR /app

COPY package.json yarn.lock ./
RUN yarn --frozen-lockfile
COPY . .

#==================================================
# Build Layer
FROM base AS build
ENV NODE_ENV=production
WORKDIR /build

COPY --from=base /app ./
RUN yarn build:next

# ==================================================
# Package install Layer
FROM node:14.17.0-slim AS node_modules

WORKDIR /modules

COPY package.json yarn.lock ./
RUN yarn install --non-interactive --frozen-lockfile --production

# ==================================================
# Production Run Layer
FROM node:14.17.0-slim
ENV NODE_ENV=production
WORKDIR /app

COPY package.json yarn.lock next.config.js ./
COPY --from=build /build/public ./public
COPY --from=build /build/.next ./.next
COPY --from=node_modules /modules/node_modules ./node_modules

EXPOSE 8080

CMD ["yarn", "start:next"]
package.json
package.json
"scripts": {
    "start:next": "next start -p 8080",
    "build:next": "rm -rf .next && next build"
}

マルチステージビルド(distroless)

前述と同様、Dockerのマルチステージビルドを使用しています。構成もほとんど変わりませんが、RunLayerをslimからdistrolessに変更しています。distrolessにはyarnが含まれていないため、カスタムサーバーから立ち上げる方法に変更し、yarnに依存しないようにしています。

Distrolessとは?

Distrolessは、Googleが提供しているDockerイメージです。動作に最低限な依存のみ含まれているため、非常に軽量なイメージです。最小の構成になっているため、本番環境向きでパフォーマンスとセキュリティにも優れています。

https://github.com/GoogleContainerTools/distroless

Dockerfile
#==================================================
# Base Layer
FROM node:14.17.0-slim AS base
WORKDIR /app

COPY package.json yarn.lock ./
RUN yarn --frozen-lockfile
COPY . .

#==================================================
# Build Layer
FROM base AS build
ENV NODE_ENV=production
WORKDIR /build

COPY --from=base /app ./
RUN yarn build

# ==================================================
# Package install Layer
FROM node:14.17.0-slim AS node_modules

WORKDIR /modules

COPY package.json yarn.lock ./
RUN yarn install --non-interactive --frozen-lockfile --production

# ==================================================
# Production Run Layer
FROM gcr.io/distroless/nodejs:14
ENV NODE_ENV=production
WORKDIR /app

COPY package.json yarn.lock next.config.js ./
COPY --from=build /build/public ./public
COPY --from=build /build/.next ./.next
COPY --from=build /build/dist ./dist
COPY --from=node_modules /modules/node_modules ./node_modules

EXPOSE 8080

CMD ["dist/index.js"]
package.json
package.json
"scripts": {
    "start": "node dist/index.js",
    "build": "npm run build:next && npm run build:server",
    "build:server": "rm -rf dist && tsc --project tsconfig.server.json",
    "build:next": "rm -rf .next && next build"
}
server/index.ts
server/index.ts
import express, { Request, Response } from 'express'
import next from 'next'

const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
const port = process.env.PORT || 8080

app.prepare().then(() => {
  const server = express()

  server.use((req: Request, res: Response) => handle(req, res))

  server.listen(port, (err?: any) => {
    if (err) throw err
    console.log(`> Ready on http://localhost:${port}`)
  })
})

追記(2021/06/07)

internetkunさんよりご指摘頂きました。ありがとうございます。以下にサーバー立ち上げの別方法を追記します。node_modulesにはnextが内包されているため、node_modules/.bin/next startで起動することができます。よって、パフォーマンスに影響がでるカスタムサーバーは特別な理由がない限りは、使用を控えると良さそうです。

Before deciding to use a custom server please keep in mind that it should only be used when the integrated router of Next.js can't meet your app requirements. A custom server will remove important performance optimizations, like serverless functions and Automatic Static Optimization.

Dockerfile
+ RUN yarn build:next
- RUN yarn build

- COPY --from=build /build/dist ./dist

+ CMD ["node_modules/.bin/next start"] 
- CMD ["dist/index.js"]

軽量化の結果

まず、今回の軽量化でどの程度イメージサイズを削減できたかですが、以下の通りです。(プラグイン入れているので、元の容量がやや大きくなってます)

イメージ サイズ 削減率
シングルステージビルド(slim) 917.52 MB -
マルチステージビルド(slim) 297.91 MB 67.5%
マルチステージビルド(distroless) 251.02 MB 72.6%

シングルステージビルドからマルチステージビルドへの変更、distrolessの活用により軽量化前と比べてイメージサイズを約70%削減でき、デプロイ時間の短縮とセキュリティの向上を実現できました。

まとめ

今回、軽量化を実施してみてシングルステージビルドには必要無いデータが多く含まれていることが分かりました。今後はマルチステージビルドを活用してパフォーマンスとセキュリティに優れた軽量なDocker環境を構築できればと思います。

少々長くなりましたが、本記事を最後まで読んで頂き、ありがとうございました!
まだまだエンジニアとしては経験が浅いので、ご助言等ありましたらコメントいただけると幸いです。

更新履歴

  • マルチステージビルド(distroless)の一部を更新(2021/06/07)

関連記事

https://zenn.dev/kazumax4395/articles/643ffc25d3f803
https://zenn.dev/kazumax4395/articles/66ec5c259c950c

参考記事

https://zenn.dev/necocoa/articles/nestjs-docker
https://nextjs-ja-translation-docs.vercel.app/docs/advanced-features/custom-server

Discussion