Dockerfileについての偏った理解
Dockerコンテナを使ったことがある人で、Dockerfileを聞いたことがない人はいないと思います。色々なWeb記事を見ていると、Dockerコンテナを使うのであればまずはDockerfileを用意して、、、という流れになっていることがほとんどのように見受けられます。
周りのエンジニアに聞いても、「Dockerfile=コンテナを使うためのもの」という話を聞くので、やはりこのような認識を持っている人は多いようです。
その認識にちょっと待った!
Dockerfileとは何か、それをビルドして作られるDockerImageとは何か。イメージを掴んでもらいつつ、その後実践までしてもらうのが今回の記事です。
なんとなくDockerfileを使っていたエンジニアの皆さんにはちょっと集まっていただき、今回の記事を読んでなんとなくDockerfileを使う現状を卒業しましょう。
Dockerコンテナグレートジャーニー
本記事は、シリーズものの第6回の記事となります。単体としても見ていただけますし、もっと基礎的なところから知りたいという方は、記事の最初の方の記事もぜひご覧ください。
旅路(インデックス)
- そもそも仮想化とは? 仮想化ではないシステムとは?
- Dockerコマンド基礎(環境構築~ubuntu/httpdコンテナでのコマンド実行)
- Dockerのストレージについて
- Dockerのネットワークについて
- Docker-compose でコンテナをまとめる
- Docker image と Dockerfile 【⇦本記事】
- AWS ECS で Dockerコンテナを走らせる(Comming soon)
【主題】DockerfileとDockerイメージとはなんなのか?
今回の記事での登場人物
今回の記事で出てくる単語と、その関連性について最初に概要を説明します。
図にするとこんな感じです。
【登場する概念】
- Dockerコンテナ
- 実態があり、実際に演算を行う。
- Dockerイメージ
- Dockerコンテナを作ることができる。金型でクッキーをくりぬくように、いくつでも同じコンテナを作れます。
- Dockerfile
- Dockerイメージの作成手順書。
【コマンド】
- Build
- Dockerfileに書いてある手順を実行し、イメージを作成する
- Commit
- 現在稼働中のコンテナの状態をそのまま保存し、イメージを作成する
Dockerイメージの2つの側面
Dockerイメージの役割をゲームのセーブデータに例えるのであれば、その役割に対する視点は2つにわけることができます。
- セーブデータを作成し、現状を保存する
- セーブデータを読み込んで複製し、ゲームを再開する
というイメージです。
1つ目の「セーブデータを作成し、現状を保存する」は、「Docker build」と「Docker commit」の機能に該当します。
2つ目の「セーブデータの読み込みと複製」はいわゆる「Docker run」に相当する機能です。
今回重要なのは1つ目の「セーブデータを作成し、現状を保存する」機能です。これを実現するには、上記で書いたようにDockerfileをBuildする
という方法とコンテナをCommitする
という2つの手段があります。
というわけで、これ以降では2つの方法でDockerイメージを作成することがどういうことか、イメージ図を交えての解説を行います。
Commitする(その場でセーブするようなもの)
ゲームとセーブデータとして例えてみましょう。
ゲームをプレイしている(Dockerコンテナを操作している)と、いい感じの場所まで進んだ時にデータを保存したくなりますよね。ゲームなら「保存する」というコマンドになりますが、Dockerコンテナではその操作がCommit
に該当します。
私はこのCommitについて、下の画像のようなイメージで考えています。
ゲームを好きなように動かしてたどり着いた場所でセーブをすれば、次回以降はその場所からゲームを再開できます。
ただし、元の場所からどのような過程をたどってセーブ地点までたどり着いたのかがわからないので、再び全く同じ道をたどって同じことをしろと言われてもできませんよね。ただただその地点でセーブする、それがCommitです。
DockerfileをBuildする(コマンド履歴を記録して、セーブする)
こちらはゲームをしているときに入力するコマンドをすべてメモしておくようなものです。
例えば以下のような感じです。
このようにコマンドさえメモしておけば、だれでも、何度でも、同じ結果を得ることができます。そしてBuild時にはすべてのコマンドを実行した直後の状態でセーブが行われます。こうすることで、行動履歴を残しつつ、その結果を保存することができます。
Dockerfileの場合は、BuildをするとDockerfileに書かれたコマンド通りに操作が行われ、その状態でデータが保存されてDockerイメージとなります。そして保存されたDockerイメージは、前回の続きから再開することができます。
これがコンテナという文脈で行われるDockerfileとDockerイメージの関係性と役割です。
DockerfileのBuildとコンテナのCommitは、何が違うか?
ここまでで、Dockerイメージを作成する2つの方法を見てきましたが、BuildとCommitでの一番大きな違いは『追跡性』です。
- Build
- 作成されたDockerイメージは「どのような手順で作られたか」がわかるようになっています。これによって、そのDockerイメージの概要や、危険なパッケージをインストールしていないか確認することができます。
- 加えて、Dockerfileさえあれば誰でも、どこでも、同じ環境を即時構築することができます。
- Commit
- 作業過程が失われているのでその過程を再現することができません。
- Commit後のDockerイメージにもコマンド履歴が残らないので、悪意のあるコードが挿入されていることを見抜けません。編集履歴のないサードパーティーのDockerイメージを使用するのは、セキュリティ上危険です。
というわけで、コンテナをCommitしてDockerイメージを作成する方法には、開発上のメリットはあまりありません。簡単に作業内容を保存できるので、ちょっとした検証用に使えるくらいでしょうか。実際の開発時は、DockerfileのBuildを通して保守を行っていきましょう。
使わないというのに今回Commitについて説明したのは、Build方式の動作も根本的にはCommitをしているようなものだからです。Commitの挙動を知っておくことで、Dockerが裏で何をやっているのか理解を深めることができるかと思います。
実践する
ここからは、実際にCommitとBuildそれぞれでDockerイメージを作ってみましょう。
今回は、Pythonを使えるイメージ(python:3.11.3-slim-buster)をDockerHubからダウンロードし、それにFastAPIをインストールして簡易サーバーを立てます。
準備
ワークスペース配下にmain.py を作成してください。
workspace
|-- main.py
今回はFastAPIで簡易サーバーを立てますが、この内容は本題とは逸れるので解説は省きます。
main.pyファイルに以下のコードをコピーして貼り付けてください。
from fastapi import FastAPI
app = FastAPI()
@app.get("/api/greet/{name}")
async def greeting(name: str):
return {"greeting": f"Hello {name}"}
Commitでイメージを作ってみる
さっそくコマンドを打っていきます。コンテナを作成しましょう。
# コンテナ作成
docker run -dit -p 28000:8000 --name fast-api-server python:3.11.3-slim-buster
# プログラムファイルの用意
docker container cp .\main.py fast-api-server:/usr/src/main.py
# コンテナ内に入る
docker exec -it fast-api-server /bin/bash
コンテナ内に入れたら、サーバーを起動しましょう。
# FastAPIをインストール
pip install fastapi uvicorn[standard]
# サーバーの起動コマンド。`--host 0.0.0.0 --port 8000`とすることで、すべてのネットワークインターフェースを通じて8000番ポートで受け付けるようにします。
uvicorn main:app --host 0.0.0.0 --port 8000
サーバーが起動したら、ブラウザを開いてhttp://localhost:28000/api/greet/<好きな文字列>
と入力して以下のように返ってくれば成功です。
これでFastAPIをインストールしたコンテナが完成しました。
それでは次に、このコンテナをCommitで保存します。
# docker commit <コンテナ名 or コンテナID> <任意のイメージ名>:<任意のVer>
docker commit fast-api-server my-fast-api-server:13.2
# 確認
docker image ls
> PS C:\Users\******> docker image ls
> REPOSITORY TAG IMAGE ID CREATED SIZE
> my-fast-api-server 13.2 915e8a36c9a7 2 minutes ago 184MB
なお、Verを13.2としているのに特に意味はありません。バージョン指定できるんだよ、ぐらいに考えてください(何も指定しなければLatestになる)。
Commitしたので、これにてイメージ化が完了です。先ほどのゲームの例に沿っていえばセーブポイントができた状態なので、次回はこのイメージを直接起動可能です。
紛らわしいので先ほど作成したコンテナを削除します。
docker container stop fast-api-server
docker container rm fast-api-server
それではcommitしたイメージを使って新しいコンテナを立ち上げます。今回はワンライナーで起動&プログラム実行まで行います。
# `-w`: ワークスペースを指定 `--rm`:コンテナ停止時に削除する
# `uvicorn main:app --host 0.0.0.0 --port 8000` 起動時に実行するコマンドを指定
docker container run --name my-server-2 -dit -w /usr/src -p 28000:8000 --rm my-fast-api-server:13.2 uvicorn main:app --host 0.0.0.0 --port 8000
ブラウザを開いてhttp://localhost:28000/api/greet/<さっきとは違う文字列>
を書いて、以下のように返ってくれば成功です。
Dockerfileを使用する
次にDockerfileをBuildする方法でDockerイメージを作成していきます。
ファイル構成はワーキングディレクトリ直下にDockerfileを追加するだけで大丈夫です。
workspace
|-- main.py
|-- Dockerfile
さて、下にDockerfileを記載しますが、よく見てみると先ほどのCommitの説明で実行した生Dockerコマンドで作成したものと同じ流れであることがわかるかと思います。
# 使用するイメージを指定
FROM python:3.11.3-slim-buster
# dockerコンテナのカレントディレクトリを移動させる
WORKDIR /usr/src
# RUNコマンドは、その後に書かれたコマンドがbuild時に即時実行される。
RUN pip install fastapi uvicorn[standard]
# ホストのPythonプログラムをDockerコンテナにコピーする
# `COPY . .`として対象フォルダ以下すべてをコピーするのをよく見る。
# ただ、今回手元の環境には関係ないファイルやvenvファイルが同一フォルダにあったので、必要最小限のみコピー。
COPY ./main.py ./main.py
# EXPOSE はあくまで宣言のために使われる。
# 実際にポートの疎通を行うのは、docker container run のときに`-p`コマンドで指定する
EXPOSE 8000
# CMD で指定したコマンドはBuild時には実行されず、イメージをRUNするときに実行される。
# つまり、実行時の処理の予約をしているようなもの。
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Dockerfileの記述は以上です。
あとはワーキングディレクトリに移動してBuildを実行します。
# docker build: 現在のディレクトリにあるDockerfileをビルドします。
# `-t my-fast-api-server-from-dockerfile`: 作成されるイメージにタグ、つまり別名を付けます。付けないとランダム文字列が割り振られるので付けましょう。
# `.`: 最後のドットを見落とす人も多いかと思います。単にカレントディレクトリを表しており、「ワーキングファイル以下のすべてのファイルをDocker側に送信する」ということを示します。これにより、Docker側がmain.pyなどのファイルを使用できるようになります。
docker build -t my-fast-api-server-from-dockerfile .
# 出来上がったDockerイメージの確認
docker image ls
> REPOSITORY TAG IMAGE ID CREATED SIZE
> my-fast-api-server-from-dockerfile latest 58a78b4ebbec 3 minutes ago 184MB
イメージが出来上がったので、実行しましょう。
docker run -dit --name my-fast-api-container-from-dockerfile -p 28001:8000 my-fast-api-server-from-dockerfile
今回はホストの28001ポートに割り当てたので、こちらに接続して確認していきましょう。ブラウザから接続できればOKです!
2つのイメージの違いについて
今回はCommitとBuildの2通りを実践してきましたが、最後にその違いを実際に確認してみます。
Docker Desktopを開いてImageタブからそれぞれの詳細(View detail)を確認します。
すると、そのDockerイメージの今までのコミット履歴が確認できます。
左側がCommitで作成したDockerイメージ、右がDockerfileのBuildで作成したDockerイメージです。比べてみるとCommitで作成したDockerイメージは情報が欠落しているのに対して、Buildした方は走らせたコマンドやコピーしたファイルの情報を保持しています。
ちなみにここで見れるコマンドの履歴は、画面に「Layers」と書かれているようにDockerイメージのレイヤになっています。昔のものから積み上げていく
(閑話)Dockerレイヤーについて
いままでDockerレイヤーについて何度か触れてきましたが、Dockerレイヤーとはどのような概念であるかを簡単に説明します。
下図に置いて、レイヤー1~3はDockerイメージを表しており、Dockerコンテナを起動して操作を行った結果、それらの変更はIOレイヤーに反映されます。
レイヤー1~3は読込専用となっているので、実際にはIOレイヤー上で上書きすることになるということです。
もちろん、IOレイヤーで操作を行った後にdocker commit
をすれば、そのIOレイヤーでの変更分を新しく「レイヤー4」として保存することもできます。
この読込専用のレイヤをイメージレイヤ、書き込みのできるIOレイヤを「コンテナレイヤ」と呼ぶようです。
せっかくなのでこれもゲームに例えてしまうと
・「イメージレイヤー」:最後にセーブしたところまでが
・「コンテナレイヤー」:セーブ地点から、セーブせずに進めているところ
・セーブせずにゲームを消したら、それまでの行動(コンテナレイヤー)が消し飛ぶのは当然
(最近のゲームはオートセーブが多すぎて、もしかするとピンとこない人も多かったりするのでしょうか?)
Dockerレイヤーについてもう少し詳しく知りたい場合は、参考資料にある公式ドキュメントやその他の解説記事を参照してみてください。
まとめ
- Dockerイメージは、Dockerコンテナを量産することができる
- Dockerイメージの作成方法は2種類
- DockerコンテナをCommitする
- DockerfileをBuildする
- Commitは単純なデータセーブ、DockerfileのBuildはコマンド履歴を詳細に残る
- 実際にはcommitが使われることはほぼ無いが、Docker動作の基礎としてイメージを付けておくといい
というわけで、今回はDockerfileとDockerイメージの概念を掴む話でした。
Dockerコンテナグレートジャーニーを追ってきてくれた皆さんはお疲れ様です、これでDockerの基礎的な話は網羅できました。次回は応用編ということで、実際にDockerfile, docker-composeを使って簡単なWebアプリを作成し、それをAWS ECSにデプロイしてみます。
参考資料
Docker commit について
Dockerレイヤーについて
書籍。最初はこの本で勉強しました。