Dockerイメージの最適化

こんにちは、
アプリケーションエンジニアとして働いてる、キムです。
今回は自分が作成、運営中のシステムのDockerイメージの最適化、イメージの軽量化のコツについていくつか方法を共有したいと思います。

確か、この方法以外により良い方法もあると思いますので、なにかあればコメントとかで残していただけると嬉しいです。

## 入る前に
アプリケーションをDocker Containerで作って使う時、ちゃんと気にしないと作成されたDockerイメージの容量が考えたことより大きくてサーバーにデプロイするときや、共有が難しくなるかもしれません。

しかし、下記の流れをみてDockerイメージ容量に影響を与える部分をどうやって回避するのかを理解できれば、より軽量のイメージを作られると思います。

今日はその方法の中、自分が経験し適用してみたコツを共有しようと思います。

## やってみましょう！
それでは比較のために何も気にせずDocker Imageを作ってみましょう。
自分は今回Nuxtでプロジェクトを作ってみました。

```bash
$ yarn create nuxt-app qiita_test
yarn create v1.22.17
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...

success Installed "create-nuxt-app@4.0.0" with binaries:
      - create-nuxt-app

create-nuxt-app v4.0.0
✨  Generating Nuxt.js project in qiita_test
? Project name: qiita_test
? Programming language: JavaScript
? Package manager: Yarn
? UI framework: None
? Nuxt.js modules: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Linting tools: ESLint, Prettier
? Testing framework: Jest
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Server (Node.js hosting)
? Development tools:
? Continuous integration: GitHub Actions (GitHub only)
? What is your GitHub username? seongjoo kim
? Version control system: Git
```

これでPJが作成されました。次は`Dockerfile`を作成して下記のように書いてみましょう。
本当に基本的な、何も考えず基本的な処理を行うDockerfileになります。

```Dockerfile
FROM node:16

WORKDIR /app
COPY . /app
RUN yarn global add webserver.local
RUN yarn install & yarn build

EXPOSE 3000
CMD webserver.local -d ./.nuxt
```

コード作成が終わったら下記のコマンドでイメージを作ってみましょう。

```bash
$ docker image build . --tag=demo-docker
```

このコマンドで、イメージが作成されたら内容を確認してみましょう。

```bash
$ docker images demo-docker
REPOSITORY    TAG       IMAGE ID       CREATED          SIZE
demo-docker   latest    c6d2943f9cf0   18 seconds ago   1.13GB
```

なんと…1.13GB…！これはすごいですね。

## Docker Imageの軽量化
このすごい容量をどうすれば軽量化できますかね。
この問題を解決するに自分は２つの方法でアプローチしてみました。

### 1.より軽いベースイメージを選択すること
Dockerイメージは基本ベースイメージを元にし、その上にアプリやパッケージを積み上げていく方法になります。
このベースイメージを我々が普段知っているサーバーOS（CentOS,Ubuntu）をベースとして作ると、Docker環境での不要なパッケージや、ファイルも含まれていて自然に容量が高くなります。 
（一般的にサーバーにOSをインストールすると、かなり容量を食いますね？Dockerを使うかそうかも結局サーバーにOSにをインストールすることと同じ概念です。）

