C++ でシンプルな Docker コンテナを作成する

C++Beginner
オンラインで実践に進む

はじめに

Docker の本質は、LXC を使用して仮想マシンのような機能を実現し、ハードウェアリソースを節約し、ユーザーにより多くの計算リソースを提供することです。このプロジェクトでは、C++ と Linux の Namespace および Control Group 技術を組み合わせて、シンプルな Docker コンテナを実装します。

最後に、コンテナに以下の機能を実現します。

  1. 独立したファイルシステム
  2. ネットワークアクセスのサポート

👀 プレビュー

$ make
make container
make[1]: Entering directory '/home/labex/project'
gcc -c network.c nl.c
g++ -std=c++11 -o docker-run main.cpp network.o nl.o
make[1]: Leaving directory '/home/labex/project'
$ sudo./docker-run
...start container
root@labex:/## ifconfig
eth0      Link encap:Ethernet  HWaddr 00:16:3e:da:01:72
          inet6 addr: fe80::dc15:18ff:fe43:53b9/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:38 errors:0 dropped:0 overruns:0 frame:0
          TX packets:9 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:5744 (5.7 KB)  TX bytes:726 (726.0 B)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

🎯 タスク

このプロジェクトでは、以下を学びます。

  • C++ と Linux の Namespace 技術を使用してシンプルな Docker コンテナを作成する方法
  • コンテナに独立したファイルシステムを実装する方法
  • コンテナにネットワークアクセスを有効にする方法

🏆 成果

このプロジェクトを完了した後、以下ができるようになります。

  • C++ と Linux の Namespace 技術を使用してシンプルな Docker コンテナを作成する
  • コンテナに独立したファイルシステムを実装する
  • コンテナにネットワークアクセスを有効にする

Linux Namespace 技術

C++ では、キーワード namespace が馴染み深いです。C++ では、各名前空間(namespace)が異なるコード内の同じ名前を分離します。つまり、名前空間の名前が異なれば、名前空間内のコードの名前は同じでも構いません。これにより、コード内の名前の衝突問題が解決されます。

一方、Linux Namespace は、Linux カーネルが提供する技術で、C++ の namespace の概念に似て、アプリケーションに対するリソース分離の解決策を提供します。私たちは、PID、IPC、ネットワークなどのリソースはオペレーティングシステム自体によって管理されるべきであることを知っていますが、Linux Namespace を使うと、これらのリソースをグローバルではなく、特定の名前空間に割り当てることができます。

Docker 技術の世界では、LXC や OS レベルの仮想化などの用語をよく耳にします。LXC は、Namespace 技術を利用して、異なるコンテナ間のリソース分離を実現しています。Namespace 技術を利用することで、異なるコンテナ内のプロセスは異なる名前空間に属し、互いに干渉することがありません。要するに、Namespace 技術は軽量な仮想化形式を提供し、システム全体のプロパティを異なる視点から操作することができます。

Linux では、Namespace に関連する最も重要なシステムコールは clone() です。clone() の目的は、プロセスを作成する際にスレッドを特定の名前空間に制限することです。

システムコールのカプセル化

Linux のシステムコールは C 言語で記述されていますが、このプロジェクトでは C++ コードを記述する必要があります。純粋な C++ の一貫したコーディングスタイルを維持するために、まずこれらの必要な API を C++ 形式にカプセル化します。これにより、これらの API の使い方についてもより深く理解することができます。

以下の API を使用します。

clone()

clonefork の両方のシステムコールは、Linux でプロセスを作成するために使用されます。ただし、forkclone の一部に過ぎません。両者の違いは、fork は親プロセスの正確なコピーである子プロセスを作成するだけであるのに対し、clone はより強力で、親プロセスのリソースを子プロセスに選択的にコピーすることができる点にあります。コピーされないリソースは、ポインタコピー (arg) を通じてプロセス間で共有されます。コピーする具体的なリソースは flags を使用して指定でき、この関数は子プロセスの PID を返します。

