Go言語とコンテナ

Home » 媒体 » ASCII.jp » Go言語とコンテナ
ASCII.jp, ASCII.jp- プログラミング+, IT・インターネット コメントはまだありません




Goならわかるシステムプログラミング
第20回

Go言語によるプログラマー視点のシステムプログラミング

長かった本連載も今回が最終回です。 この連載では、プログラムがコンピュータ上で動くときに何が起きているのかを、Go言語のコードを通して覗いてきました。 今回は、その締めくくりとして、コンテナについて紹介します。 現在広く利用されているコンテナ技術であるDockerのコアは、Go言語製のlibcontainerというライブラリです。 このライブラリを使って自作のコンテナを仕立ててみます。

今回の原稿にあたっては、仮想化周りでsyohexさんに細かく指摘をいただきました。ありがとうございました。

仮想化

コンテナの話に入る前に、コンテナと目的がよく似た技術である仮想化について説明します。 仮想化は、コンテナよりも先に広く使われるようになった技術ですが、 歴史的にさまざまなソリューションがあり、どのような仕組みか、どのようなメリットがあるか、どのような制約があるか、どこにフォーカスするかで分類の仕方なども大きく変わります。 ここでは、仮想化とは何かについて、軽く概要だけ触れます。

ソフトウェアは、ハードウェア、そしてその上で動くOSの上で動作します。 ハードウェアを制御し、効率よくストレージやネットワークの入出力ができるようにしたり、メモリを管理したり、CPUの処理時間を割り振ったりするのは、OSの役割です。 そのハードウェアとOSの間にもう一つOS(もしくはOSのようなもの)の層を差し込むことで、1台のハードウェア上に複数のOSやシステムを安全に共存させ、ピークの異なる複数のサービスをまとめるなどして効率的にハードを使うのが仮想化です。 具体的には、完全な機能を持った普通のOS(ホストOS)の上にハードウェアをエミュレーションする仮想化のためのソフトウェアを作り、その上にゲストOSをインストールして使います(ゲストOSも普通のOSです)。

仮想化のためのソフトウェアとしては、Oracle VirtualBoxやHyper-V、Parallels、VMWare、QEMU、Xen Serverといったものが利用されています。 これらは、大きく分けて次の2つの方式に大別できます。

  • CPUを完全にエミュレーションすることで別のハードウェアのソフトウェアも使える方式。CPUをエミュレーションするため、どうしてもパフォーマンスは大きく落ちるが、現世代の高速なCPUを使って旧世代のCPU向けに書かれたアプリケーションを動作させるのに利用される
  • 同じアーキテクチャのCPUに限定されるものの、エミュレーションが不要で高速な方式

仮想化は、クラウドコンピューティングを支える大事な技術です。 仮想化そのものをサービスとして提供しているものは、Infrastructure as a Service(IaaS)と呼ばれます。 Amazon EC2やGoogle Compute Engine、さくらのVPSなど、OS環境を提供するサービスが登場したおかげで、ソフトウェアビジネスの構造は大きく変わりました。

仮想化は低レイヤの技術の組み合わせ

仮想化そのものは、OSの上でOSを動かすという、大掛かりで複雑な仕組みです。 ゲストOS上で動いているプログラムから、システムコール呼び出しなどの特権的な命令でホストOSが呼ばれてしまうと、仮想化していたつもりがおかしなことになってしまいます。 あるゲストOSで設定したCPUの状態(例えば、例外発生のモード変更)が、別のゲストOSに影響を与えてはいけません。ホストOSとゲストOSの間も同様です。 こうした要件は、システムを仮想化するのに満たすべき必要条件として、1974年に論文化されています。

最新のIntel系CPUは、この「PopekとGoldbergの要件」を満たす仮想化支援機能として、VT-xというものを備えています。 これは、第5回 Goから見たシステムコールで触れたユーザモード3)、特権モード0)の下に、ハイパーバイザー用OSのモード-1)を追加し、それを使うことで、ゲストOSからホストOSへの処理の移譲が必要な操作を効率よくフックできる仕組みです。 一見すると、WindowsやmacOSというホストOSがあり、その上のアプリケーションとして仮想化のシステムがいて、その中でゲストOSが実行されているように見えますが、CPUから見ると、ホストOSの下により強力な権限をもったレイヤーが追加されているというわけです。