なので、この問題点を解決するためのDockerのためのOSがつくられて、その中に代表的なものが[Alpine](https://hub.docker.com/_/alpine)でございます。

`Alpine`はDockerのためのLinuxのDistributeであります。
（一般的なOSをDocker上で軽く使いたく、カスタマイジングされたものになります。）

それでは、上記のDockerfileをAlpineをベースに修正してBuildし直してみましょう

```Dockerfile
FROM node:16-alpine

WORKDIR /app
COPY . /app
RUN yarn global add webserver.local
RUN yarn install & yarn build

EXPOSE 3000
CMD webserver.local -d ./.nuxt
```

どうでしょうか？！

```bash
REPOSITORY    TAG       IMAGE ID       CREATED          SIZE
demo-docker   latest    1ce499968dde   15 seconds ago   369MB
```

なんと！1.13GBから、369MBになりました。
でも、私はこれで満足できません！！
もう少し減らしてみましょう！

### 2.Multi-stage Build
これは、Buildする時、Build処理だけ必要な作業をわけてBuildを行い、最終Containerイメージには追加しない方法になります。
つまり、Buildステップを分けて、不要なものを結果物には含まれないように作る方法になります。

Dockerfileを下記のように修正してみましょう

```Dockerfile
FROM node:16-alpine AS build
WORKDIR /app
COPY . /app
RUN yarn install & yarn build

FROM node:16-alpine
WORKDIR /app
RUN yarn global add webserver.local
COPY --from=build /app/.nuxt ./.nuxt
EXPOSE 3000
CMD webserver.local -d ./.nuxt
```

Buildのステップを2つに分けました。
Build環境セットアップ及びBuild後、実際Buildされた結果だけウェブサーバーで使うようにします。

Build結果は下記のようになります。
更に減りましたね！

```bash
$ docker images demo-docker
REPOSITORY    TAG       IMAGE ID       CREATED          SIZE
demo-docker   latest    fe177f7962d8   37 seconds ago   119MB
```

ここまで来たら、なにかもう少し物足りないな…と思うと思います。
それはウェブサーバーをなぜ`webserver.local` を使ってるのかです。

我々にはもう少し軽く、早い`Nginx`と`Apache`があるのにです！

つぎはこれは`Nginx`に書き換えてみましょう

```Dockerfile
FROM node:16-alpine AS build
WORKDIR /app
COPY . /app
RUN yarn install & yarn build

FROM nginx:stable-alpine
COPY --from=build /app/.nuxt /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```

じゃぁ、最後の結果を見ましょう
どうでしょうか！？

```bash
$ docker images demo-docker
REPOSITORY    TAG       IMAGE ID       CREATED          SIZE
demo-docker   latest    3d3251e6baf7   56 seconds ago   23.8MB
```

最初に始まるときに1.19GBだったことを覚えてますでしょうか？
今まで、修正してきたことを見売ればわかりますが、本当に小さな修正がここまで容量の差分を作り出します。

## おまけ.1
普段は、このAlpine Linux Dockerイメージで始まることをおすすめします。
その理由としては、上記見たとおりにイメージのサイズが小さいからです。では、なぜ小さいでしょうか？
それは、Cライブラリーとして`musl`を使ってるし、いろんなUnixツールを持っている`busybox`をベースとしてるからです。

しかし、Pythonアプリケーションを作る時にはAlpineをおすすめしません。
その理由は[Using Alpine can make Python Docker builds 50× slower](https://pythonspeed.com/articles/alpine-docker-python/)こちらを読んでみてください。
この記事をざっくり略してみると、

- PyPIに上げたPythonライブラリーは普段`Wheel`フォーマットを使いますが、Alpine Linuxは`Wheel`フォーマットをサポートしてないので、ソースコード(.tar.gz)をわざとダウンロードして直接コンパイルする場合があるからです。
- `Wheel`フォーマットをサポートしてない理由は`musl`ライブラリーがGNU Cライブラリー（glibc)としてコンパイルされた`Wheel`バイナリーをサポートしてないからです。なのでPyPIをつかって`.whl`として作られたパッケージではなく、ソースコード（.tar.gz)をダウンロードすることです。
- `Wheel`フォーマットはPythonでインストールなしで直接実行できるメリットがあります。その理由は`Wheel`フォーマットそのものが、`zip`圧縮から拡張子だけ変えたものであり、PythonはJavaの`.jar`のように`zip`ファイルをすぐに実行できるからです。

なので、Apline Linuxを使うのであれば、すべてのPythonパッケージからCコードをコンパイルしなきゃいけないので、イメージをBuildするには時間がかなりかかります。

```Dockerfile
FROM python:3.8-alpine
RUN apk --update add gcc build-base freetype-dev libpng-dev openblas-dev
RUN pip install --no-cache-dir matplotlib pandas
```

このようなイメージをBuildするには自分の環境だと30分ぐらいかかりました。
ついでに、Alpine LinuxをベースとしたPythonイメージは容量もそこそこあるし、いくつかの脆弱性も発見されてるいます。

