はじめに
docker run hello-world /bin/bash
このコマンドを打てばhello-worldイメージがレジストリからpullされ、そのイメージからコンテナが作成され、その後にコンテナ内でbashが開かれます。
今回はこのコマンドを打つことでどのようにコンテナが作成されていくのか調べてみました。
Docker Engineの全体像
以下の図は公式ドキュメントで紹介されているDocker Engineの全体像です。
(出典:Docker 概要)
図にもあるようにDocker Engineはクライアント・サーバーモデルのアプリケーションです。
そして3つのコンポーネントによって作成されます。
- Docker CLI (クライアント)
- Dockerデーモン (サーバー)
- Docker Engine API (REST API)
以下でそれぞれについて説明します。
Docker CLI
Docker CLIはDockerのクライアントです。
docker run hello-world /bin/bash
などdockerコマンド以降にこの後説明するDockerデーモンに行って欲しいこと(コンテナ立ち上げ、イメージのビルド)やイメージの指定、コマンドの指定などを書き加えます。
Docker CLIでコマンドを打つことで、Docker Engine APIというものを通じてDockerデーモンに指示が渡り、Docker CLIで指示した内容を処理してくれます。
デーモンとは
Dockerデーモンについて説明する前にそもそもデーモンとはなんなのでしょうか?
デーモンのスペルはdaemonで守護神の意味です。
デーモンはバックグラウンドで動いており、基本的にユーザーが意識することはありませんが縁の下の力持ちとしての役割を担っています。
バックグラウンドで動いているようなWebサーバーもデーモンの1種と言えます。
また、慣例としてデーモンの最後にはdを付けます。(httpdなど)
そしてデーモンはプロセスの1種です。処理要求があるのを待ち続け、要求があった時に自分自身をコピーしたプロセスを作り、処理を行います。アクセスがあるまではずっと待機の状態です。
しかし、アクセスが多いWebサーバーなどはデーモンはアクセスの度にプロセスを作成するためプロセスが多くなります。結果として負担がかかりサーバーが落ちることがあります。
スーパーデーモン
それを防ぐためにスーパーデーモンというものがあります。
これはデーモンを管理するデーモンで、複数のデーモンの代わりに待ち続けます。特定のデーモンが必要になった場合はそのデーモンのみを立ち上げるように指示を出します。これによって無駄なデーモンが常に立ち上がることがなくなるためサーバーへの負荷を減らすことができます。
Dockerデーモン
Dockerデーモンはその名の通りDocker用のデーモンです。
デーモンなのでdockerdというプロセスで動きます。dockerdはDocker Engine APIが呼び出されるのを待ち続けます。
Dockerデーモンは呼び出されたDocker Engine APIに応じてイメージのビルドやコンテナの起動などを行います。
Dockerクライアント(Docker CLI)とDockerデーモンの間の通信にはDocker Engine APIが利用され、UNIXドメインソケットやネットワークインタフェースを介して行われます。
デフォルトはunixドメインソケットを使用して通信を行います。
unixドメインソケット
ここでunixドメインソケットと言う単語が出てきましたので説明します。
unixドメインソケットはファイルシステム(.sock)を介してプロセス間通信を行うものです。
似たような物としてソケットがありますが、unixドメインソケットとは異なります。
どちらもプロセス間のやり取りを行う手法の1つですが、ソケットは異なるホスト間での通信をし、unixドメインソケットは同じホスト間での通信を行います。
そのためunixドメインソケットはコンピューター内部でしか使用できませんが、高速にデータをやり取りすることができます。通信先にはIPアドレスやドメイン名は指定せずにソケットファイルファイルのパスを指定します。
Dockerでは/var/docker/docker.sockというソケットファイルを使用してプロセス間通信を実現しています。
そのため、このソケットファイルを変更することでDockerクライアントからリモートにあるDockerデーモンへのアクセスを行うこと
リモートのDockerデーモンと接続する場合はTCPソケット(一般的なソケット)を使用するため設定を変更する必要があります。
Docker Engine API
このAPIはDocker Remote API, REST APIなどいろいろな呼び方がされていますが、今回はDocker Engine APIに統一して説明します。Docker CLIはDocker Engine APIを通して、スクリプトやコマンドの実行を行い、Dockerデーモンを制御したり入力を行ったりします。
実はDocker CLIは単にクライアントにすぎないため、Docker CLIを使わないでもDocker Engine APIを叩くことができ、コンテナの作成を行うことができます。しかし、Docker Engine APIのプロセス通信にはUNIXドメインソケットがデフォルトで使われていることからそのままでは使うことができません。
そのためこのAPIを使うにはTCPが使えるように設定を変更することでAPIを直接叩くことができます。
また、Docker Engine APIのバージョンはDockerデーモンとDockerクライアントのバージョンによって異なります。
docker versionコマンドでバージョンを確認できます。私の環境のAPIは1.40が使われていました。
$ docker version
Client: Docker Engine - Community
Cloud integration: 1.0.1
Version: 19.03.13
API version: 1.40
Go version: go1.13.15
Git commit: 4484c46d9d
Built: Wed Sep 16 16:58:31 2020
OS/Arch: darwin/amd64
Experimental: false
Server: Docker Engine - Community
Engine:
Version: 19.03.13
API version: 1.40 (minimum version 1.12)
Go version: go1.13.15
Git commit: 4484c46d9d
Built: Wed Sep 16 17:07:04 2020
OS/Arch: linux/amd64
Experimental: false
containerd:
Version: v1.3.7
GitCommit: 8fba4e9a7d01810a393d5d25a3621dc101981175
runc:
Version: 1.0.0-rc10
GitCommit: dc9208a3303feef5b3839f4323d9beb36df0a9dd
docker-init:
Version: 0.18.0
GitCommit: fec3683
全体像
こちらの図は公式のものですが、大まかな流れがわかるかと思います。
(出典:Docker アーキテクチャの理解)
Dockerデーモンの動作
先ほどDockerデーモンはDocker CLIから受け取った指示に基づいて処理を行うと説明しましたが、実はコンテナの作成などを行っているのはDockerデーモンではありません。
Dockerデーモンは、コンテナの起動、イメージのビルド、ネットワーク、ボリュームなどコンテナ全体の管理を行います。
コンテナ作成などを行っているのはコンテナランタイムが行っています。
コンテナランタイム
コンテナランタイムは2種類存在しています。
・高レベルランタイム
・低レベルランタイム
それぞれ役割が異なります。以下で詳しく説明していきます。
高レベルランタイム
高レベルランタイムはデーモンとして常駐しており、Dockerデーモンと直接対話して、低レベルランタイムに内容を渡します。
高レベルランタイムは管理しているイメージの情報を低レベルランタイムに渡して、コンテナ作成などの指示を低レベルランタイムに渡します。
containerd
Docker内部でデフォルトで用いられているのはcontainerdという高レベルランタイムで、コンテナイメージを管理する役割を担っています。
Dockerではcontainerdはコンテナの実行・管理を担当します。イメージのpullなどはdockerdが行います。
(k8sになるとcontainerdがレジストリとの通信を行います。こちらの資料でわかりやすく説明されています。)
containerdはイメージの情報をFilesystem bundleと呼ばれるコンテナの素を作成して低レベルランタイムに渡します。
Filesystem bundleはイメージをpullした後、そのイメージを構成するファイルをruncなどの低レベルランタイムに渡すときの格納方法を定めたものです。
また、containerd用のCLIコマンドはctrを使用してコンテナを作成することも可能です。
containerdが低レベルランタイムを呼び出す時は、shimと呼ばれるバイナリコンポーネントを通じて行います。
低レベルランタイムはそれぞれの持つアーキテクチャにあったshimを実装し、それをcontainerdにプラグインすることで、containerdを通じてそれら低レベルランタイムを操作することができます。
低レベルランタイム
高レベルランタイムからの指示を受け、コンテナ作成などを行います。
低レベルランタイムは高レベルランタイムとは異なりデーモンではないため常駐はしません。
低レベルランタイムはOCIによって決められたOCI Runtime Specificationに基づいた実装がされているため、入れ替えることが可能です。
OCI Runtime Specification
OCI Runtime Specificationでは標準的なコンテナの5原則が定義されています。
-
Standard operations
標準的なコンテナは一連の標準的な操作が実装されています。 -
Content-agnostic
どんな標準的な操作も内容にかかわらず同様の結果を得ることができます。 -
Infrastructure-agnostic
OCIがサポートしたインフラ上であれば動かすことができます。 -
Designed for automation
コンテンツやインフラに関係なく同じ操作を提供することができるため自動化に向いている。 -
Industrial-grade delivery
どんな企業でもソフトウェアの配布を行うことができる。
加えてスタンダード(標準的な)コンテナは以下の3つの仕様が揃っているとされています。
・ ファイルの構成形式 (Filesystem Bundle)
・ 基本的な操作のセット (create, start, kill, delete, etc..)
・ 実行環境 (config)
以下の図のようにコンテナのライフサイクルを定義し、標準化することで一貫性のあるプロセスを定義することができます。
(出典:https://www.slideshare.net/KoheiTokunaga/ocirunc?ref=https://cdn.embedly.com/)
そして今回はDockerで用いられているOCIのリファレンス実装の低レベルランタイムruncについて少し触れていこうと思います。
runc
runcはOCIによるリファレンス実装の低レベルコンテナランタイムです。runcコマンドを使ってコンテナの操作を行うことができます。
また、現在は修正されていますがruncには脆弱性が報告されたことがあります。これは悪意のあるイメージを実行した際に、そのイメージがruncバイナリを上書きし、rootとしてコードが実行できてしまったというものでした。
これはruncがコンテナを作成するのがホストOSのkernel上に作成するため、1つの悪意のあるイメージがrootを使用できると他のコンテナにも影響があるというものでした。
まとめ
以上、これまでの流れをまとめると以下のようになります。
1つ1つのコンテナはruncが管理し、コンテナ群をcontainerdで管理し、dockerコマンドの受け付けや全体の処理をdockerdが行います。
参考文献
Docker アーキテクチャの理解
コンテナの作り方「Dockerは裏方で何をしているのか?」
Docker Engine API
コンテナランタイムの仕組みと、Firecracker、gVisor、Unikernelが注目されている理由。 Container Runtime Meetup #2
OCI Runtime Specificationを読んだので概要を書く
OCIランタイムの筆頭「runc」を俯瞰する
書籍
イラストでわかるDockerとKubernetes (Software Design plus)