はじめに
本記事ではGolangでdockerを作る!を目指してるんですが、実際はLinuxのcgroups, namespaceの機能を使って簡単なdockerの機能を実装します。これを通してdockerに関する理解をより深めるのが目標です。自分はLinuxとdockerに関して知識が浅いので、ツッコミどころはどんどんコメントでツッコンでください。
なぜdocker
半年前に初めてdockerを使って、Virtual Machineしか使ったことない自分はその利便さに感動しました。感動した点としては:
1. 起動が早い。
2. 隔離した環境内で元のシステムを汚染せず色々試せる。
3. 環境設定とサービスをまとめた自作コンテナを作成でき、そのままデプロイできる。
まず従来のVirtural Machineとの違いを見てみます。
Virtual Machineはよく知ってる人は多いと思うので、比較してみると
早いのは
- dockerの操作はHostOS、実際はLinux Kernelが提供してるAPIを叩いてるだけ、普通にシステム上の一つのプロセス的なイメージ。
- virtual machineではGuestOSを通してHostOSに命令を出してる、かつ様々nGuestOSが動くようにハードウェアレベルのVirtualizationが必要なので、これが負担になる。
環境隔離
- dockerはLinuxの機能であるcgroups, namespaceを使ってシステムレベルで分割してる
- 稼働中のコンテナは基本的にシステム上の一つのプロセス的なイメージ(実際linuxのchrootのコマンドでプロセスの実行環境を隔離できる)
コンテナイメージ
- vmのイメージは完全体のOSが入ってるのに対して、dockerではOS自体はなくてもよく、使う機能をminimumなイメージだけでok.
- vmのisoファイルは編集できないが、dockerのレイヤー構造からコンテナ内で編集した内容は元のイメージも合わせて新しいイメージとして書き出せます。
-> 同じようにLinuxのnamespace, cgroupsを使えばdocker作れるんじゃない?!
何を実現したいか
一言でゆうとdockerではnamespaceとcgroupsの機能を使ってシステムからの資源分離を実現しているので、同じようにこれらの機能使ったcliアプリケーションを作ればdockerと似たような機能を実現できるのでは。
まずdockerを使って、実際どうなってるか確かめてみます。
docker pull busybox
docker run -it --rm busybox
これでbusyboxを入れたコンテナのshellに入れます。
実際コンテナ内でコマンドをたたいて確認すると。
ファイルシステム、pid、uidの分離が確認できる。そして環境内で初めて走ったプログラムはshellなので、shのPIDが1になってることがわかる。
元のmac osで確認しても、process idとuser idが完全に分離されてることがわかる。
-> まずはこれと同じ分離ができるCliアプリケーションを作る
golangでCliアプリケーションを作る。
目標はgolangで、以下を実現する
- dockerと同じようにnamespace分離した"コンテナ"を作る。
- cgroupsを使ってコンテナが使える資源を制限する。
実際これはdockerと言うよりはLXC(Linux container)の機能。詳細はhttps://rest-term.com/archives/3287/
最終的にNetworkの分離や、コンテナ内で編集した内容をイメージとして書き出す機能も実現したいので、それを加えればdockerの機能に近ずくイメージです。
namespaceの分離
使うライブラリは
- https://github.com/urfave/cli goで簡単にcliアプリケーションを作れるライブラリ
- "syscall" https://golang.org/pkg/syscall/
Package syscall contains an interface to the low-level operating system primitives.
書かれてる通りsyscallではOSのlow-levelの機能を呼ぶinterfaceがあるので、これを通してnamespaceやcgroupsのコマンドを呼ぶ。
やること再確認
- 実行して、namespaceを分離する。
- 分離した環境内で/bin/shでshellを呼び出す。
- 呼び出したshell内で環境を確認する。
urfave/cliの使い方
ここでは詳しく説明しないですが、この記事を参考にすると簡単に動かし方がわかると思います。
https://qiita.com/gatchan0807/items/4ffdf65f7affe8faec5a
実際のコード
上で書いた実現したいことを元に実装します。
main.go
package main
import "github.com/urfave/cli"
import "os"
import "fmt"
const usage = "build a simple docker"
func main() {
app := cli.NewApp()
app.Name = "simpleDocker"
app.Usage= usage
// ここで実際に実行するcommandをいれる
app.Commands = []*cli.Command{
&initCommand,
&runCommand,
}
// command lineからの引数を読んでアプリを実行する
err := app.Run(os.Args)
if err != nil {
fmt.Errorf(err.Error())
}
}
ここではCliを実行する際に実行される、二つのコマンドを登録します。
- runCommand namespaceを分離した環境を作る。
- initCommand 分離した環境内での初期設定を行う。
では実際のコマンドの内容を見てみます。
var ttyFlag = cli.BoolFlag{
Name: "ti",
Usage: "enable tty",
}
var runCommand = cli.Command{
Name: "run",
Usage: "create container",
Flags: []cli.Flag{
&ttyFlag,
},
Action: func(context *cli.Context) error {
cmd := context.Args().Get(0)
tty := context.Bool("ti")
Run(tty, cmd)
return nil
},
}
var initCommand = cli.Command{
Name: "init",
Usage: "inti process",
Action: func(context *cli.Context) error {
cmd := context.Args().Get(0)
err := RunContainerInitProcess(cmd, nil)
if err != nil {
fmt.Errorf(err.Error())
}
return nil
},
}
ttyFlagは分離した環境内でshellを起動するかチェックしてます。ここではrunCommandとinitCommandのNameと動作を指定してます。実際に引数でrun, initを入れると呼び出されます。
- initCommand -> RunContainerInitProcess
- runCommand -> NewParentProcess
// cloneした新しいプロセスを返す
func NewParentProcess(tty bool, command string) *exec.Cmd {
args := []string{"init", command}
cmd := exec.Command("/proc/self/exe", args...)
// namespaceの分離設定
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID
| syscall.CLONE_NEWNS | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
}
// 標準入出力をprocess内に送る
if tty {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
return cmd
}
// コンテナ内の初期設定
func RunContainerInitProcess(command string, args []string) error {
// ファイルシステムをmountする際の一般的な設定
defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
argv := []string{command}
if err := syscall.Exec(command, argv, os.Environ()); err != nil {
fmt.Errorf(err.Error())
}
return nil
}
func Run(tty bool, command string) {
parent := NewParentProcess(tty, command)
if err := parent.Start(); err != nil {
fmt.Errorf(err.Error())
}
// parentプロセスが終了するのまでwaitします
parent.Wait()
os.Exit(-1)
}
この部分が核心になってくるんですが、
まずNewParentProcess
をみます, ここではnamespace分離したプロセスを作って返す事をやってます、chrootみたいな事ですね。
- syscall_CLONE_NEWUTS -> 新しいUTS namespaceを作る。hostnameをコンテナで変更しても、元のシステムに影響はないです。
- syscall_CLONE_NEWIPC -> 新しいIPC namespaceを作る、これはプロセス間の通信環境を分離してます。
- syscall_CLONE_NEWPID -> これでコンテナ内の初めてのプロセスのPIDが1になるはず。
- syscall_CLONE_NEWNS -> ファイルシステムを分離します、これでコンテナ内でMountしても、元の環境に影響がないです。
- syscall_CLONE_NEWNET -> ネット関連設備の分離をします。コンテナないでは分離された後ifconfigを叩いても何も表示されない想定
procファイル
ここでいきなり、procファイルの説明を入れるですが、/procは普通のファイルと違いkernelの情報得るためのinterface的な存在です。ちなみにmac/winはこのファイルはないです。linuxの複数のコマンドではこのファイルの内容を読んでるだけです。例えば、lsmodはcat/proc/modulesしてるだけ。
proc/self/exe
ここでselfは元のシステムから見た場合のPIDを指しており、cmd := exec.Command("/proc/self/exe", args...)
はすなわち自分で自分呼ぶ。現在のプロセス内で初期化のプロセスを呼んでることです。
RunContainerInitProcess
はこの呼ばれる初期化プロセスです。ここでやってることは、元のシステムの/procファイルを分離したコンテナ内にMountします。
なぜこれをやるのかと言うと、現状ではpsを呼んでも元のprocファイルを読んでるので現在環境の
ここでMountすれば現在の環境内に沿ったprocファイルで置き換えられるかつMount namespaceを分離してるので元の環境にも影響はないです。
コード実行してみる
以上でコードの説明をしたので、実際にビルドして見ます。
図の内容でわかるように、
- root userになっており、最初のプロセス/bin/shのpidも1になってます. PID namespace, Mount namespace, uts namespaceの分離ができてることがわかります。
- ifconfigを実行しても、何も表示されないので、network namespaceの分離もできてる。
dockerと違うのは、ls
で元のシステムのファイルが表示されます、これは子プロセスでは親プロセスのファイルシステムを継承しているので、これではおかしいですね。子プロセスでファイルの削除をしたら、親のファイルも消えます。Mount namespaceを分離しても、Mount/UnMountの操作が影響ないだけで、ファイルを削除すると消えます。
pivot_root
実際dockerではpivot_root命令で新しいファイルシステムをマウントしてます。
ここの記事を参考にするとhttp://www.liushao.net/b/pivot_root-vs-chroot/
func MountNewRoot() {
syscall.Mount("rootfs", "rootfs", "", syscall.MS_BIND, "")
os.MkdirAll("rootfs/oldrootfs", 0700)
syscall.PivotRoot("rootfs", "rootfs/oldrootfs")
os.Chdir("/")
}
このコードをprocファイルをマウントする、NewParentProcess関数の中で読み込めば。現在のrootファイルシステムを置き換えて、新しいrootシステムを現在のプロセスにマウントします。ビルドして確認すると。
これで新しいファイルシステムをマウントしました。もちろんmount namespaceを分離してるので、元のシステムに対して影響はないです。
まとめ
本記事ではdockerの機能を実現したい願望で、golangを使ったcliアプリケーションを作りました。実際まだ、namespaceの分離しかできてないので、まだdockerからはとお〜いです。
cgroupsの機能を使って資源制限までやりたかったですが,長くなりそうなので「golangを使ってdockerのようなcliアプリケーションを作成2」でやります.次の記事ではdockerのレイヤー構造に関して説明し,実装してみますので、興味のある方は是非読んで指摘してもらえれば助かります。