もろず blog

もろちゃんがITに関しての様々なトピックを解説します

Docker ガチ入門 〜つくって学ぶ docker pull & run 編〜

こんにちは!

いま所属している LAPRAS という会社のメンバーで「LAPRAS 夏の自由研究リレー」と題してそれぞれが興味のある領域についての記事を書こう!という企画をやっており、その一環で記事を書くことにしました! この記事が1発目の記事なので気合いをいれていきたいと思います。今日から他のメンバーも各々の自由研究の記事を発信して行くのでぜひチェックしてください!

note.lapras.com

さて、そんな僕の夏の自由研究のテーマは 「つくって学ぶ Docker pull & run 編」 です。Docker についてより深く知るために、docker pull と run を自分でつくってみようと思います。

お仕事でも趣味の開発でも、もはや Docker なしではアプリケーションは作れないというくらいに Docker を使っています。 LAPRAS では全てのコードは Docker イメージにパッケージングされていて、本番サービスのコードは全て Kubernetes 上で動いています。 もう Docker なしでは生きられないですね!

これまで Docker をがっつり使っている方も、まだあまりよく知らないという方も、この記事を呼んで一緒に Docker を作りながら学んでみましょう!

(ちなみに Docker の入門てきなタイトルですが docker の基本的な使い方などは一切出てきませんのでご了承ください・・・・。 ガチ入門です。)

目次

この記事では

について書きます。

1. docker pull についてざっくり理解

さて、まずは docker pull をつくるために、docker pull がどういう処理になっているかを調べてみます。

Docker のファイルシステムについて

以前こちらの、Docker が利用している union filesystem についての記事を書きました。 union filesystem は Docker の特徴の1つだと思っているので Docker を理解する上でとても大事な概念です。

moro-archive.hatenablog.com

この記事を書く際に調べたことをまとめると、union filesystem を使うことで以下のような設計がされていることがわかりました。

  • Docker コンテナ内のファイルは複数のレイヤーで構成されている
  • Docker イメージのレイヤーは readonly としてマウントしている
  • コンテナ個別の書き込み可能なレイヤーを Docker イメージのレイヤーに被せることで、コンテナ内で書き込みが発生したデータはこのレイヤーに適用される

処理の流れ

今回 docker pull をつくってみるにあたりこちらのリポジトリを参考にさせていただきました。

github.com

こちらのリポジトリにある docker pull のサンプルコードを見てみると以下のような処理をやっていることがわかります。

Docker イメージを取得するための必要最低限の処理だけをみると、Docker レジストリAPI にアクセスしてデータを取ってくるだけなので、意外と難しくはなさそうだということがわかりました。

Docker レジストリ API の仕様

Docker レジストリAPI についての公式ドキュメントを探してみると以下のものが見つかりました。 Docker レジストリの認証についてはこちらに仕様が書かれています。

docs.docker.com

また、Docker イメージのマニフェストファイルのスキーマはこちらに定義があります。

docs.docker.com

記事の先頭に戻る

2. docker run についてざっくり理解

docker run をつくってみるにあたってはこちらのリポジトリを参考にさせていただきました。 こちらのリポジトリはワークショップの形式にまとめられていて、1つ1つ順を追ってコンテナの仕組みをつくっていけるようになっています。 とても分かりやすいのでオススメです! github.com

処理の流れ

さて、こちらのリポジトリのコードを見ると、以下のような処理をやっていることがわかります。

  • コンテナ内のルートディレクトリになるディレクトリを作る
  • Docker イメージのディレクトリと、コンテナ個別のディレクトリをコンテナのプロセスにマウントする
  • いくつかの基本的なデバイスファイルを初期化してマウントする
  • cgroups を利用してコンテナが使用する CPU, メモリ を隔離・制限する
  • コンテナのプロセスの uid を変更する

必要最低限な処理の流れをみるとほとんどの処理はシステムコール側で実装されているので、OS で既に用意されている仕組みを適切に呼び出していけば docker run が実現できそうだということが分かります。

参考

過去に軽く Docker のソースコードを読んでコンテナの起動をどうやっているかを調べたことがあるんですが、コンテナの起動部分の処理は元々 Docker に内包されていたものが現在は runc というモジュールに切り出されてメンテナンスされています。 ちゃんと読み解けていなかったのでまたいつかやる気が出てきた時にコードを読んでみようと思います・・・・・。

github.com

記事の先頭に戻る

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 をするコードを書きました。

github.com

docker pull と比べてこれは結構ややこしかったです。

システムコールを呼ぶためのラッパーモジュール

基本的な流れは事前に調べた通りで、Python からいくつかの OS コマンドを呼ぶためのライブラリを自前で書きました。 C で Python のモジュールを書くのは初めてだったんですが、参考にした https://github.com/Fewbytes/rubber-docker にあるコードをベースに実装できたのでそこまでハマることなく作れました。

github.com

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 の書き込みで利用する一時ディレクトリということのようですね。

unix.stackexchange.com

ルートディレクトリの変更

最もコンテナっぽいと個人的に思っている、コンテナ内のファイルは全く別で違う 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 ライフをお過ごしください!

記事の先頭に戻る