プロセスは主に 4 つの要素で構成されていることがわかっています。

  1. 実行するコードのセグメント
  2. プロセスのためのプライベートなスタック領域
  3. プロセス制御ブロック (PCB)
  4. プロセス固有の名前空間

最初の 2 つの要素は clone のパラメータ fnchild_stack に対応しています。プロセス制御ブロックはカーネルによって制御されるため、私たちが心配する必要はありません。したがって、名前空間は flags パラメータに関連付けられています。Docker コンテナを作成するという目標を達成するために、主に必要なパラメータは以下の通りです。

名前空間の分類 システムコールのパラメータ


    UTS         CLONE_NEWUTS
   マウント       CLONE_NEWNS
    PID         CLONE_NEWPID
  ネットワーク     CLONE_NEWNET

名前からわかるように、CLONE_NEWNS はファイルシステム関連のマウントとファイルシステム関連のリソースのコピーを提供し、CLONE_NEWUTS はホスト名を設定する機能を提供し、CLONE_NEWPID は独立したプロセス空間のサポートを提供し、CLONE_NEWNET はネットワーク関連のサポートを提供します。

execv()

int execv(const char *path, char *const argv[]);

execvpath で指定された実行可能ファイルを実行します。このシステムコールにより、子プロセスが /bin/bash を実行してコンテナを実行状態に保つことができます。

sethostname()

int sethostname(const char *name, size_t len);

名前が示す通り、このシステムコールはホスト名を設定するために使用されます。C スタイルの文字列はポインタを使用し、文字列の長さを直接内部から判断することができないため、len パラメータを使用して文字列の長さを取得します。

chdir()

int chdir(const char *path);

すべてのプログラムは特定のディレクトリで実行されることがわかっています。リソースにアクセスする必要があるときは、絶対パスではなく相対パスを使用して関連するリソースにアクセスすることができます。chdir は、プログラムの作業ディレクトリを変更する便利な機能を提供し、特定の目的に使用することができます。

chroot()

このシステムコールはルートディレクトリを変更するために使用されます。

int chroot(const char *path);

mount()

このシステムコールは、mount コマンドと同様に、ファイルシステムをマウントするために使用されます。

int mount(const char *source, const char *target,
                 const char *filesystemtype, unsigned long mountflags,
                 const void *data);

コンテナサブプロセスの作成

~/project ディレクトリに移動し、docker.hpp という名前のファイルを作成します。このファイルでは、まず外部コードから呼び出せる docker という名前の名前空間(namespace)を作成します。

//
// docker.hpp
// cpp_docker
//

// システムコール用のヘッダーファイル
#include <sys/wait.h>   // waitpid
#include <sys/mount.h>  // mount
#include <fcntl.h>      // open
#include <unistd.h>     // execv, sethostname, chroot, fchdir
#include <sched.h>      // clone

// C 標準ライブラリ
#include <cstring>

// C++ 標準ライブラリ
#include <string>       // std::string

#define STACK_SIZE (512 * 512) // 子プロセス空間のサイズを定義

namespace docker {
    //.. ここから Docker の魔法が始まります
}

まず、可読性を高めるためにいくつかの変数を定義しましょう。

// `docker` 名前空間内で定義
typedef int proc_status;
proc_status proc_err  = -1;
proc_status proc_exit = 0;
proc_status proc_wait = 1;

コンテナクラスを定義する前に、コンテナを作成するために必要なパラメータを分析しましょう。現時点ではネットワーク関連の設定は考慮しません。イメージから Docker コンテナを作成するには、ホスト名とイメージの場所を指定するだけです。したがって:

// Docker コンテナの起動設定
typedef struct container_config {
    std::string host_name;      // ホスト名
    std::string root_dir;       // コンテナのルートディレクトリ
} container_config;

では、container クラスを定義し、コンストラクタでコンテナに必要な設定を行いましょう。

class container {
private:
    // 可読性を高める
    typedef int process_pid;

    // 子プロセスのスタック
    char child_stack[STACK_SIZE];

    // コンテナの設定
    container_config config;
public:
    container(container_config &config) {
        this->config = config;
    }
};

