RubyKaigiに行くと本にサインを求められるすごいエンジニアが書いたhaconiwaというmruby製のコンテナエンジン(コンテナ環境構築の基盤ツール)があるのですが、少し試してみようと思って、とりあえず1サーバ上に1万コンテナぐらい動かそうとしてみました。久々に今回は自分の作ったOSSではなく、OSSの検証レポート的な記事になります。
haconiwaは僕の好きなOSSの一つで、それはなぜかと言うと、
- haconiwaでコンテナを作る際に、haconiwa実行環境にはコンテナの要素機能が全て入っている必要はない
- 必要なコンテナの要素機能を簡単に組み合わせて、自分が実現したいコンテナ、あるいは、それに準ずる環境を作れる
- haconiwaによるコンテナ定義をRubyのDSLで表現でき、動的な設定や組み合わせの設定を簡単にかける
ということができるからです。その特性から、CentOS6のような比較的ライブラリが古い環境でも、要求に応じて簡単に動かす事ができます。
コンテナエンジンのようなソフトウェアでコンテナ環境を作る時に、これまではコンテナに関する機能を多数要求し、それを満たせない環境では使うことができなかったり、作ったり試すために非常に新しい環境や不安定な環境を必要とするパターンが多かったのですが、haconiwaは動作させるOSが提供している機能だけを使って、その状況状況に合わせて作りたいコンテナ(例えばchrootとcgroupだけ使いたい、とか)を作ることができます。さらに、設定をRubyのDSLで簡単に書けるため(その良さは後述)、拡張性と生産性を高度に実現しているコンテナエンジンなのではないかと思っています。
ということで、まずは早速試してみました。
haconiwaをビルドする
今回試す環境は、Kernelは4.8系が動いているけど、glibcは2.1.2が動いているという謎環境、というか、僕の手元のCentOS6テスト環境なのですが、そこでhaconiwaをビルドして試してみました。例えばこういう特殊な状況においても、haconiwaは上記に挙げた特性を活かして、利用者が作りたい仕様のコンテナを簡単に作る事ができます。
今回使ったハードウェアスペックは、CPU24コア、メモリ32GBです。
haconiwaのビルドは非常に簡単で、
rake compile
としてやれば良いだけです。ビルドで詰まった所としては、
- libcapのビルドにkernel-headersがいる
- glibc2.1.2はsetnsの呼び出し関数が実装されておらずその場合ビルドがエラーになる
あたりですが、後者については隣のエンジニアにソっと告げると、あっという間に改修して頂けるので非常に仕事が捗るわけですね。
そしてビルドが通ると、haconiwa/mruby/bin/haconiwa
バイナリができるので、それをどこかにコピーして使ってみましょう。
コンテナ環境を作る
haconiwaはbootstrap的にlxcなどを使ってコンテナ環境のテンプレートを作ってくれる便利機能があるのですが、今回はそういうものが入っていない環境でしたので、普通にchroot環境を作って試すことにしました。
そこで、Apache+mod_phpが動いて、phpinfo.phpがひとまず動くような環境をudzuraさんにいただきました(これはまた今度公開します)。このあたりは、chroot環境の作り方がこれまでにも沢山色々なサイトやツールで紹介されていると思うので省略します。普通にchroot環境をつくれば良いというイメージです。
そのchroot環境を/で固めたtarファイルをもらった上で、haconiwaバイナリでDSLテンプレートを作ります。テンプレートは以下のコマンドで簡単に作成できます。
$ ./haconiwa new phpinfo.haco assign new haconiwa name = haconiwa-2d057697 assign rootfs location = /var/lib/haconiwa/2d057697 create phpinfo.haco
これで、DSLテンプレートができました。
まずは、haconiwaの最小設定などを試すべく、コメントを読みながら以下のようにDSLの定義をしました。
unless ENV['APACHE_PORT'] raise "env APACHE_PORT not found" end Haconiwa.define do |config| # The container name and container's hostname: config.name = "haconiwa-2d057697-#{ENV['APACHE_PORT']}" # The first process when invoking haconiwa run: config.init_command = ["/usr/bin/env", "APACHE_PORT=#{ENV['APACHE_PORT']}", "APACHE_PID=/var/run/httpd/httpd.#{ENV['APACHE_PORT']}.pid", "/usr/sbin/httpd", "-X"] # If your first process is a daemon, please explicitly daemonize by: config.daemonize! # The rootfs location on your host OS # Pathname class is useful: root = Pathname.new("/var/lib/haconiwa/2d057697") config.chroot_to root # mount point configuration: config.add_mount_point "tmpfs", to: root.join("tmp"), fs: "tmpfs" # Share network etc files from host to contianer # You can reuse /etc/netns/${netnsname}/* files: config.mount_network_etc(root, host_root: "/etc") # Re-mount specific filesystems under new container namespace # These are recommended when namespaces such as pid and net are unshared: config.mount_independent "procfs" config.mount_independent "sysfs" config.mount_independent "devtmpfs" config.mount_independent "devpts" config.mount_independent "shm" end
今回ひとまずは上記のようなDSLにした理由として、
- まずはchrootやmountとコマンドのデーモン化レベルを試したい
- その他の機能が必要になれば順次試す(これがhaconiwaの良い所)
- ひとつのchroot環境を共有してコンテナを複数立ち上げたい
- シェルからワンラインで複数コンテナ立ち上げたい
- コンテナのファイルは共有するので、pidファイルやportは動的に記述したい
があります。通常のコンテナエンジンにこれを求めるのはちょっと厳しいのですが、haconiwaを使えばあっという間に実現できます。上記のようにRuby書いたことがある人なら大抵書いた事があるような、環境変数の受け渡しによって実現することができます。
その上で、 /var/lib/haconiwa/2d057697
がchroot環境のrootになりますので、このディレクトリの中で、phpinfoが動くchroot環境のtarを展開しておきます。
cd /var/lib/haconiwa/2d057697 tar xvf phpinfo-chroot.tar.gz
その上で、chroot配下のApacheは2.4系で動いているので、環境変数から設定を受け取れるように、以下のようなhttpd.confにしておきます。
Listen ${APACHE_PORT} PidFile ${APACHE_PID}
これによって、Apacheはhaconiwaから受け取った環境変数を元に、動的に設定を変えてApacheを起動させることができます。
1万コンテナ動かしてみる
ということで、mod_phpを組み込んで、通常デフォルトで入っているようなApacheモジュールがコミコミのhttpd(RSSが5MB〜10MBぐらい)を1万個動かしてみましょう。上記のようなhaconiwaの設定を行ったので、以下のようにワンラインでコンテナを起動させることができます。
for port in `seq 10001 20000`; do echo APACHE_PORT=$port ./haconiwa start phpinfo.haco; sleep 0.01; done
こういう大量のデーモンをバックグラウンドで起動したり、一気に殺したりすると、高負荷になってサーバが落ちることがあるので、できるだけシーケンシャルな動きになるように今回はsleepをはさんで負荷を調整しました。そして、上記コマンドを実行すると、haconiwaによってコンテナが大量に起動されていきます。
Container successfully up. PID={container: 13302, supervisor: 13301} Container successfully up. PID={container: 13306, supervisor: 13305} Container successfully up. PID={container: 13310, supervisor: 13309} Container successfully up. PID={container: 13314, supervisor: 13313} Container successfully up. PID={container: 13318, supervisor: 13317} Container successfully up. PID={container: 13322, supervisor: 13321} Container successfully up. PID={container: 13326, supervisor: 13325} Container successfully up. PID={container: 13330, supervisor: 13329} Container successfully up. PID={container: 13335, supervisor: 13334} Container successfully up. PID={container: 13339, supervisor: 13338} Container successfully up. PID={container: 13344, supervisor: 13343} Container successfully up. PID={container: 13348, supervisor: 13347} Container successfully up. PID={container: 13352, supervisor: 13351} Container successfully up. PID={container: 13356, supervisor: 13355} Container successfully up. PID={container: 13360, supervisor: 13359} Container successfully up. PID={container: 13364, supervisor: 13363} Container successfully up. PID={container: 13368, supervisor: 13367} Container successfully up. PID={container: 13372, supervisor: 13371} Container successfully up. PID={container: 13376, supervisor: 13375} Container successfully up. PID={container: 13380, supervisor: 13379} Container successfully up. PID={container: 13384, supervisor: 13383} Container successfully up. PID={container: 13388, supervisor: 13387} Container successfully up. PID={container: 13392, supervisor: 13391} Container successfully up. PID={container: 13396, supervisor: 13395} Container successfully up. PID={container: 13400, supervisor: 13399} Container successfully up. PID={container: 13404, supervisor: 13403} ・ ・ ・
そして、しばらく起動し続けていると、4000コンテナあたりで起動しなくなりました。そこで、コンテナ内の起動に失敗しているhttpdのエラーログを確認しました。すると、
[Mon Nov 07 09:53:00.327685 2016] [core:emerg] [pid 4454] (28)No space left on device: AH00023: Couldn't create the rewrite-map mutex AH00016: Configuration Failed
とか
[Mon Nov 07 09:40:11.671895 2016] [auth_digest:error] [pid 32150] (28)No space left on device: AH01762: Failed to create shared memory segment on file /run/httpd/authdigest_shm.32150
というエラーが出力されていました。これは、複数のコンテナで同一のhttpdの環境を共有しているため、セマフォ識別子が同一のものとなり、その上限に引っかかっていたようです。実際にうづらさんがその辺さっと調査して、
ipcs -s | grep apache | wc -l 32000
このApacheの値が、以下のようにkernel.sem
の上限にひっかかっていました。
# sysctl kernel.sem kernel.sem = 32000 1024000000 500 32000
また、もう一つの問題として、各コンテナでhostnameを変更するため、コンテナ起動時のhostnameの変更が、親のホストにまで影響を与え、コンテナ生成中にころころと親のhostnameを変更し続けるような挙動もしていました。
namespace機能を使う
現在のDSLの設定は、ある種マウントやchrootを工夫しているだけなので、これらの要求に答えることができません。しかし、haconiwaは様々なコンテナの要素機能を持っているため、ここでは実行コンテキストの一部を分離する機能を使い、このセマフォ識別のnamespaceであるIPCと、hostnameの変更などに対するnamespaceであるUTSを、コンテナ単位で分離するようにしました。これによって、コンテナ間やホストとnamespaceが分離されるため、上記のセマフォの問題やIPCの問題がおきないはずです。
ということで、以下のようにDSLの最終行に2行追記しました。
unless ENV['APACHE_PORT'] raise "env APACHE_PORT not found" end Haconiwa.define do |config| # The container name and container's hostname: config.name = "haconiwa-2d057697-#{ENV['APACHE_PORT']}" # The first process when invoking haconiwa run: config.init_command = ["/usr/bin/env", "APACHE_PORT=#{ENV['APACHE_PORT']}", "APACHE_PID=/var/run/httpd/httpd.#{ENV['APACHE_PORT']}.pid", "/usr/sbin/httpd", "-X"] # If your first process is a daemon, please explicitly daemonize by: config.daemonize! # The rootfs location on your host OS # Pathname class is useful: root = Pathname.new("/var/lib/haconiwa/2d057697") config.chroot_to root # mount point configuration: config.add_mount_point "tmpfs", to: root.join("tmp"), fs: "tmpfs" # Share network etc files from host to contianer # You can reuse /etc/netns/${netnsname}/* files: config.mount_network_etc(root, host_root: "/etc") # Re-mount specific filesystems under new container namespace # These are recommended when namespaces such as pid and net are unshared: config.mount_independent "procfs" config.mount_independent "sysfs" config.mount_independent "devtmpfs" config.mount_independent "devpts" config.mount_independent "shm" # namespeaceの設定を2行追記 config.namespace.unshare "ipc" config.namespace.unshare "uts" end
たったこれだけで、IPCとUTSのnamespaceが分離されます。
そこで、再度、シェルから1万コンテナの起動を行いました。
すると、4000を超えたあたりでもエラーが出ることなく起動を続け、6615コンテナぐらいまで立ち上がった所で、メモリ不足となり、それ以上は起動しませんでした。これによってIPCやUTSの問題は解決し、ハードウェアのリソースの問題に移り変わったことがわかります。
つまり、メモリ32GBのサーバでhaconiwaを使ってphpinfoが動き、それなりに通常使うようなApachモジュールがデフォルトで入っているコンテナを1worker(masterも兼務)で動かすと、6600ぐらい動かすことができる、ということになります。実際に計算してみても、その時に起動していたコンテナ内のhttpdのRSSが5MBぐらいでほとんどCoWは効かないforkの仕方だと思うので(haconiwa -> httpdというプロセスツリーが6600組みあるイメージ)、
irb(main):001:0> 5 * 6600 / 1024 => 32
となるので、swap領域も入れると大体そんなもんか、という感じで計算でも概ね納得の値です。
また、haconiwa自体のメモリフットプリントは以下のように非常に小さく、1MB以下になってました。
root 11969 0.0 0.0 16412 668 ? Ss 20:09 0:00 ./haconiwa start phpinfo.haco apache 11970 0.0 0.0 413556 9636 ? S 20:09 0:00 \_ /usr/sbin/httpd -X
cgroup機能を使う
ここで、概ね5000コンテナ位だったらいい感じで動かせそうなので、さらに各コンテナにcgroup機能を適用して、リソースを制御することにしました。その際に、以下のようにcgroupの設定を最終行に更に2行追加します。
unless ENV['APACHE_PORT'] raise "env APACHE_PORT not found" end Haconiwa.define do |config| # The container name and container's hostname: config.name = "haconiwa-2d057697-#{ENV['APACHE_PORT']}" # The first process when invoking haconiwa run: config.init_command = ["/usr/bin/env", "APACHE_PORT=#{ENV['APACHE_PORT']}", "APACHE_PID=/var/run/httpd/httpd.#{ENV['APACHE_PORT']}.pid", "/usr/sbin/httpd", "-X"] # If your first process is a daemon, please explicitly daemonize by: config.daemonize! # The rootfs location on your host OS # Pathname class is useful: root = Pathname.new("/var/lib/haconiwa/2d057697") config.chroot_to root # mount point configuration: config.add_mount_point "tmpfs", to: root.join("tmp"), fs: "tmpfs" # Share network etc files from host to contianer # You can reuse /etc/netns/${netnsname}/* files: config.mount_network_etc(root, host_root: "/etc") # Re-mount specific filesystems under new container namespace # These are recommended when namespaces such as pid and net are unshared: config.mount_independent "procfs" config.mount_independent "sysfs" config.mount_independent "devtmpfs" config.mount_independent "devpts" config.mount_independent "shm" # namespeaceの設定を2行追記 config.namespace.unshare "ipc" config.namespace.unshare "uts" # cgroupによって、コンテナのCPU使用上限を30%に制限 config.cgroup["cpu.cfs_period_us"] = 100000 config.cgroup["cpu.cfs_quota_us"] = 30000 end
この設定により、各コンテナのCPU使用率が30%に制限されるようになるはずです。これで、再度shellからワンラインで5000コンテナを上げてみます。
for port in `seq 10001 15000`; do APACHE_PORT=$port ./haconiwa start phpinfo.haco; sleep 0.01; done
すると、以下のように想定どおりきちんとコンテナが動いていることがわかります。また、cgroupに関する設定も漏れなく適用されているようです。
- 起動後のプロセス数
$ ps auwxf | grep http[d] | wc -l 5000
- 起動後のlisten数
# netstat -lnpt | grep httpd | wc -l 5000
- 起動後のcgroupの設定数
# ls -l /sys/fs/cgroup/cpu | grep haconiwa | wc -l 5000
- 起動後のmemoryの状態
$ free -m total used free shared buffers cached Mem: 32091 31699 392 0 12 438 -/+ buffers/cache: 31247 844 Swap: 16383 5992 10391
- curlで適当にコンテナにリクエスト送ってみる
$ curl -s localhost:13000/phpinfo.php | grep "Server API" <tr><td class="e">Server API </td><td class="v">Apache 2.0 Handler </td></tr>
- すべてのコンテナにリクエスト
$ for port in `seq 10001 15000`; do curl -s localhost:$port/phpinfo.php; done | grep "Server API" | wc -l 5000
ちゃんと動いているようです。
ベンチマークでリソース制御ができているか確認
最後に、ちゃんとコンテナ単位で設定どおりリソースが分離されているかを確認します。
ab -l -k -c 10 -n 10000000 http://localhost:14010/phpinfo.php >> result.log 2>&1 & ab -l -k -c 10 -n 10000000 http://localhost:13010/phpinfo.php >> result.log 2>&1 & ab -l -k -c 10 -n 10000000 http://localhost:12010/phpinfo.php >> result.log 2>&1 & ab -l -k -c 10 -n 10000000 http://localhost:12222/phpinfo.php >> result.log 2>&1 & ab -l -k -c 10 -n 10000000 http://localhost:14410/phpinfo.php >> result.log 2>&1 & ab -l -k -c 10 -n 10000000 http://localhost:14411/phpinfo.php >> result.log 2>&1 & ab -l -k -c 10 -n 10000000 http://localhost:14412/phpinfo.php >> result.log 2>&1 & ab -l -k -c 10 -n 10000000 http://localhost:14413/phpinfo.php >> result.log 2>&1 & ab -l -k -c 10 -n 10000000 http://localhost:14414/phpinfo.php >> result.log 2>&1 & ab -l -k -c 10 -n 10000000 http://localhost:14415/phpinfo.php >> result.log 2>&1 & ab -l -k -c 10 -n 10000000 http://localhost:14416/phpinfo.php >> result.log 2>&1 & ab -l -k -c 10 -n 10000000 http://localhost:14417/phpinfo.php >> result.log 2>&1 &
こんな感じで適当なポートに負荷をかけてみました。すると、その時のtopは以下のようになっていました。
top - 20:41:37 up 3:47, 5 users, load average: 7.75, 2.24, 61.35 Tasks: 10402 total, 13 running, 10387 sleeping, 0 stopped, 2 zombie Cpu(s): 1.9%us, 1.8%sy, 0.0%ni, 90.8%id, 4.4%wa, 0.0%hi, 1.0%si, 0.0%st Mem: 32862188k total, 32529360k used, 332828k free, 8040k buffers Swap: 16777212k total, 8576304k used, 8200908k free, 416080k cached PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 29771 apache 20 0 404m 7632 2400 R 30.4 0.0 0:12.95 httpd 19855 apache 20 0 404m 5616 2400 R 29.0 0.0 0:12.94 httpd 22084 root 20 0 20944 9688 1632 R 29.0 0.0 0:00.33 top 23951 apache 20 0 404m 7628 2400 R 29.0 0.0 0:12.94 httpd 28012 apache 20 0 404m 7628 2400 R 29.0 0.0 0:12.97 httpd 29743 apache 20 0 404m 7628 2400 R 29.0 0.0 0:12.98 httpd 29747 apache 20 0 404m 7628 2400 R 29.0 0.0 0:12.97 httpd 29751 apache 20 0 404m 7632 2400 R 29.0 0.0 0:12.97 httpd 29759 apache 20 0 404m 7628 2400 R 29.0 0.0 0:12.96 httpd 29763 apache 20 0 404m 7632 2400 R 29.0 0.0 0:12.98 httpd 29767 apache 20 0 404m 7632 2400 R 29.0 0.0 0:12.96 httpd 29775 apache 20 0 404m 7628 2400 R 29.0 0.0 0:12.60 httpd 20739 apache 20 0 404m 7012 2400 R 27.6 0.0 0:12.95 httpd 18971 root 20 0 89824 2296 1292 S 6.9 0.0 0:02.26 ab 18987 root 20 0 89824 2320 1316 S 6.9 0.0 0:02.33 ab 18970 root 20 0 89824 2320 1324 S 5.5 0.0 0:02.24 ab 18974 root 20 0 89824 2340 1340 S 5.5 0.0 0:02.31 ab 18975 root 20 0 89824 2316 1316 S 5.5 0.0 0:02.28 ab 18978 root 20 0 89824 2332 1324 S 5.5 0.0 0:02.22 ab 18979 root 20 0 89824 2276 1276 S 5.5 0.0 0:02.31 ab 18982 root 20 0 89824 2312 1304 S 5.5 0.0 0:02.33 ab 18983 root 20 0 89824 2340 1340 S 5.5 0.0 0:02.36 ab 18991 root 20 0 89824 2316 1316 S 5.5 0.0 0:02.21 ab 18986 root 20 0 89824 2320 1320 S 4.1 0.0 0:02.25 ab 18990 root 20 0 89824 2228 1232 S 4.1 0.0 0:02.29 ab 8 root 20 0 0 0 0 S 2.8 0.0 3:55.93 rcu_sched 103 root 20 0 0 0 0 S 1.4 0.0 0:19.20 rcuos/11 6886 root 20 0 4268 960 784 S 1.4 0.0 0:00.93 fsupdated
ちゃんとコンテナ単位でリソースが30%に抑えられていますね!!!!!!
参考までに、1コアCPU30%に抑え込まれた条件下でのreq/secは以下のような結果でした。
# ab -l -k -c 10 -n 10000 http://localhost:14000/phpinfo.php Server Software: Apache/2.4.6 Server Hostname: localhost Server Port: 14000 Document Path: /phpinfo.php Document Length: Variable Concurrency Level: 10 Time taken for tests: 51.239 seconds Complete requests: 10000 Failed requests: 0 Keep-Alive requests: 0 Total transferred: 450678756 bytes HTML transferred: 448848756 bytes Requests per second: 195.16 [#/sec] (mean) Time per request: 51.239 [ms] (mean) Time per request: 5.124 [ms] (mean, across all concurrent requests) Transfer rate: 8589.49 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 0.1 0 1 Processing: 2 51 34.6 83 87 Waiting: 2 49 34.7 17 87 Total: 3 51 34.6 83 87 Percentage of the requests served within a certain time (ms) 50% 83 66% 85 75% 85 80% 85 90% 86 95% 86 98% 86 99% 86 100% 87 (longest request)
停止時も、一気にkillallなどすると高負荷になるため、シーケンシャルにゆっくり安全に殺していきましょう。数千起動してkillallとかしちゃうと、以下のようなロードアベレージになって、kernelが大変苦しくなります。
$ cat /proc/loadavg 8098.64 5875.62 2985.01 8/9805 25330
また、haconiwaは.hacoファイルを使ってきれいにコンテナを停止する機能もサポートしていますので、startと同様、以下のように書くことができます。
for port in `seq 10001 20000`; do APACHE_PORT=$port ./haconiwa kill phpinfo.haco; sleep 0.1; done
start
オプションをkill
にしただけですね。
まとめ
ということで、haconiwaを色々と実際に触って検証してみましたが、とにかく痒い所に手がとどく拡張性を持ち、それでいてDSLによって簡単かつ高い生産性をもってコンテナをコントロールすることができるソフトウェアであると改めて認識しました。
ディストリビューションによっては、コンテナを構成する要素機能がそろっていなくてうまく動かせない、というパターンが多い中で、使用可能な機能だけでちょっとしたコンテナやそれに準ずる環境をプログラマブルに書きたい、という用途にhaconiwaはピッタリです。また、拡張性と生産性を両立するソフトウェアの設計というのはなかなか難しいのですが、それをコンテナの内部実装という高度な領域においても、高いレベルで両立できています。
今回はglibcが古いなどという制約がある環境下での検証でしたが、今後はuidやgid、setns、capabilityなど、haconiwaはその他沢山の機能が実装されているので、そういったセキュリティ周りの機能もフルに使って、コンテナ環境を色々とカスタマイズできれば良いなと思っています。また、それもきっと非常に簡単にできるのだろうなという印象を持ちましたので、是非コンテナに興味ある方はhaconiwaを使ってみて、コンテナ環境構築を楽しみつつ、haconiwa自体へフィードバックなどを行って、OSS活動的楽しみも同時に味わっていけると良いのではないでしょうか。
ということで、久々にOSSの検証レポートをお送りしました。次はメモリ256GBの環境で試してみようと思いますのでお楽しみに。