現在のIntel系CPUには、VT-x以外にも、ゲストのメモリアドレスとホストのメモリアドレスとを変換する拡張ページテーブル、外部ハードウェアとのアクセスでホストOSを介さずに実行(PCIパススルー)できるようにするVT-d、ネットワークの仮想化のVT-cなど、さまざまな仮想化支援機能が実装されています。 これらの支援機能により、ハイパーバイザーが毎回割り込みをしてホストに投げなくても済むようになり、ネイティブに近い速度で仮想化が動くようになってきました。

ハイパーバイザーの側では、WindowsのHyper-V、macOSのHypervisor.framework、LinuxのKVMといった、OSが提供する支援機能を利用します。 高効率な仮想化は、CPUやOSなどさまざまな低レベルのレイヤーの手助けにより実現しているのです。

準仮想化

本文で紹介した仮想化の手法は「完全仮想化」と呼ばれるものです。これに対し、「準仮想化」について紹介している書籍も多くあります。 完全仮想化と準仮想化とでは、ハイパーバイザーやVirtual Machine Manager(VMM)と呼ばれるシステムがホストOS(あれば)やゲストOSの調停を行うのは同じですが、 OSの実行に必要だけど他に影響を与えうる命令(センシティブ命令)の扱いが異なります。

  • 完全仮想化では、ゲストOSがホストOS上にインストールされ、ゲストOSは自分が仮想環境で動いているのを意識する必要がない。 他に影響を与えうる命令を実行すると、割り込みが発生し、ハイパーバイザーがその処理を代行する
  • 準仮想化は、ハードウェア上にインストールされたハイパーバイザー(ホストOSはない)上で動作する。 ゲストOSは、自分がハイパーバイザーの上で動作していることを意識しており、他に影響を与えうる命令の代わりにハイパーバイザーを呼ぶ(hypercall)

準仮想化では、事前にセンシティブ命令を書き換えておくことで仮想化を実現します。 ハイパーバイザーの仕様に合わせてカスタマイズされたゲストOSを使うため、ホストOSは不要であり、全体のレイヤーが薄くなります。 また、センシティブ命令をハイパーバイザーによる例外処理の割り込みではなく効率の良い呼び出しに書き変えたり、パフォーマンス・チューニングを施したりすることで、完全仮想化よりも高いパフォーマンスを実現できます。 Amazon EC2も、初期には準仮想化メインで運用されていました。

完全仮想化では、センシティブ命令などの呼び出しに対してハイパーバイザーが割り込みを行い、適切に変更を局所化させます。 この方法ではパフォーマンスに難がありますが、本文で紹介したようなさまざまな支援機能をCPUが提供することで高速化が施され、欠点が改善されてきました。

準仮想化には、Windowsなどの外部からのカスタマイズが難しいOSは開発元の協力がないと動かせず、GPUなどの新しいハードウェア対応が難しいといったデメリットもあります。 そのため、徐々に完全仮想化のほうが優勢になっていきました。 Amazon EC2も、リストを見る限り、現在ではほぼ完全仮想化となっています(ただし、インスタンスタイプごとに、完全仮想化(HVM)と準仮想化(PV)とで対応具合が異なります)。

コンテナ

ここまでの説明からわかるように、仮想化は、使いたいサービスだけでなくOSも含めてまるごと動かすことが前提の仕組みです。 そのため、たとえばゲストOSとホストOSが同じLinuxであればカーネルやシステムのデーモンを重複してロードすることになり、無駄にメモリを消費してしまいます。

そこで、「OSのカーネルはホストのものをそのまま使うが、アプリケーションから見て自由に使えるOS環境が手に入る」の実現に特化したのがコンテナと呼ばれる技術です。 「アプリケーションが好き勝手にしても全体が壊れないような、他のアプリケーションに干渉しない・されない箱を作る」という機能だけ見ると、仮想化もコンテナも同じであるため、コンテナのことを「OSレベル仮想化」と呼ぶこともあります。

仮想化ではストレージをまるごとファイル化したような仮想イメージを使ってアプリケーションを導入しますが、コンテナでもイメージと呼ばれるものを使います。 Amazon EC2 Container Service、Azure Container Service、Google Container Engine(GKE)、さくらインターネットのArukas(β版)など、コンテナのイメージ上でアプリケーションのデプロイが可能になるサービスもあります。

