こんにちは!
いま所属している LAPRAS という会社のメンバーで「LAPRAS 夏の自由研究リレー」と題してそれぞれが興味のある領域についての記事を書こう!という企画をやっており、その一環で記事を書くことにしました! この記事が1発目の記事なので気合いをいれていきたいと思います。今日から他のメンバーも各々の自由研究の記事を発信して行くのでぜひチェックしてください!
さて、そんな僕の夏の自由研究のテーマは 「つくって学ぶ Docker pull & run 編」
です。Docker についてより深く知るために、docker pull と run を自分でつくってみようと思います。
お仕事でも趣味の開発でも、もはや Docker なしではアプリケーションは作れないというくらいに Docker を使っています。 LAPRAS では全てのコードは Docker イメージにパッケージングされていて、本番サービスのコードは全て Kubernetes 上で動いています。 もう Docker なしでは生きられないですね!
これまで Docker をがっつり使っている方も、まだあまりよく知らないという方も、この記事を呼んで一緒に Docker を作りながら学んでみましょう!
(ちなみに Docker の入門てきなタイトルですが docker の基本的な使い方などは一切出てきませんのでご了承ください・・・・。 ガチ入門です。)
目次
- 1. docker pull についてざっくり理解
- 2. docker run についてざっくり理解
- 3. docker pull をつくってみる
- 4. docker run をつくってみる
- 5. まとめ
について書きます。
1. docker pull についてざっくり理解
さて、まずは docker pull をつくるために、docker pull がどういう処理になっているかを調べてみます。
Docker のファイルシステムについて
以前こちらの、Docker が利用している union filesystem についての記事を書きました。 union filesystem は Docker の特徴の1つだと思っているので Docker を理解する上でとても大事な概念です。
この記事を書く際に調べたことをまとめると、union filesystem を使うことで以下のような設計がされていることがわかりました。
- Docker コンテナ内のファイルは複数のレイヤーで構成されている
- Docker イメージのレイヤーは readonly としてマウントしている
- コンテナ個別の書き込み可能なレイヤーを Docker イメージのレイヤーに被せることで、コンテナ内で書き込みが発生したデータはこのレイヤーに適用される
処理の流れ
今回 docker pull をつくってみるにあたりこちらのリポジトリを参考にさせていただきました。
こちらのリポジトリにある docker pull のサンプルコードを見てみると以下のような処理をやっていることがわかります。
- Docker Hub のレジストリからアクセストークンを取得する
- 指定した Docker イメージのマニフェストファイルを取得する
- マニフェストファイル内にある Docker イメージ内のレイヤー毎のダイジェストを取得する
- Docker Hub のレジストリからレイヤーのバイナリをダウンロードする
Docker イメージを取得するための必要最低限の処理だけをみると、Docker レジストリの API にアクセスしてデータを取ってくるだけなので、意外と難しくはなさそうだということがわかりました。
Docker レジストリ API の仕様
Docker レジストリの API についての公式ドキュメントを探してみると以下のものが見つかりました。 Docker レジストリの認証についてはこちらに仕様が書かれています。
また、Docker イメージのマニフェストファイルのスキーマはこちらに定義があります。
2. docker run についてざっくり理解
docker run をつくってみるにあたってはこちらのリポジトリを参考にさせていただきました。 こちらのリポジトリはワークショップの形式にまとめられていて、1つ1つ順を追ってコンテナの仕組みをつくっていけるようになっています。 とても分かりやすいのでオススメです! github.com
処理の流れ
さて、こちらのリポジトリのコードを見ると、以下のような処理をやっていることがわかります。
- コンテナ内のルートディレクトリになるディレクトリを作る
- Docker イメージのディレクトリと、コンテナ個別のディレクトリをコンテナのプロセスにマウントする
- いくつかの基本的なデバイスファイルを初期化してマウントする
- cgroups を利用してコンテナが使用する CPU, メモリ を隔離・制限する
- コンテナのプロセスの uid を変更する
必要最低限な処理の流れをみるとほとんどの処理はシステムコール側で実装されているので、OS で既に用意されている仕組みを適切に呼び出していけば docker run が実現できそうだということが分かります。
参考
過去に軽く Docker のソースコードを読んでコンテナの起動をどうやっているかを調べたことがあるんですが、コンテナの起動部分の処理は元々 Docker に内包されていたものが現在は runc
というモジュールに切り出されてメンテナンスされています。
ちゃんと読み解けていなかったのでまたいつかやる気が出てきた時にコードを読んでみようと思います・・・・・。
3. docker pull をつくってみる
先ほど調べた内容と参考にしたリポジトリのコードを元に docker pull
を実現するコードを書いてみました。
github.com
Docker イメージのレイヤーデータのダウンロード処理
Docker イメージのデータはレイヤー毎に tar ファイルのバイナリがダウンロードできるようになっています。 本来は tar ファイルのまま、もしくはレイヤー毎に解凍したファイル一式を分けてローカルに保持しておくのが正しいはずですが、ダウンロード時に全てのレイヤーの tar を解凍して1つのディレクトリツリーにマージするように実装してます。 これは単純に横着しただけです、はい。
https://github.com/Chanmoro/build-docker-from-scratch/blob/master/app/pull.py#L131-L150
# 各レイヤーをダウンロードする for digest in manifest.layer_digests: print('Fetching layer %s..' % digest) # ダウンロードしたレイヤーを tar として保存する local_layer_tar_name = os.path.join(image_layers_path, digest) + '.tar' with open(local_layer_tar_name, 'wb') as f: for chunk in client.download_layer(library, image, digest): if chunk: f.write(chunk) # tar を展開する with tarfile.open(local_layer_tar_name, 'r') as tar: # tar ファイルの中身を一部表示する for member in tar.getmembers()[:10]: print('- ' + member.name) print('...') tar.extractall(str(contents_path)) print('extract done') print(f'Save docker image to {image_base_dir}')
4. docker run をつくってみる
さて、次は docker pull
で取得したイメージを使って docker run
をするコードを書きました。
docker pull と比べてこれは結構ややこしかったです。
システムコールを呼ぶためのラッパーモジュール
基本的な流れは事前に調べた通りで、Python からいくつかの OS コマンドを呼ぶためのライブラリを自前で書きました。 C で Python のモジュールを書くのは初めてだったんですが、参考にした https://github.com/Fewbytes/rubber-docker にあるコードをベースに実装できたのでそこまでハマることなく作れました。
Docker イメージのファイルのマウント
Docker イメージのファイルを readonly でマウントして、コンテナ個別の書き込み可能な領域を OverlayFS でマウントしているところのコードはこんな感じで実装できます。 「ああ、こういうことね!その通りじゃん!」というのが実際につくってみてとてもよく分かりました。
https://github.com/Chanmoro/build-docker-from-scratch/blob/master/app/run.py#L79-L96
def _mount_image_dir(self, image: Image, container_dir: ContainerDir): """ Docker イメージをマウントする :param image: :param container_dir: :return: """ image_path = self._get_image_base_path(image, self.IMAGE_DATA_DIR) image_root = os.path.join(image_path, 'layers/contents') # オーバーレイ FS としてマウントする linux.mount( 'overlay', container_dir.root_dir, 'overlay', linux.MS_NODEV, f"lowerdir={image_root},upperdir={container_dir.rw_dir},workdir={container_dir.work_dir}" )
OverlayFS のマウントで指定する workdir
ってなんなんだろう?と思ってググってみるもなかなか詳細な情報が出てこずこちらの QA の回答が一番それっぽかったです。
OverlayFS の書き込みで利用する一時ディレクトリということのようですね。
ルートディレクトリの変更
最もコンテナっぽいと個人的に思っている、コンテナ内のファイルは全く別で違う OS としても動く、という動作の一番重要なところはこの pivot_root
(もしくは chroot
) なんじゃないでしょうか。
これによってそのプロセスのルートディレクトリを自由に変更できます。
こう見るとホントにただシステムコールを呼んでるだけなので、巨人の肩の上に乗っている感じがとてもありますね。
https://github.com/Chanmoro/build-docker-from-scratch/blob/master/app/run.py#L151-L164
def _change_root_dir(self, container_root_dir: str): """ コンテナ内のルートディレクトリを変更する :param container_root_dir: :return: """ old_root = os.path.join(container_root_dir, 'old_root') os.makedirs(old_root) linux.pivot_root(container_root_dir, old_root) os.chdir('/') linux.umount2('/old_root', linux.MNT_DETACH) os.rmdir('/old_root')
その他
この docker run の実装もかなり横着して端折っています・・・・。
cgroups によるCPU、メモリ、ディスクの分離や、コンテナ毎のネットワークのルーティングなどなどとても重要な機能がまだまだあるんですが、 pivot_root
と OverlayFS によるマウントを実装できただけで個人的にもうかなり満足してしまいました。
こちらも今後またやる気が出てきたらトライしてみようと思います・・・・・!
5. まとめ
さて、今回は Docker ガチ入門の つくって学ぶ docker pull & run 編 ということで docker pull と run を自分でつくってみることでその動きを理解しよう!という内容を書きました。
実装したコードはこちらのリポジトリで公開しています。README に実行方法を書いてあるのでそれぞれの PC 上で確認してもらえます。(動かなかったら issue をつくっていただけると助かります!) github.com
今回実際に自分で Docker の機能の一部をつくってみて Docker の動作への理解がかなり深まったと思います。 同時に、コンテナ化やリソース分離のための OS の機能は UNIX から既に実装されていたりするので、コアな技術自体は実はそんなに新しいものではないということも改めてよく分かりました。
Docker がこれだけ流行してほぼデファクトになっている状況をみると、 コンテナ実行のためのランタイムよりも、パッケージングとその配布を簡単にするための Dockerfile と Docker レジストリと Docker Hub の存在がかなり大きかったんじゃないかなと思います。
相変わらず Docker は非常に素晴らしいソフトウェアだと思いますし、同じくデファクトになりつつある Kubernetes も知れば知るほど奥が深いので、今後も積極的に使っていこうとおもいます。
それでは皆さんもこの夏に Docker をつくってみて学びを深め、快適な Docker ライフをお過ごしください!