【🪙中級者向け】改めてDockerを深ぼる② ~ Dockerfileのレイヤーを理解しよう ~

## はじめに
こんにちは！ITスクールRareTECHにてCS（Customer Support）を担当している池村です。今回の記事は、Docker中級編②ということで、**Dockerfileについてもっと理解していきましょう**。といった内容です。初心者向けの記事で、軽く概要を書いているので、まずはそちらを読んでからこちらの記事を読んでいただけますと幸いです。

https://qiita.com/ikemura-ren/items/3debbe2ba8997a95e6b5

![248839f1-b857-4eed-bfb6-bef59ec85c0e.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3798971/101bd94c-3c51-4a0b-bd93-213f4e5de6e0.png)


## Dockerfileってそもそも何？
Dockerfileって不思議ですよね。拡張子ついてないけど、ファイルではあるし。
結論から言うと、Dockerfileはテキストファイルに該当します。

### 拡張子について改めて

本来、テキストファイルには`.txt`をつけるわけですが、初心者の頃はこの拡張子が大事なものと誤解しやすいです。拡張子はあくまで、**人間がファイルを識別しやすくする**もの、かつ**エディタなどがファイルを識別する**ためについてたりします。

```Python:拡張子がhtmlだろうと、機械はファイルの中身を見る
> python3 test.html           
test
```
:::note warn
Javaなどは、JVMが拡張子も見ているので実行できなかったりします。
:::

### Dockerfileはどう読み込まれているのか？
#### パース（解析）
Dockerfileの場合、Docker Engineが中身を解析して、1行1行見ていく作業をします。
この段階では、まだ書かれているコマンドなどを実行しているわけではありません。
もちろん、ここで構文が間違っていたらエラーが出ます。

```Bash:構文エラーはこんな感じ
> docker build .
[+] Building 0.1s (1/1) FINISHED                                                                                                                                           docker:orbstack
 => [internal] load build definition from Dockerfile                                                                                                                                  0.0s
 => => transferring dockerfile: 76B                                                                                                                                                   0.0s
Dockerfile:1
--------------------
   1 | >>> FROOM python:3.9
   2 |     RUN pip install flask
   3 |
--------------------
ERROR: failed to solve: dockerfile parse error on line 1: unknown instruction: FROOM (did you mean FROM?)

```

#### 実行
パースが終わったら、今度は1行1行実行をしていきます。
ここでやっているこを細かく見ていきましょう。
今回は以下のようなシンプルなDockerfileを準備します。
```Dockerfile:サンプルDockerfile
FROM python:3.9
COPY app.py /app.py
RUN pip install flask
```

#### 1つ目のコマンドの実行
ここは`FROM`で書かれたベースイメージを準備するのが目的なので、DockerHubからPython3.9のイメージをとってきます。ローカルにこのイメージがすでにある場合は、それを使います。**基本コマンドでいうところの`pull`が使われています**。

#### スナップショットを取る①
ここでレイヤーという概念が登場します。（後ほど詳しく）
ここまでのコンテナの状態を保存しているようなものです。Python3.9のイメージを持ってくる、までを保存しています。

#### 2つ目のコマンドの実行
`COPY`なので、ローカルにある`app.py`をコンテナの中の`/`ディレクトリにコピーしています。これはFlaskでアプリを実行する際の起点となるファイルですね。

#### スナップショットを取る②
再度ここでレイヤーを作ります。
Dockerfileでは、コマンドを実行するたびに、スナップショットを取っていきます。

#### 3つ目のコマンドの実行
最後に`pip`コマンドで必要なライブラリのインストールですね。

#### スナップショットを取る③
ここで最後のスナップショットを取って終了です。

### まとまったイメージが作られる
実行が完了すると、先ほどまでのレイヤーが積み重なってイメージが完成になります。

:::note info
ここまでがイメージができるまでの流れになります。細かなところはこの後に説明していきますので、しっかり覚えていきましょう。
:::



## レイヤーについてもっと詳しく
さて、レイヤーやスナップショットなど、エンジニアなら聴き慣れている言葉ですが、まだ学習し始めた人はわからない言葉だと思います。