一口にコンテナ技術といっても、内部では複数の機能を組み合わせて実現されています。 たとえばLinuxでは、コンテナを実現するためのOSカーネルの機能として、コントロールグループ(cgroups)および名前空間(Namespaces)があります。 これらの機能を組み合わせることで、さまざまなOSのリソースを、仮想メモリを用意するように気軽に分割できます。

コントロールグループ(cgroups)は、次の項目の使用量とアクセスを制限できるようにするカーネルの機能です。

  • CPU
  • メモリ
  • ブロックデバイス(mmap可能なストレージとほぼ同義)
  • ネットワーク
  • /dev以下のデバイスファイル

また、カーネルでは、次のような項目について名前空間(Namespaces)を分離できるようになっています。

  • プロセスID
  • ネットワーク(インタフェース、ルーティングテーブル、ソケットなど)
  • マウント(ファイルシステム)
  • UTS(ホスト名)
  • IPC(セマフォ、MQ、共有メモリなどのプロセス間通信)
  • ユーザー(UID、GID)

これらの機能については、次節でコンテナを自作しますが、そのコードを見れば概要がつかめるはずです。 より詳しい情報は、Surgoさんによる下記の記事などを参照してください。

コンテナと仮想化の関係

仮想化ではOSを起動する必要があるため、起動には長い時間がかかります。一方、コンテナはプロセスを起動するように仮想環境を構築できます。 コンテナのほうが効率が良いので、コンテナが仮想化を置き換えるとか、仮想化は古いというわけではありません。 コンテナのツールとして人気のDockerは、Linuxではコンテナだけを利用しますが、macOSやWindowsではOSが提供する仮想化の仕組みを使ってLinuxを動かし、その中でコンテナを利用します。 コンテナの中で動かしたいシステムがLinuxであれば、一度Linuxを動かす必要がありますが、現在サーバー開発ではLinuxがよく使われ、提供されているDockerイメージもほぼLinuxなので、Linux以外には厳しい状況です。

例外がFreeBSDです。 FreeBSDには昔から、Linuxバイナリを動かすエミュレーション機能があるので、FreeBSDをホストにしてDockerでLinuxイメージを使うと、FreeBSDのカーネルのままJailでコンテナ化し、コンテナ内部ではLinuxバイナリを動かすという動きになるようです。

また、Windowsは軽量なLinuxのコンテナ相当のWindowsコンテナと、仮想化もプラスして他のOSも起動できるHyper-Vコンテナの2種類あります。

libcontainerでコンテナを自作する

現在、Dockerのコアとなっているのは、Go言語で書かれているlibcontainerというライブラリです。 このライブラリを利用してGo言語でコンテナを実装し、Linux上での起動に挑戦してみましょう。 今回のコードのサンプルはLinuxバイナリが直接実行できる環境でしか動作しません。 WindowsやmacOSをお持ちの方は、VirtualBoxなどの仮想環境をインストールして、Ubuntu Linuxなどを入れて試してください。

なお、言うまでもありませんが、コンテナの実装はDockerだけではありません。 Dockerにはrktというライバルもいて、こちらを推す人も少なくないのですが、rktはlibcontainerと違ってライブラリとして使うことが想定されていないので、今回はlibcontainerを取り上げます。

Dockerとlibcontainer

当初、Dockerは、Linuxにおけるコンテナ機能のためのユーティリティであるLXCのラッパーでした。現在のDockerは、libcontainerをベースに書き直されたものです。 さらにその後、libcontainerを含むコア部分はrunCというツールになりました。 それによってlibcontainerがなくなったわけではなく、現在もrunCのディレクトリ内に同梱されています。

runCは、Open Container Initiativeに寄贈されています。 OCI傘下になったことで、今後はさまざまなプラットフォームにも対応されていくことでしょう。

なお、ここで紹介する自作はコンテナは実験的なものであり、実用面ではrunCコマンドやDockerを使うほうがはるかに簡単でお手頃です。

OSのブートに必要な下準備

コンテナとなるコードを書く前に、コンテナ内で新しいOSを起動するために必要なファイル一式を用意する必要があります。 ここではサイズが小さいAlpine Linuxのイメージに含まれているファイルを利用させてもらうことにします。 必要なファイルは下記の要領で取得できます。

