InfoQの記事「100行以下のGoでコンテナを作る」をやってみた

InfoQの記事「100行以下のGoでコンテナを作る」をやってみた

InfoQの「Build Your Own Container Using Less than 100 Lines of Go」という記事を読んで、コンテナが技術的にどのように成り立っているのか、大変面白く学ぶことができました。本ブログでは、その記事の内容を紹介していきます。

Build Your Own Container Using Less than 100 Lines of Go
Shipping containers and software containers share a lot in common, but the analogy has limits. This article explores this relationship further by demonstrating how it is possible to build a simple container using less than 100 lines of Golang code. Topics covered include namespaces, cgroups and laye…

記事の概要

InfoQの記事では、コンテナ技術の基盤となる仕組みを解説しながら、Go言語を用いてシンプルなコンテナを構築する方法を紹介しています。
コンテナは、以下の3つの主要技術を活用して構築されています。

①Namespaces
プロセスやネットワークを隔離することで、コンテナごとに独立した環境を作成します。

PID 名前空間 : あるプロセスがカーネルにプロセス一覧を要求するとまるで自身と子プロセスのみが存在しているように見せます

MNT(マウント)名前空間 : ディレクトリをマウントし、ホストを含む他の名前空間に影響を及ぼさないようにします。またpivot_rootシステムコールを使うと、ホストとは別のファイルシステムを持つことができ、これによって自分自身がUbuntu、BusyBox、Alpine などホストとは異なる環境で動作しているように見せることができます

USER(ユーザー)名前空間 : プロセスが認識するuid(ユーザーID)をホスト上の異なるuidにマッピングします。コンテナのuidを0だと認識させることで、自分がまるでroot権限を持っていると思い込ませつつ、ホストでは権限が無いのでセキュリティの観点からも強力な仕組みです。

そのほかにもNET(ネットワーク)名前空間、UTS(UNIX Time-sharing System)名前空間、IPC(Inter-Process Communication)名前空間の分離を行っています。

②cgroups
CPUやメモリなどのリソースを制限し、各コンテナが公平に(または設計次第では不公平に)リソースを使用できるように管理します。

③Layered Filesystems
Dockerでは
[ ベースOS ]
[ 依存ライブラリ ]
[ アプリケーション ]
のような層状のファイルシステムになっており、この仕組みのおかげで、ベースOSを毎回コピーする必要がなく、変更があった部分だけを管理しています。
そうすると、ダウンロードが早くなったり、起動が早くなるなどの利点があります。

Go言語でコンテナを作成する

記事では、Go言語を用いて100行未満のコードでシンプルなコンテナを作成する方法を紹介しています。

package main

import (
	"fmt"
	"os"
	"os/exec"
	"syscall"
)

func main() {
	switch os.Args[1] {
	case "run":
		parent()
	case "child":
		child()
	default:
		panic("wat should I do")
	}
}

func parent() {
	cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
	}
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		fmt.Println("ERROR", err)
		os.Exit(1)
	}
}

func child() {
	must(syscall.Mount("rootfs", "rootfs", "", syscall.MS_BIND, ""))
	must(os.MkdirAll("rootfs/oldrootfs", 0700))
	must(syscall.PivotRoot("rootfs", "rootfs/oldrootfs"))
	must(os.Chdir("/"))

	cmd := exec.Command(os.Args[2], os.Args[3:]...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	if err := cmd.Run(); err != nil {
		fmt.Println("ERROR", err)
		os.Exit(1)
	}
}

func must(err error) {
	if err != nil {
		panic(err)
	}
}

このコードは、Go言語の exec.Command を利用して、新しいプロセスを生成し、その中で隔離された環境を作成する仕組みを持っています。

実行時には rootfs ディレクトリを作成し、 go run main.go run ls のような形で引数を渡して実行します。

parent()内にある、"/proc/self/exe"というのがLinux特有のもので、Macでは動かすことができません。
"/proc/self/exe"には現在実行中のプロセスの実行ファイルがシンボリックリンクとして置かれる仕組みになっていて、実行中に自分自身を再実行しているイメージです。

コードのポイント

parent() 関数では、新しいプロセスを生成し、syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS のフラグを設定することで、新しい名前空間を作成。

syscall.CLONE_NEWUTS → ホスト名 (hostname) を分離
syscall.CLONE_NEWPID → プロセス ID (PID) を分離
syscall.CLONE_NEWNS → マウント (ファイルシステム) を分離

child() 関数では、pivot_root を用いてファイルシステムのルートを変更し、隔離された環境を構築。

このコードを実行すると、通常の環境とは異なる独立したコンテナ環境が起動します。

最後に

はじめにMacでは上記の理由から起動できず、Docker for Macのubuntuイメージで実行していました

作成したコンテナ環境でls ( go run main.go run ls)を実行しようとしましたが、child関数にあるようにルートディレクトリを分離していて /bin ディレクトリなんかもありませんから、実行できず

ホストのlsファイルのシンボリックリンクをrootfsに設置してgo run main.go run ./ls としても実行できず

PivotRootで旧ルートディレクトリ(ホストの/)がoldrootfsにマウントされているので、 go run main.go run /oldrootfs/bin/ls としても実行できず

goで hello と標準出力する実行ファイルを配置しても実行できずでして
まだ完全に動かせてはいないのですが、逆に本当にホストと分離された環境になってしまっているんだな…と感じ、とてもおもしろい経験でした!

本物のコンテナにするには、cgroupsなどなど他にも色々必要なようです。(記事内では「それらをすると100行を超えてしまうかもしれないね!」とジョークを飛ばされていました)

どうやって隔離された環境が作られているのか、魔法のように感じていたコンテナ技術も、システムコールや既存のOSの機能を組み上げて作られていると思うととても胸が熱くなるな〜と思いました!
ホスト名を分離するシステムコールがあるのとかも面白いですよね

また色々実験してみようと思います!
おしまい