### スナップショット
まずはスナップショットですが、これはエンジニアをしていると色々なところで出てきます。広義の意味で『**特定の瞬間の状態をまるごと保存すること**』になります。

:::note info
カメラでパシャっと写真をとる感じですね。
:::

例えばデータベースでは、中身の状態を特定の瞬間で凍結して保存しておいたりできます。サーバーなども同様で、ディスク上のファイルの状態を特定時点で保存することができます。

![a5245c5b-8839-4e5a-859c-b966146d2953.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3798971/18e8f1c7-d975-4fc1-8325-5eaa2898dbf1.png)


#### 余談：スナップショットとバックアップの違い
スナップショットとバックアップは違います。
スナップショットを取っているから安心というわけではなく、**元のサーバー等が吹っ飛べば、スナップショットも吹っ飛びます**。

逆にバックアップは、異なるサーバー等にコピーしている状態のことです。
用意する手間や時間はかかりますが、本当に安心なのはバックアップの方です。

:::note info
結局、スナップショットも取って、バックアップも準備する、が吉です。
:::


### レイヤー
Dockerfileの場合は、各コマンドが実行されるたびに、その状態を保存しています。これは、コンテナが何度もビルドすること前提の仕様で、何度もビルドして同じコマンドを毎回実行するのは効率が悪くなります。ですので、イメージを高速に作成するために必要な動作です。

レイヤーは、ITの他の分野だと概念的に話されることが多い印象です。
例えばOSI参照モデルの7つのレイヤーなどですね。これらの考え方と近いものではありますが、Dockerの場合は、レイヤーにしっかり実態があります。

レイヤーを簡単に言うと、
#### ファイルシステムのある時点での変化・差分を保存した実体

になります。
実体なので、要は***ファイル***です。ファイルシステムの差分を記録したものがファイルとして残り、Dockerfile内の各コマンドごとに保存されたレイヤーが積み重なって、イメージになります。イメージには、レイヤーがどう積み上がってできているかの情報も含まれています。

:::note info
なので、コンテナのイメージというのは、**パッケージ（小包）のようなもの**で、中に各レイヤーと順番情報が入っていて、Docker Engineがその箱を開けて順番通りに実行していることになります。、
:::


## レイヤーを実際に見てみよう
今回は以下のファイルをディレクトリに作って、どんなレイヤーになっているか確認していきます。
```Dockerfile:先ほどと一緒
FROM python:3.9
COPY app.py /app.py
RUN pip install flask
```

同じディレクトリにapp.pyも作ります。
```Python:app.pyの中身
from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return 'Hello World'

if __name__ == "__main__":
    app.run(debug=True)

```

:::note warn
同じディレクトリにDockerfileとapp.pyがある状態にしてください。
:::

ではこの状態で、ビルドを行います。
```Bash:ターミナルでビルドを実行
docker build -t flask_test_docker .
```
これでビルドは完了です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3798971/f58736d2-567b-4bd2-b029-2f0d0cff1da0.png)

```Bash:イメージができているか確認
docker image ls
```
`flask_test_docker`というイメージができていればOKです。

では今度はそれの中身を確認していきましょう。

```Bash:今までのレイヤーを確認
docker history flask_test_docker
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3798971/3f09d5dc-2a64-4d02-b0d2-2eae53c22bf5.png)

さて、色々出てきましたね。
ここで見てほしいのは**上の2行**です。これは先ほどDockerfileで作成したレイヤーが書かれています。その前のものは、このベースイメージを作った人たちが積み重ねたレイヤーです。

![fad01565-112c-469b-bc13-473cd74e7355.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3798971/94e47a39-6f7c-4dea-9e4b-f0a49c56a380.png)


:::note info
我々コンテナを使うユーザーは、ベースイメージ製作者の重ねたレイヤーに、追加でレイヤーを乗せているということになります。
:::

さて、それぞれのレイヤーには、バイト数が書かれています。
これが積み重なって、最終的なイメージの重さが決まるんですね。

## レイヤーがあると何が嬉しいの？
レイヤーがたくさんあることで、次回のビルドが高速になります。
これは**キャッシュ**と呼ばれる機能が働いていて、レイヤーが作られるたびにキャッシュも持つようになります。キャッシュは色々なところに使われているので、今回はDockerビルドキャッシュについて解説していきます。

### ビルドキャッシュって？
簡単にいいますと、
#### 前回ビルドしたときに作ったイメージ・レイヤーを、また使えるなら使っちゃおう！

です。なので、**2回目以降のビルドが高速になる仕組み**が備わっています。
例えば、app.pyの中身を

```Python:app.pyの中身に追記
from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return 'Hello World'