$ docker pull alpine ⏎
Using default tag: latest
latest: Pulling from library/alpine
2aecc7e1714b: Pull complete
Digest: sha256:0b94d1d1b5eb130dd0253374552445b39470653fb1a1ec2d81490948876e462c
Status: Downloaded newer image for alpine:latest
 
$ docker run --name alpine alpine ⏎
 
$ docker export alpine > alpine.tar ⏎
 
$ docker rm alpine ⏎

これで、ファイルシステムの中身がtarファイルとして取り出せました。 作業フォルダにrootfsというフォルダを作り、このtarファイルを展開しておきましょう。

$ mkdir rootfs ⏎
 
$ tar -C rootfs -xvf alpine.tar ⏎

libcontainerを利用してコンテナを作る方法は、libcontainerライブラリのREADMEにほとんどそのまま書いてあります。 それを参考にコンテナを作成してみましょう。

まずは必要なライブラリを取得してきます。 libcontainerライブラリのほかに、unixパッケージをインストールしてください。

$ go get github.com/opencontainers/runc/libcontainer ⏎
$ go get golang.org/x/sys/unix ⏎

それでは実装を見ていきましょう。

libcontainerを使うときは、main()関数のほかに、init()関数を定義します。 これは、Linuxの起動時には最初に呼ばれるinitプロセスが必要なためで、その処理をinit()以下に集約します。

コンテナの生成とinitプロセスの起動とを1つの実行ファイルで実現するには、自分自身をinitプロセスとして呼び出すモード(InitArgsの部分)をmain()の中で利用します。 その部分までのコードを下記に示します。

package main
 
import (
    "github.com/opencontainers/runc/libcontainer"
    "github.com/opencontainers/runc/libcontainer/configs"
    _ "github.com/opencontainers/runc/libcontainer/nsenter"
    "log"
    "os"
    "runtime"
    "path/filepath"
    "golang.org/x/sys/unix"
)
 
func init() {
    if len(os.Args) > 1 && os.Args[1] == "init" {
        runtime.GOMAXPROCS(1)
        runtime.LockOSThread()
        factory, _ := libcontainer.New("")
        if err := factory.StartInitialization(); err != nil {
            log.Fatal(err)
        }
        panic("--this line should have never been executed, congratulations--")
    }
}
 