container クラスの具体的なメソッドを考える前に、まずこの container クラスをどのように使うかを考えましょう。そのために、~/project フォルダに main.cpp ファイルを作成します。

//
// main.cpp
// cpp_docker
//

#include "docker.hpp"
#include <iostream>

int main(int argc, char** argv) {
    std::cout << "...start container" << std::endl;
    docker::container_config config;

    // コンテナを設定する
    //...

    docker::container container(config);// 設定に基づいてコンテナを構築する
    container.start();                  // コンテナを起動する
    std::cout << "stop container..." << std::endl;
    return 0;
}

main.cpp では、コンテナの起動を簡潔かつ理解しやすくするために、start() メソッドを使ってコンテナを起動すると仮定します。これは、後で docker.hpp ファイルを書くための基礎を提供します。

では、docker.hpp に戻って start() メソッドを実装しましょう。

void start() {
    auto setup = [](void *args) -> int {
        auto _this = reinterpret_cast<container *>(args);

        // コンテナに関連する設定を行う
        //...

        return proc_wait;
    };

    process_pid child_pid = clone(setup, child_stack+STACK_SIZE, // スタックの底部に移動
                        SIGCHLD,      // 子プロセスが終了したときに親プロセスにシグナルを送信する
                        this);
    waitpid(child_pid, nullptr, 0); // 子プロセスの終了を待つ
}

docker::container::start() メソッドは、Linux の clone() システムコールを使用しています。docker::container インスタンスオブジェクトをコールバック関数 setup に渡すために、clone() の 4 番目の引数を使って渡すことができます。ここでは this ポインタを渡しています。

setup 関数については、ラムダ式を作成しています。C++ では、空のキャプチャリストを持つラムダ式は関数ポインタとして渡すことができます。したがって、setupclone() に渡されるコールバック関数になります。

ラムダ式の代わりに、クラス内で定義された静的メンバ関数を使用することもできますが、そうするとコードがあまりエレガントになりません。

この container クラスのコンストラクタでは、clone() システムコールによって呼び出される子プロセス処理関数を定義しています。typedef を使ってこの関数の戻り値の型を proc_status に変更しています。この関数が proc_wait を返すと、clone() によってクローンされた子プロセスは終了を待ちます。

しかし、これだけでは不十分です。なぜなら、プロセス内で何の設定も行っていないからです。その結果、プロセスが起動されると何もすることがないため、プログラムはすぐに終了します。Docker では、コンテナを実行し続けるために、次のようにできることがわかっています。

docker run -it ubuntu:14.04 /bin/bash

これは、STDIN をコンテナの /bin/bash にバインドします。では、docker::container クラスに start_bash() メソッドを追加しましょう。

private:
void start_bash() {
    // C++ の std::string を安全に C スタイルの文字列 char *に変換する
    // C++14 以降、この直接代入は禁止されています:`char *str = "test";`
    std::string bash = "/bin/bash";
    char *c_bash = new char[bash.length()+1];   // +1 は '\0' のため
    strcpy(c_bash, bash.c_str());

    char* const child_args[] = { c_bash, NULL };
    execv(child_args[0], child_args);           // 子プロセスで /bin/bash を実行する
    delete []c_bash;
}

そして、setup 内でこのメソッドを呼び出します。

auto setup = [](void *args) -> int {
    auto _this = reinterpret_cast<container *>(args);
    _this->start_bash();
    return proc_wait;
}

これで、次のような動作を確認できます。

labex:project/ $ hostname
iZj6cboigynrxh4mn2oo16Z
labex:project/ $ g++ main.cpp -std=c++11
labex:project/ $./a.out
...start container
labex@iZj6cboigynrxh4mn2oo16Z:~/project$ mkdir test
labex@iZj6cboigynrxh4mn2oo16Z:~/project$ ls
a.out docker.hpp main.cpp test
labex@iZj6cboigynrxh4mn2oo16Z:~/project$ exit
exit
stop container...

上記の手順では、まず現在の hostname を確認し、これまでに書いたコードをコンパイルして実行し、コンテナに入ります。コンテナに入った後、bash のプロンプトが変わることがわかります。これは期待通りの結果です。