@app.route('/test') ←3行分追記
def test():
    return 'Hello Test'

if __name__ == "__main__":
    app.run(debug=True)

```
この状態で、再ビルドしてみると、Dockerfileのベースイメージが書かれたFROM文はキャッシュが働いて、前にビルドした時の結果をそのまま使ってくれます。


```Dockerfile:現在の形だとあまりキャッシュの意味はない
FROM python:3.9  ←ここはキャッシュが働く
COPY app.py /app.py ←ここから下は再ビルド
RUN pip install flask
```

ただ、app.pyを変更した影響で、2行目以降は再度ビルドされます。ここには少し工夫が必要です。

:::note warn
Dockerfileでは、新しくビルドが必要な行から下は、再度ビルドするような仕組みが備わっています。古いキャッシュによってバグが出ないような安全設計です。
:::

でも、3行目の`RUN`は同じことやっているし、できればキャッシュ動いてほしい。となった場合です。

```Dockerfile:書き換える
FROM python:3.9
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app.py .
```
この場合は、インポートする`Flask`を`requirements.txt`に書いておいて、先にビルドしています。このおかげで、`app.py`だけを書き換えるなら、5行目以前は全てキャッシュが働いて、次回以降のビルドが高速になってくれます。





## より軽量なイメージを作成する
さて、イメージはより軽量であれば良いとされています。それはなぜか？
1. イメージが軽量だと`build`が速い
1. ディスクを圧迫しない
1. CI/CDなどのクリーンビルド時の速度に影響

:::note warn
CI/CDなどではキャッシュ機能はあるにはありますが、基本的にはクリーンビルドが推奨だと思います。
:::

### 軽量にするテクニック
#### ① よく変更がかかるファイル等は最後の方に書く
これは先ほど紹介した例ですね。
```Dockerfile:悪い例
COPY . .
RUN pip install -r requirements.txt
```
よりも
```Dockerfile:良い例
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
```

の方が、コードを変更してもその部分だけが再ビルドされるので速くなります。

#### ② `RUN`命令をまとめる
こちらは`RUN`で実行するコマンドを1行にまとめてしまう形になります。
```Dockerfile:悪い例
RUN apt-get update
RUN apt-get install -y git
RUN apt-get install -y curl
```
よりも

```Dockerfile:良い例
RUN apt-get update && \
    apt-get install -y git curl && \
    rm -rf /var/lib/apt/lists/*
```

この最後の`rm -rf /var/lib/apt/lists/*`は、インストールしたパッケージのリストを削除しています。一度インストールしたら、これらを使う機会はもうないので、綺麗にして容量を削減しています。

#### ③ マルチステージビルドを使う
Dockerfileにはマルチステージビルドという機能があります。
```Dockerfile:マルチステージビルド
# 開発時
FROM golang:1.20 as builder
WORKDIR /app
COPY . .
RUN go build -o app

# 本番
FROM alpine:latest
COPY --from=builder /app/app /app
ENTRYPOINT ["/app"]

```

開発時は重い環境を使いつつ、本番では軽量な状態でデプロイする方法です。
ただ、上記は例であり、マルチステージビルドに関してはしっかり別記事で紹介する予定です。


## おわりに
今回の記事はDockerfileのレイヤーについて紹介しました。
これを知っているかいないかで、Dockerfileのベストな書き方に辿り着く速度が変わってきます。軽量で悪いことはないですからね。特に重いアプリはより軽量にしていかないと、起動に1時間かかるとか笑えないです。

## 余談
ちょっと久しぶりの記事になってしまいましたが、やはり書いている時は楽しいですね。