結論を言うと、Pythonアプリケーションを使うときにはDebian Busterをベースとした`python:3.8-buster`または、`3.8-slim-buster`などを使うことをおすすめします。
※参考：[The best Docker base image for your Python application (August 2021)](https://pythonspeed.com/articles/base-image-python-docker-images/)

## おまけ.2 その他のTIPS
##### イメージレイアの数を減らしましょう
過去、Dockerのバージョンではイメージレイア数がパフォーマンスに影響をかけたと言われてます。
しかし、もう大丈夫です。

それでも、Dockerのレイアー数を減らすことは最適化的な面では役に立つと思います。
レイアーは`RUN`,`ADD`,`COPY`コマンドだけで作られるので、下記のように複数の別れてるコマンドをChainingしてつなげてみましょう
レイアー数を少なくすることでDockerイメージ、Containerのパフォーマンスに影響があるわけではないですが、Dockerfileの可読性や、メンテナンス面では役に立つと思います。

```Dockerfile
RUN apt-get update
RUN apt-get -y install git
RUN apt-get -y install locales
RUN apt-get -y install gcc
↓
RUN apt-get update && apt-get install -y \
    gcc \
    git \
    locales
```

このように作成することで、既存４つのレイアーが作られたことを一つのレイアーとして減らせます。
そして、これをやりながら、インストールするパッケージの順番をアルファベット順に書き換えたりすることで、可読性も高く、重複インストールを防止できる効果もあります。

##### アプリケーションコードをコピーする部分は下へ
下記のサンプル`Dockerfile`には`COPY`が２回実行されます。
１回目は依存性パッケージが明示されたファイル、２回目はアプリケーションコードが保存されてるディレクトリーであります。

```Dockerfile
FROM python:3.8-slim-buster

WORKDIR /app

COPY requirements.txt /app
COPY django_project /app

RUN pip install -r requirement.txt

CMD ["pip", "freeze"]
```

通常、依存パッケージは頻繁に変わらないため、最初の`COPY`命令で作成されたレイヤーはキャッシュされます。
しかし、2番目の`COPY`はビルドするたびに変わることができるため、キャッシュが頻繁に初期化され、次に実行される`RUN`コマンドで実行される依存パッケージのインストールが毎回実行される可能性が高いです。
このようになると、不必要にビルド時間が増えて、やや依存性パッケージバージョンを`latest`や`*`にしておけば、予期せずパッケージバージョンが上がることがります。

したがって、アプリケーションコードコピーコマンドは、頻繁に変更されないステートメントの後に来ると、イメージの構築時間を短縮するのに有利です。

##### プログラミング言語ごと、パッケージマネージャが提供するLockファイルを使うこと
前の説明で依存パッケージを指定したファイルとして`requirements.txt`を使用しました。 このファイルは、Pythonの公式パッケージマネージャである`PIP`で使用される慣行的に呼ばれるファイルです。

Python開発環境に基づいてみると、`PIP`はパッケージ固有の相互依存関係への関係を管理するのに不十分な面があります。 だから最近では、より進化した`Locking`システムを備えた`Pipenv`や`Poetry`を使用することをお勧めします。

これらのツールを使用して生成されたロックファイル（Pipfileなど）に基づいてパッケージをインストールできるようにすることで、上記説明したキャッシュレイヤの利点を得ることができ、予期しないパッケージバージョンの更新を防ぐことができます。

```Dockerfile
FROM python:3.8-slim-buster

WORKDIR /tmp
RUN pip install pipenv

COPY Pipfile /tmp/Pipfile
COPY Pipfile.lock /tmp/Pipfile.lock

RUN pipenv install --system --deploy

CMD ["pip", "freeze"]
```

## 終わり
いかがでしょうか？
もう、Dockerはウェブアプリケーションの開発では当たり前な時期になりました。
Dockerfileの修正ははじめにアプリケーションを立ち上げるとき以外はなかなか手を入れづらいとおもいます。
しかし、このような技を身に着けておくと今後メンテナンスしやすいベースを作り出すと思います。
参考になったら嬉しいです。

## 参考資料
- [How to Optimize Docker Images for Smaller Size?](https://coderbook.com/@marcus/how-to-optimize-docker-images-for-smaller-size-and-speed/)
- [Using Alpine can make Python Docker builds 50× slower](https://pythonspeed.com/articles/alpine-docker-python/)
- [Best practices for writing Dockerfiles | Docker Documentation](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#minimize-the-number-of-layers)
- [https://medium.com/banksalad/docker-best-practices-8b4f28ab3a65](https://medium.com/banksalad/docker-best-practices-8b4f28ab3a65)