しかし、これが私たちが望む結果ではないことにすぐに気づくでしょう。なぜなら、これはホストシステムとまったく同じです。この「コンテナ」内で行われるすべての操作は、直接ホストシステムに影響を与えます。

ここで、clone API で必要な名前空間(namespace)を導入します。

コンテナに独自のホスト名を持たせる

先ほどのシステムコールのセクションで述べたように、システムコールを使って子プロセスのホスト名を設定するのは非常に簡単です。そこで、docker::container クラスにプライベートメソッドを作成します。

private:
// コンテナのホスト名を設定する
void set_hostname() {
    sethostname(this->config.host_name.c_str(), this->config.host_name.length());
}

また、start() メソッドを以下のように変更します。

void start() {
    auto setup = [](void *args) -> int {
        auto _this = reinterpret_cast<container *>(args);

        // コンテナを設定する
        _this->set_hostname();
        _this->start_bash();

        return proc_wait;
    };

    process_pid child_pid = clone(setup, child_stack+STACK_SIZE,
                        CLONE_NEWUTS| // UTS 名前空間を追加
                        SIGCHLD,      // 子プロセスが終了したときに親プロセスにシグナルを送信する
                        this);
    waitpid(child_pid, nullptr, 0); // 子プロセスの終了を待つ
}

main.cpp ファイルでは、ホスト名を設定します。