func main() {
    abs, _ := filepath.Abs("./")
    factory, err := libcontainer.New(abs, libcontainer.Cgroupfs,
                                     libcontainer.InitArgs(os.Args[0], "init"))
    if err != nil {
        log.Fatal(err)
        return
    }

main()関数の以降の大部分は、生成するコンテナの環境設定です。 ホスト名(Hostname)やマウントするファイルシステム(Mounts)などをconfigインスタンスに設定していきます。 あらかじめ用意した仮想OSの実行環境もここで指定します(Rootfs)。


    capabilities := []string{
        "CAP_CHOWN",
        "CAP_DAC_OVERRIDE",
        "CAP_FSETID",
        "CAP_FOWNER",
        "CAP_MKNOD",
        "CAP_NET_RAW",
        "CAP_SETGID",
        "CAP_SETUID",
        "CAP_SETFCAP",
        "CAP_SETPCAP",
        "CAP_NET_BIND_SERVICE",
        "CAP_SYS_CHROOT",
        "CAP_KILL",
        "CAP_AUDIT_WRITE",
    }
    defaultMountFlags := unix.MS_NOEXEC | unix.MS_NOSUID | unix.MS_NODEV
    config := &configs.Config{
        Rootfs: abs+"/rootfs",
        Capabilities: &configs.Capabilities{
            Bounding: capabilities,
            Effective: capabilities,
            Inheritable: capabilities,
            Permitted: capabilities,
            Ambient: capabilities,
        },
        Namespaces: configs.Namespaces([]configs.Namespace{
            {Type: configs.NEWNS},
            {Type: configs.NEWUTS},
            {Type: configs.NEWIPC},
            {Type: configs.NEWPID},
            {Type: configs.NEWNET},
        }),
        Cgroups: &configs.Cgroup{
            Name: "test-container",
            Parent: "system",
            Resources: &configs.Resources{
                MemorySwappiness: nil,
                AllowAllDevices: nil,
                AllowedDevices: configs.DefaultAllowedDevices,
            },
        },
        MaskPaths: []string{
            "/proc/kcore", "/sys/firmware",
        },
        ReadonlyPaths: []string{
            "/proc/sys", "/proc/sysrq-trigger", "/proc/irq", "/proc/bus",
        },
        Devices: configs.DefaultAutoCreatedDevices,
        Hostname: "testing",
        Mounts: []*configs.Mount{
            {
                Source: "proc",
                Destination: "/proc",
                Device: "proc",
                Flags: defaultMountFlags,
            },
            {
                Source: "tmpfs",
                Destination: "/dev",
                Device: "tmpfs",
                Flags: unix.MS_NOSUID | unix.MS_STRICTATIME,
                Data: "mode=755",
            },
            {
                Source: "devpts",
                Destination: "/dev/pts",
                Device: "devpts",
                Flags: unix.MS_NOSUID | unix.MS_NOEXEC,
                Data: "newinstance,ptmxmode=0666,mode=0620,gid=5",
            },
            {
                Device: "tmpfs",
                Source: "shm",
                Destination: "/dev/shm",
                Data: "mode=1777,size=65536k",
                Flags: defaultMountFlags,
            },
            {
                Source: "mqueue",
                Destination: "/dev/mqueue",
                Device: "mqueue",
                Flags: defaultMountFlags,
            },
            {
                Source: "sysfs",
                Destination: "/sys",
                Device: "sysfs",
                Flags: defaultMountFlags | unix.MS_RDONLY,
            },
        },
        Networks: []*configs.Network{
            {
                Type: "loopback",
                Address: "127.0.0.1/0",
                Gateway: "localhost",
            },
        },
        Rlimits: []configs.Rlimit{
            {
                Type: unix.RLIMIT_NOFILE,
                Hard: uint64(1025),
                Soft: uint64(1025),
            },
        },
    }

最後に、ここまでの部分で作ったconfigインスタンスをCreate 関数に渡してコンテナを作ります。 コンテナ内部で起動するプログラムは&libcontainer.Processで指定できます。 ここではシェルを起動するようにしましょう。


    container, err := factory.Create("container-id", config)
    if err != nil {
        log.Fatal(err)
        return
    }
 
    process := &libcontainer.Process{
        Args: []string{"/bin/sh"},
        Env: []string{"PATH=/bin"},
        User: "root",
        Stdin: os.Stdin,
        Stdout: os.Stdout,
        Stderr: os.Stderr,
    }
 
    err = container.Run(process)
    if err != nil {
        container.Destroy()
        log.Fatal(err)
        return
    }
 
    _, err = process.Wait()
    if err != nil {
        log.Fatal(err)
    }
 
    container.Destroy()
}

以上でコンテナの実装は終了です。 さっそく実行してみましょう。 コンテナの中でシェルが起動するはずです。 そのシェルで/bin/hostnameを実行すると、上記コードで設定したホスト名である「testing」が表示されることがわかります。

$ go build -o container container.go ⏎
 
$ sudo ./container ⏎
[sudo] shibuのパスワード: [sudoパスワードを入力] ⏎
/bin/sh: can't access tty; job control turned off
/ # /bin/hostname ⏎
testing

なお、このconfig構造体も含め、各サブ構造体はJSONでのシリアライズが可能なタグが付いており、JSONから読み込むこともできます。 コンテナ構造体を使えば、プログラムを使って外部からコンテナを操作することも可能です。

// コンテナ内部で動作しているプロセスIDのリストを[]int形式で返す
processes, err := container.Processes()
 
// CPU、メモリ、I/O、コンテナの統計情報取得
stats, err := container.Stats()
 
// コンテナを停止
container.Pause()
 
// コンテナを再開
container.Resume()
 
// コンテナのinitプロセスにシグナル送信
container.Signal(signal)

まとめ

最終回として、最近話題になることが多いコンテナについて説明しました。 どちらかというと、Kubernetes、Docker Swarm、Mesosといった大規模なオーケストレーションの方面に話題がシフトしていっていますが、今回の話はその下で行われている基礎の説明になります。

コンテナはOSが持つリソースに「壁」を作って、そのプロセス専用の環境を作ることでした。 連載ではネットワーク、ファイルシステム、プロセス、並列処理、メモリをそれぞれ何回かに分けて紹介してきました。 コンテナが扱うものはそのすべてです。

