【NestJS】マルチステージビルドでNestJSイメージのサイズを小さくする方法

## はじめに
Dockerイメージのサイズを見ちゃったりしてると玄人感出ますよね...
『目指せ、玄人！』ということで、NestJSでのマルチステージビルドについてまとめます📝

## マルチステージビルドとは...
ざっくりいうと「ビルドするステージとランタイムで使用するステージを分けて記述する方式」です。
これにより、Dockerイメージを軽量化することができます！
[公式](https://matsuand.github.io/docs.docker.jp.onthefly/develop/develop-images/multistage-build/)

## 結論
私が考えたNestJS用のdockerfileはこちらです↓↓

```dockerfile
FROM node:16.17.0 AS dist

WORKDIR /tmp

COPY ./app ./

RUN yarn install \
    && yarn build


FROM node:16.17.0 AS module

WORKDIR /tmp

COPY ./app ./

RUN yarn install --prod


FROM node:16.17.0-alpine

WORKDIR /usr/app

COPY --from=dist /tmp/dist ./dist
COPY --from=module /tmp/node_modules ./node_modules

EXPOSE 3000

CMD ["node", "dist/main"]
```

最終的に生成されたコンテナには、`dist`と`node_modules`しか存在しません。


---

### ここから順を追って解説
## ディレクトリ構成
```bash
$ tree -L 2
.
├── README.md
├── app
│   ├── README.md
│   ├── nest-cli.json
│   ├── node_modules
│   ├── package.json
│   ├── src
│   ├── test
│   ├── tsconfig.build.json
│   ├── tsconfig.json
│   └── yarn.lock
└── dockerfile
```

`nest new app`を実行して、`dockerfile`を配置しただけの状態からスタートします💨
NestJSを立ち上げて、`localhost:3000`にアクセスすると、`Hello World!`と返ってきます。




## dockerfile1
#### とりあえず動くコンテナ編

```dockerfile:dockerfile1
FROM node:16.17.0

WORKDIR /usr/app

COPY ./app ./

RUN yarn install
RUN yarn build

EXPOSE 3000

CMD ["yarn", "start:prod"]
```

めちゃくちゃよくありそうな`dockerfile`。こちらでもちゃんとNestJSは動作します😺


## イメージ確認1
```bash
$ docker build -t image:1 .
```

```
$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
image        1         6d8ba6caed1c   46 seconds ago   1.49GB
```

```bash
$ docker run --name nest -p3000:3000 --rm -d image:1
beb048664d49cf9997d85ead5844859d72e98dd73fc8f6b68fb6d9ed350ffe78
$ curl http://localhost:3000
Hello World!
```
※ 以後、curlでの確認は割愛します🙇‍♂️


一応動きますが、`1.49GB`という激重コンテナが爆誕したわけです💣
ディレクトリ配下のすべての資源をDockerイメージに含めていることが主な原因です。コンテナの中を覗いてみると、ランタイムではいならい資源がたくさんあることがわかります。

```
$ docker run --rm image:1 ls
README.md
dist
nest-cli.json
node_modules
package.json
src
test
tsconfig.build.json
tsconfig.json
yarn.lock
```

ここから軽量化に向けて、少しずつ改善していこうと思います🤸‍♀️


## dockerfile2
#### マルチステージビルド編

```dockerfile:dockerfile2
FROM node:16.17.0 AS build

WORKDIR /tmp

COPY ./app ./

RUN yarn install
RUN yarn build


FROM node:16.17.0

WORKDIR /usr/app

COPY --from=build /tmp/dist ./dist
COPY --from=build /tmp/package.json ./
COPY --from=build /tmp/node_modules ./node_modules

EXPOSE 3000

CMD ["yarn", "start:prod"]
```

出ました！マルチステージビルド🐳
NestJSのビルドのステージとランタイム実行用のステージを分離して記述しています。`AS build`でステージに名前をつけることができ、`--from=build`でそのステージを参照することができます！


## イメージ確認2
```
$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
image        2         ecaa05bb4a9a   2 minutes ago    1.2GB
```

お！！少しだけですが、軽くなりました！(`1.49GB`→`1.2GB`)
少ししか軽くなっていないと感じるかもしれませんが、アプリケーション開発が進むと、`src`と`test`の容量がすぐに増えて、マルチステージビルドをしていないとどんどんDockerイメージが大きくなります。

## dockerfile3
#### prod用のnode_modulesを作成して、package.jsonすらコンテナに含めないようにする編
```dockerfile:dockerfile3
FROM node:16.17.0 AS dist

WORKDIR /tmp

COPY ./app ./

RUN yarn install \
    && yarn build


FROM node:16.17.0 AS module

WORKDIR /tmp

COPY ./app ./

RUN yarn install --prod


FROM node:16.17.0

WORKDIR /usr/app

COPY --from=dist /tmp/dist ./dist
COPY --from=module /tmp/node_modules ./node_modules

EXPOSE 3000

CMD ["node", "dist/main"]
```

`yarn install`は`--prod`オプションをつけると、`devDependencies`のモジュールを`node_modules`に含めないという挙動をとります。
ステージを2つに分けて、それぞれの成果物をランタイム用のコンテナに配置することで、より軽量なコンテナを作成することができます！
ついでに、`package.json`のコマンドを`CMD`に直接書くようにしました。そうすることで、ランタイムでは`package.json`がいらなくなります。


#### 注意
以下に`--prod`オプションを付ければいいと思うかもしれませんが、そうすると`yarn build`でコケます...
NestJSのcliは、`devDependencies`に存在しているためです。
```dockerfile
RUN yarn install \
    && yarn build
```

## イメージ確認1
```
$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED             SIZE
image        3         a410579410f9   29 seconds ago      921MB
```

だいぶ軽くなりましたね！(`1.2GB`→`921MB`)
`node_modules`の`devDependencies`がいかに重いかがよくわかります💥



## dockerfile4(おまけ)
#### そもそも軽いnodeイメージを使うべき編
```dockerfile:dockerfile4
FROM node:16.17.0 AS dist

WORKDIR /tmp

COPY ./app ./

RUN yarn install \
    && yarn build


FROM node:16.17.0 AS module

WORKDIR /tmp

COPY ./app ./

RUN yarn install --prod


FROM node:16.17.0-alpine

WORKDIR /usr/app

COPY --from=dist /tmp/dist ./dist
COPY --from=module /tmp/node_modules ./node_modules

EXPOSE 3000

CMD ["node", "dist/main"]
```

`node`イメージはdebian系の結構重めのイメージなので、ランタイムではもっと軽量なイメージを使いましょう！
用途に合わせて軽いイメージを選定してもらえるといいかなと思います！

```
REPOSITORY   TAG                   IMAGE ID       CREATED             SIZE
node         16.17.0-alpine        5dcd1f6157bd   9 days ago          115MB
node         16.17.0-buster-slim   af57506e8951   3 days ago          179MB
node         16.17.0-slim          af57506e8951   3 days ago          179MB
node         16.17.0               c8af85aa3027   3 days ago          910MB
```

また、マルチステージビルドのイメージ(`node`)は、軽量のものを使うかはおまかせします。最終的には破棄されるので、Dockerイメージのサイズには影響しません。ただ、イメージのpullに時間はかかるので、できる限り軽量のものを使うことをおすすめします。


## 動作確認4
```
$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED             SIZE
image        4         91483413dff1   2 minutes ago       125MB
```

マルチステージビルドの前にこれやりなよって話ですね...(`921MB`→`125MB`)
bashすら入っていない超軽量イメージなので、コンテナの中に入って色々やりたい時はちょっと困るかもしれません。


## まとめ
マルチステージビルドによって、Dockerイメージを軽量化することができました！
マルチステージビルドの導入によって、`1.49GB`→`921MB`になりました！
イメージが軽くなると、ECRへのpush、ECSのデプロイが早くなるので開発速度が上げられると思います！
この機会に皆さんのdockerfileを見直してみてください🙇‍♂️