int main(int argc, char** argv) {
    std::cout << "...start container" << std::endl;
    docker::container_config config;
    config.host_name = "labex";
    ……

では、コードを再コンパイルしましょう。

labex:project/ $ g++ main.cpp -std=c++11
labex:project/ $./a.out
...start container
stop container...

コンテナがすぐに終了することがわかります。これは、名前空間を導入すると、プログラムにスーパーユーザー権限が必要になるためです。そのため、sudo でプログラムを実行する必要があります。

labex:project/ $ sudo./a.out
...start container
root@labex:/home/labex/project## hostname
labex
root@labex:/home/labex/project## exit
exit
stop container...
labex:project/ $ hostname
iZj6cboigynrxh4mn2oo16Z

しかし、これでもコンテナの期待される効果は達成されていません。なぜなら、ls コマンドからわかるように、まだホストマシンのディレクトリにアクセスできるからです。

コンテナに独自のファイルシステムを持たせる

Docker 技術では、コンテナはイメージに基づいて作成されます。独自のコンテナを実装したいのであれば、自然にイメージに基づいて作成する必要があります。幸いなことに、Docker イメージを用意しています。以下のコマンドでダウンロードできます。

cd ~/project
wget --header="User-Agent: Mozilla/5.0" https://file.labex.io/lab/171925/docker-image.tar

次に、~/project/labex フォルダに展開します。

mkdir labex
tar -xf docker-image.tar --directory labex/
rm docker-image.tar

ここで、展開エラーが発生することがあります。これは、環境によっては一部のファイルが外部から作成できないためです。これは独自のコンテナの実装に影響しないので、無視してください。

tar: dev/agpgart: Cannot mknod: Operation not permitted
tar: dev/audio: Cannot mknod: Operation not permitted
tar: dev/audio1: Cannot mknod: Operation not permitted
tar: dev/audio2: Cannot mknod: Operation not permitted
tar: dev/audio3: Cannot mknod: Operation not permitted
tar: dev/audioctl: Cannot mknod: Operation not permitted
……

展開が完了すると、labex の下にほぼ完全な Linux ディレクトリが見えるようになります。

labex:project/ $ ls labex
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

ここで、docker::container がこのディレクトリに入り、それをルートディレクトリとして使用し、起動時にサブプロセスの外部アクセスをマスクするようにします。

private:
// ルートディレクトリを設定する
void set_rootdir() {

    // chdir システムコールで、特定のディレクトリに切り替える
    chdir(this->config.root_dir.c_str());

    // chroot システムコールで、ルートディレクトリを設定する。
    // すでに先に現在のディレクトリに切り替えているので、
    // 現在のディレクトリをルートディレクトリとして使用できる。
    chroot(".");
}

次に、main.cpp の関連設定を記述します。

#include "docker.hpp"
#include <iostream>

int main(int argc, char** argv) {
    std::cout << "...start container" << std::endl;
    docker::container_config config;
    config.host_name = "labex";
    config.root_dir  = "./labex";
    ……

そして、clone() 呼び出しで CLONE_NEWNS を有効にして、マウント名前空間(Mount Namespace)をアクティブにします。

void start() {
    auto setup = [](void *args) -> int {
        auto _this = reinterpret_cast<container *>(args);
        _this->set_hostname();
        _this->set_rootdir();
        _this->start_bash();
        return proc_wait;
    };

    process_pid child_pid = clone(setup, child_stack+STACK_SIZE,
                      CLONE_NEWUTS| // UTS 名前空間
                      CLONE_NEWNS|  // マウント名前空間
                      SIGCHLD,      // 子プロセスが終了したときに親プロセスにシグナルを送信する
                      this);
    waitpid(child_pid, nullptr, 0); // 子プロセスの終了を待つ
}

では、再コンパイルしましょう。

labex:project/ $ g++ main.cpp -std=c++11
labex:project/ $ sudo./a.out
...start container
root@labex:/## ls
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
root@labex:/## hostname
labex

ls を実行すると、子プロセスが完全な linux ディレクトリ内で動作していることがわかります。

コンテナに独自のプロセスシステムを持たせる

しかし、まだ問題があります。pstop のようなコマンドを使用すると、親プロセス内のすべてのプロセスを引き続き確認できます。これは望ましい結果ではありません。たとえば、ps の出力で a.out を見ることができ、プロセス ID の値も非常に大きいです。

この問題を解決するには、PID 名前空間(PID Namespace)を導入して、子プロセスの PID 空間を親プロセスから分離する必要があります。

private:
// 独立したプロセス名前空間を設定する
void set_procsys() {
    // proc ファイルシステムをマウントする
    mount("none", "/proc", "proc", 0, nullptr);
    mount("none", "/sys", "sysfs", 0, nullptr);
}

同様に、start() 関数にこのコードを追加し、CLONE_NEWPID を導入する必要があります。

void start() {
    auto setup = [](void *args) -> int {
        auto _this = reinterpret_cast<container *>(args);
        _this->set_hostname();
        _this->set_rootdir();
        _this->set_procsys();
        _this->start_bash();
        return proc_wait;
    };

    process_pid child_pid = clone(setup, child_stack,
                      CLONE_NEWUTS| // UTS 名前空間
                      CLONE_NEWNS|  // マウント名前空間
                      CLONE_NEWPID| // PID 名前空間
                      SIGCHLD,      // 子プロセスが終了したときに親プロセスにシグナルを送信する
                      this);
    waitpid(child_pid, nullptr, 0); // 子プロセスの終了を待つ
}

これで、再度コンパイルして実行すると、コンテナが独自の独立したプロセス空間を持っていることがわかります。

この時点で、Linux の名前空間(Namespace)技術を使用して、子プロセス内のリソースを分離し、Docker コンテナに独自のプロセス空間とファイルシステムを持たせました。

しかし、コンテナはまだネットワークにアクセスできず、ifconfig を使用してホストマシンのネットワークデバイスにアクセスすることさえできます。これは望ましい結果ではありません。次に、コンテナをさらに強化して、完全なコンテナに近づけ、ネットワークアクセスのサポートを提供します。

Docker ネットワーキングの原理

これまで、Docker が閉じたコンテナを実装する方法についての基本的な理解を得ました。しかし、実装した Docker コンテナはネットワークアクセスをサポートしておらず、実行した異なるコンテナ同士が通信することもできません。

Docker コンテナのネットワークブリッジ図

Docker コンテナ間のネットワーク通信の原理は、「docker0」と呼ばれるブリッジを介して実現されます。「container1」と「container2」の 2 つのコンテナはそれぞれ独自のネットワークデバイス「eth0」を持っています。すべてのネットワーク要求は「eth0」を通じて転送されます。コンテナは子プロセス内で動作するため、それらの「eth0」インターフェイス間の通信を可能にするには、一対のネットワークデバイス「veth1」と「veth2」を作成し、「docker0」ブリッジに追加する必要があります。これにより、ブリッジはコンテナ内の「eth0」インターフェイスによって生成されたネットワーク要求を無条件に転送およびルーティングし、コンテナ間の通信を可能にします。

したがって、作成したコンテナにネットワーク通信機能を持たせるには、まずそれらが使用できるブリッジを作成する必要があります。便宜上、環境に既存の「docker0」を直接使用します。

ネットワーク作成の準備

ネイティブの Linux API を使用してネットワークを操作することは非常に複雑な作業であり、多くの C 言語の操作も含まれます。C++ を使ったコーディングにより集中するために、ここではすでに実装されたいくつかの「既成のモジュール」を提供します。これにより、ネットワーク操作がより便利になります。

/tmp ディレクトリに移動すると、network.hnl.hnetwork.cnl.c の 4 つのファイルが用意されています。

これら 4 つのファイルを ~/project ディレクトリにコピーします。

cp /tmp/network.h /tmp/nl.h /tmp/network.c /tmp/nl.c ~/project/

最後の 3 つのファイルのコードは LXC ツールセットから引用されています。ただし、このコードは C 言語で書かれています。C++11 以降、C++ と C は互換性がなくなっているため、C++ がこのコードをスムーズに呼び出せるようにするには、C/C++ 混合プログラミングに関する知識が必要です。

まず、ソースコードを実行可能ファイルに変換するのは直接行われるのではなく、前処理、コンパイル、アセンブリ、リンクといういくつかのステップを経ます。通常、g++ main.cpp というステップを使って上記のすべてのステップを一度に完了させます。

しかし、プロジェクトが大きくなり、ソースファイルの数が増えると、わずかな変更のためにプロジェクト全体を再コンパイルするのは非効率的です。このとき、まずコードを .o ファイルにコンパイルしてからリンク作業を行うことができます。これにより、C 言語でコンパイルされたリンクファイルと C++ 関連のソースコードを同時にコンパイルすることも可能になります。

C++ と C はコンパイルと処理方法が異なるため、C 言語のコードセットをコンパイルしたいときは、__cplusplus マクロと extern "C" を使用する必要があります。

network.h には、network.c の関連インターフェイス宣言が格納されています。以下のコメント部分をコメントアウトすると:

// #ifdef __cplusplus
// extern "C"
// {
// #endif
#include <sys/types.h>
int netdev_set_flag(const char *name, int flag);
……
void new_hwaddr(char *hwaddr);
// #ifdef __cplusplus
// }
// #endif

gcc を使って直接 .o ファイルにコンパイルします。

gcc -c network.c nl.c

そして、以下のコードを使用します。

// test.cpp
#include "network.h"
int main() {
    new_hwaddr(nullptr);
    return 0;
}

コンパイルしてテストすると:

g++ test.cpp network.o nl.o -std=c++11

コンパイルに失敗し、undefined reference to 'new_hwaddr(char*)' というエラーが表示されることがわかります。

/usr/bin/ld: /tmp/ccz4DEEy.o: in function `main':
test.cpp:(.text+0xe): undefined reference to `new_hwaddr(char*)'
collect2: error: ld returned 1 exit status

つまり:

C ライブラリを C++ にコンパイルしてリンクしたいときは、インターフェイスの関連宣言を以下のようにラップする必要があります。

#ifdef __cplusplus
extern "C"
{
#endif
// C インターフェイス関数
#ifdef __cplusplus
}
#endif

このとき、network.cnl.c を再度 .o ファイルにコンパイルし、*.otest.cpp をコンパイルすると、正常にコンパイルできます。

コンテナネットワークの作成

前節の Docker のネットワーク原理に基づいて、作成したコンテナがネットワークをサポートするための手順を以下のようにまとめることができます。

  1. 一対の仮想ネットワークデバイス veth1/veth2 を作成する。
  2. veth1 の MAC アドレスを設定する。
  3. veth1 をブリッジ labex0 に追加する。
  4. veth1 を有効化する。
  5. 子プロセスを作成する。
  6. veth2 を子プロセスのネットワーク名前空間に移動し、eth0 に改名する。
  7. 子プロセスの終了を待つ。
  8. ネットワークデバイス veth1 と veth2 を削除する。

したがって、start() のロジックをさらに最適化する必要があります。

まず、docker::container_config にネットワーク関連の設定を追加します。

ヘッダーファイルをインクルードします。

#include <net/if.h>     // if_nametoindex
#include <arpa/inet.h>  // inet_pton
#include "network.h"

docker::container_config の設定を追加します。

// Docker コンテナの起動設定
typedef struct container_config {
    std::string host_name;      // ホスト名
    std::string root_dir;       // コンテナのルートディレクトリ
    std::string ip;             // コンテナの IP アドレス
    std::string bridge_name;    // ブリッジ名
    std::string bridge_ip;      // ブリッジの IP アドレス
} container_config;

次に、main.cpp でコンテナの IP アドレス、追加するブリッジ名 docker0、およびブリッジの IP アドレスを設定します。

int main(int argc, char** argv) {
    std::cout << "...start container" << std::endl;
    docker::container_config config;
    config.host_name = "labex";
    config.root_dir  = "./labex";

    // ネットワークパラメータを設定する
    config.ip        = "192.168.0.100"; // コンテナの IP アドレス
    config.bridge_name = "docker0";     // ホストのブリッジ
    config.bridge_ip   = "192.168.0.1"; // ホストのブリッジの IP アドレス

    docker::container container(config);
    container.start();
    std::cout << "stop container..." << std::endl;
    return 0;
}

上記のネットワークデバイスのロードロジックに基づいて、start() メソッドをリファクタリングします。

private:
    // 削除用にコンテナのネットワークデバイスを保存する
    char *veth1;
    char *veth2;
public:
void start() {
    char veth1buf[IFNAMSIZ] = "labex0X";
    char veth2buf[IFNAMSIZ] = "labex0X";
    // 一対のネットワークデバイスを作成する。一方はホストにロードし、もう一方は子プロセスのコンテナに移動する
    veth1 = lxc_mkifname(veth1buf); // lxc_mkifname API は、仮想ネットワークデバイス名に少なくとも 1 つの「X」を追加する必要があり、仮想ネットワークデバイスのランダム作成をサポートする
    veth2 = lxc_mkifname(veth2buf); // これはネットワークデバイスの正しい作成を保証するためです。詳細は network.c の lxc_mkifname の実装を参照
    lxc_veth_create(veth1, veth2);

    // veth1 の MAC アドレスを設定する
    setup_private_host_hw_addr(veth1);

    // veth1 をブリッジに追加する
    lxc_bridge_attach(config.bridge_name.c_str(), veth1);

    // veth1 を有効化する
    lxc_netdev_up(veth1);

    // コンテナ作成前のいくつかの設定作業
    auto setup = [](void *args) -> int {
        auto _this = reinterpret_cast<container *>(args);
        _this->set_hostname();
        _this->set_rootdir();
        _this->set_procsys();

        // コンテナ内のネットワークを設定する
        //...

        _this->start_bash();
        return proc_wait;
    };

    // clone を使用してコンテナを作成する
    process_pid child_pid = clone(setup, child_stack,
                      CLONE_NEWUTS| // UTS 名前空間
                      CLONE_NEWNS|  // マウント名前空間
                      CLONE_NEWPID| // PID 名前空間
                      CLONE_NEWNET| // ネットワーク名前空間
                      SIGCHLD,      // 子プロセスが終了すると親プロセスにシグナルを送信する
                      this);

    // veth2 をコンテナに移動し、eth0 として改名する
    lxc_netdev_move_by_name(veth2, child_pid, "eth0");

    waitpid(child_pid, nullptr, 0); // 子プロセスの終了を待つ
}
~container() {
    // 終了時に作成した仮想ネットワークデバイスを削除することを忘れないでください
    lxc_netdev_delete_by_name(veth1);
    lxc_netdev_delete_by_name(veth2);
}

注意:cloneCLONE_NEWNET を追加します。

上記の手順からわかるように、ネットワークデバイスを作成した後、子プロセスの作成中に、外部のネットワークデバイスと連携してコンテナ内で関連する設定を行う必要があります。

  1. コンテナ内の lo デバイスを有効化する。
  2. eth0 の IP アドレスを設定する。
  3. eth0 を有効化する。
  4. ゲートウェイを設定する。
  5. eth0 の MAC アドレスを設定する。
private:
void set_network() {

    int ifindex = if_nametoindex("eth0");
    struct in_addr ipv4;
    struct in_addr bcast;
    struct in_addr gateway;

    // IP アドレスをドット付き 10 進数とバイナリの間で変換する IP アドレス変換関数
    inet_pton(AF_INET, this->config.ip.c_str(), &ipv4);
    inet_pton(AF_INET, "255.255.255.0", &bcast);
    inet_pton(AF_INET, this->config.bridge_ip.c_str(), &gateway);

    // eth0 の IP アドレスを設定する
    lxc_ipv4_addr_add(ifindex, &ipv4, &bcast, 16);

    // lo を有効化する
    lxc_netdev_up("lo");

    // eth0 を有効化する
    lxc_netdev_up("eth0");

    // ゲートウェイを設定する
    lxc_ipv4_gateway_add(ifindex, &gateway);

    // eth0 の MAC アドレスを設定する
    char mac[18];
    new_hwaddr(mac);
    setup_hw_addr(mac, "eth0");
}

次に、コンテナの setup でこのメソッドを呼び出します。

……
_this->set_procsys();
_this->set_network();   // コンテナ内のネットワーク設定のための連携
_this->start_bash();
return proc_wait;

この時点で、network.onl.o のコンパイル済みリンクファイルを使用し始めたので、非常にシンプルな Makefile を書きましょう。

C = gcc
CXX = g++
C_LIB = network.c nl.c
C_LINK = network.o nl.o
MAIN = main.cpp
LD = -std=c++11
OUT = docker-run

all:
    make container
container:
    $(C) -c $(C_LIB)
    $(CXX) $(LD) -o $(OUT) $(MAIN) $(C_LINK)
clean:
    rm *.o $(OUT)

注意:Makefile のコマンドはスペースではなくタブで始める必要があります。これは、Markdown インタープリターがタブを 4 つのスペースに変換するためです。Makefile を書くときは、必ずタブを使用し、4 つのスペースではないようにしてください。そうしないと、Makefile は「Makefile:10: *** missing separator. Stop.」というエラーを表示します。

再度コンパイルして実行し、コンテナに入ります。ifconfig を使用してネットワークを確認することができます。

labex:project/ $ make
make container
make[1]: Entering directory '/home/labex/project'
gcc -c network.c nl.c
g++ -std=c++11 -o docker-run main.cpp network.o nl.o
make[1]: Leaving directory '/home/labex/project'
labex:project/ $ sudo./docker-run
...start container
root@labex:/## ifconfig
eth0      Link encap:Ethernet  HWaddr 00:16:3e:da:01:72
          inet6 addr: fe80::dc15:18ff:fe43:53b9/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:38 errors:0 dropped:0 overruns:0 frame:0
          TX packets:9 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:5744 (5.7 KB)  TX bytes:726 (726.0 B)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

まとめ

このプロジェクトを通じて、以下のことを段階的に実現しました。コンテナにファイルシステムを組み込み、外部ネットワークへのアクセスを可能にしました。

基本的な Docker コンテナを成功裏に作成しました。このコンテナをさらに最適化して、より現実的なエミュレーションを実現することができます。

✨ 解答を確認して練習✨ 解答を確認して練習✨ 解答を確認して練習✨ 解答を確認して練習✨ 解答を確認して練習✨ 解答を確認して練習✨ 解答を確認して練習✨ 解答を確認して練習✨ 解答を確認して練習