付録:この連載で扱わなかった低レイヤの話題

Go言語でサポートしていないPOSIXの機能

次のような機能はGo言語の標準ライブラリでサポートしていないため説明しませんでした。 例えば、共有メモリのシステムコールも定数だけはコードにあったりはしますが、Go言語のランタイムにないということはあまり需要もなかったり、代替手段があったりします。

  • POISXの共有メモリ
  • POSIX MQ
  • POSIX非同期I/O
  • UDPConn.RecvMsgUDPConn.SendMsgの制御命令バイト列の読み書き

IEEE752の浮動小数点数や数値の表現、SIMD

数値周りも、本来であればきちんと理解すべきトピックです。しかし本連載ではCPU内部の話は取り扱わず、OSとのやりとりに関係する部分にフォーカスしたこともあり、システムで数値をどう扱っているかについては触れませんでした。

Go言語でのSIMDは昔よりはやりやすくなっていて、x86命令のバイナリ表現を手書きする必要はなくなってきていますが、アセンブリを書かなければならない点は変わっていません。

ロギング

ロギングに利用される定番ツールとしては、syslog/logstash/fluentdなどがあります。 syslogは、かつてはシステムプログラミングの話題の中で説明されることもありましたが、現代においてはデータ処理のパイプラインを担うミドルウェアとして扱われることが多く、OSのサービスからは少し離れます。 そのため、本連載では特に取り上げませんでした。

GUI

GUIは、OSごとに差異が大きすぎる上に、それなりに中身のある話をするとなるとボリュームが極めて大きくなるので、本連載では取り扱いませんでした。 おそらくフォントのハンドリングとテキスト描画だけで本が書けます。

暗号化/乱数

OSには、暗号論的擬似乱数生成器など、暗号化にかかわるAPIもいくつかあります。 macOSのSecurity FrameworkであるSecKeychainAddGenericPassword()や、WindowsのCryptProtectData、GnomeキーリングやKwalletなどのセキュアなデータ保存など、便利な機能がたくさんありますが、OS間の差異も大きく、暗号も背後にある理論的な説明が多くなりすぎてしまうため本連載では取り上げませんでした。

Go言語で作成したプログラムのブートストラップ

システムプログラミングやカーネルの本の締め括りとして必ずといっていいほど取り上げられるのは、プログラムの起動シーケンスの説明です。 興味がある方は、英語ですが、次のブログ記事などをご覧ください。

あとがき

20回に渡り、Go言語によるシステムプログラミングについて取り上げてきました。 連載にあたって心がけたのは次の3点です。

  • システムコール周辺の関数の使い方を紹介するだけではなく、その後ろで一生懸命仕事をしているカーネルのようすが見えるようにする
  • 2020年が近い年に新しく書き下ろすので、過去の本になかった新しいトピックを入れる(古いトピックを削る)
  • Linuxなどの特定の環境だけでなく、WindowsやmacOSを使っている人でもなるべく幅広く読めるようにする

Go言語を題材として選択したのはランタイムの奥まで気軽にのぞける言語だからです。Go言語そのものの説明が目的ではなかったため、Go言語を学ぼうと思っていなかった人にはとっつきにくい内容もあったかもしれません。 しかし、コンピュータシステムを学ぶ言語の選択肢が事実上C言語ばかりというなかで、間口を広げる貢献はできたと思います。 次は皆さんが、自分の得意な言語でトライしてみてください。

記事の執筆にあたっては、会社のメンバーとの会話や社内の読書会、プログラマー仲間との普段からのチャットなどから学んだこと、着想を得たことが助けとなりました。 時には、分からないことを質問して教えてもらったり、原稿のレビューをしてもらったりと、一人では超えられなかった壁を超えることができました。 また、編集の鹿野さんには、日本語の校正にとどまらず、読者に伝わりやすくするために説明の順序を大幅に入れ替えたり、雪だるまの挿絵をたくさん入れていただきました。 もちろん、完走できたのは、最後までお付き合いしていただき、時には厳しく指摘もしていただいた読者のみなさんのおかげです。 どうもありがとうございました。

脚注


カテゴリートップへ




この連載の記事



コメントを残す