Docker コンテナを systemd-nspawn で動かす (作業メモ)

ちょっと前までは machinectl pull-dkr というコマンドがあったんだけど、Docker の考えるコンテナと systemd-nspawn の考えるコンテナの差が大きいこともあって消されている。 とはいえ、Docker コンテナも systemd-nspawn (machinectl) で扱うコンテナも本質的に違うものではないので、Docker で作ったコンテナを systemd-nspawn で動かせないこともない。 以下、mysql:5.6 を例に使った作業メモ。

イメージのままだと export できないので、適当に起動してコンテナを作ってから export して、適当なディレクトリに展開しておく。

% docker run --detach mysql:5.6 false
9872d546b6d1f245f25b895ef4c05725d3fdba30d604c11801b000b78ac79d23
% docker export -o mysql56.tar 9872d546b6d1f245f25b895ef4c05725d3fdba30d604c11801b000b78ac79d23
% mkdir mysql56
% tar -C mysql56 -xf mysql56.tar

export すると Docker の ENV とか ENTRYPOINT とか CMD 等の情報が落ちてしまうため、適当に残しておく。

% docker inspect mysql:5.6 | jq --raw-output '.[].Config.Env | map("export \(.)") | join(";")' > mysql56/env.sh
% cat mysql56/env.sh
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin;export MYSQL_MAJOR=5.6;export MYSQL_VERSION=5.6.29-1debian8
% docker inspect mysql:5.6 | jq --raw-output '.[].Config.Entrypoint | join(" ")'
/entrypoint.sh
% docker inspect mysql:5.6 | jq --raw-output '.[].Config.Cmd | join(" ")'
mysqld

systemd-nspawn でコンテナを起動する。

% sudo systemd-nspawn --ephemeral --directory mysql56 --setenv=MYSQL_ROOT_PASSWORD=notasecret --network-veth
Spawning container mysql56-f4c55f3750654603 on /home/eagletmt/work/.#machine.mysql56f1652daa7d449b0c.
Press ^] three times within 1s to kill container.
root@mysql56-f4c55f3750654603:~#

ここで mysqld を起動しようとすると、systemd によって /run に tmpfs がマウントされてしまっていて /run/mysqld が存在せずエラーになってしまうので、適当に回避する。

root@mysql56-f4c55f3750654603:~# mkdir /run/mysqld
root@mysql56-f4c55f3750654603:~# chown mysql:mysql /run/mysqld

ホスト側から接続できるように設定。

root@mysql56-f4c55f3750654603:~# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: host0@if28: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
    link/ether 9e:83:dd:e9:23:c2 brd ff:ff:ff:ff:ff:ff
root@mysql56-f4c55f3750654603:~# ip addr add 10.0.0.10/24 dev host0
root@mysql56-f4c55f3750654603:~# ip link set dev host0 up
root@mysql56-f4c55f3750654603:~# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: host0@if28: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 9e:83:dd:e9:23:c2 brd ff:ff:ff:ff:ff:ff
    inet 10.0.0.10/24 scope global host0
       valid_lft forever preferred_lft forever
    inet6 fe80::9c83:ddff:fee9:23c2/64 scope link
       valid_lft forever preferred_lft forever

ようやく mysqld を起動。

root@mysql56-f4c55f3750654603:~# source /env.sh
root@mysql56-f4c55f3750654603:~# /entrypoint.sh mysqld
Initializing database
(snip)

ホスト側から接続確認。

% mysql -uroot -h 10.0.0.10 -pnotasecret -e 'SELECT version()'
+-----------+
| version() |
+-----------+
| 5.6.29    |
+-----------+

PID から Docker の container id を知る方法

ホスト側から見てなんか挙動が怪しいプロセスがいてその PID が分かったときに、どの Docker コンテナで動いているプロセスなのか知りたいことがある。

docker ps -q | xargs -n1 docker top で全ての container id と PID の対応をリストアップして探すことでも達成できるけど、Docker はコンテナを起動するときに cgroup を作ってそのパスに container id を使っているので、/proc/$PID/cgroup を見れば一発で container id がわかることに気付いた。

% cat /proc/29823/cgroup
9:freezer:/docker/e9e7fa08af0c5478ac379ca587ad2850ffd2a0b72b97b05201d45f3337f4750e
8:cpu,cpuacct:/docker/e9e7fa08af0c5478ac379ca587ad2850ffd2a0b72b97b05201d45f3337f4750e
7:net_cls:/docker/e9e7fa08af0c5478ac379ca587ad2850ffd2a0b72b97b05201d45f3337f4750e
6:cpuset:/docker/e9e7fa08af0c5478ac379ca587ad2850ffd2a0b72b97b05201d45f3337f4750e
5:blkio:/docker/e9e7fa08af0c5478ac379ca587ad2850ffd2a0b72b97b05201d45f3337f4750e
4:pids:/system.slice/docker.service
3:devices:/docker/e9e7fa08af0c5478ac379ca587ad2850ffd2a0b72b97b05201d45f3337f4750e
2:memory:/docker/e9e7fa08af0c5478ac379ca587ad2850ffd2a0b72b97b05201d45f3337f4750e
1:name=systemd:/docker/e9e7fa08af0c5478ac379ca587ad2850ffd2a0b72b97b05201d45f3337f4750e

こんなかんじになっていれば、PID 29823 が動いている container id は e9e7fa08af0c5478ac379ca587ad2850ffd2a0b72b97b05201d45f3337f4750e だと分かる。

alias_method_chain と prepend を同時に同じメソッドに適用できない問題

Rails 5.0 のリリースが近づいてきてますが、Rails 5.0 から alias_method_chain を使っていると deprecation warning が出るようになりました https://github.com/rails/rails/pull/19434 。 単純に alias_method_chainprepend に書き換えればいいかと思いきや、かなりのレアケースではあるけれども、以前実際に失敗した事例の紹介です。

#!/usr/bin/env ruby
require 'active_support/all'

module M
  def foo
    p 'M#foo'
    super
  end
end

class C
  def foo
    p 'C#foo'
  end
end

class C
  def foo_with_modified
    p 'C#foo modified'
    foo_without_modified
  end

  alias_method_chain :foo, :modified
  prepend M
end

C.new.foo

これを実行すると

"M#foo"
"C#foo modified"
"C#foo"

という出力結果が得られる。予想通り。

#!/usr/bin/env ruby
require 'active_support/all'

module M
  def foo
    p 'M#foo'
    super
  end
end

class C
  def foo
    p 'C#foo'
  end
end

class C
  def foo_with_modified
    p 'C#foo modified'
    foo_without_modified
  end

  prepend M
  alias_method_chain :foo, :modified
end

C.new.foo

これを実行すると SystemStackError。変わったのは alias_method_chainprepend の順序だけ。

言われてみればたしかにそうなるのはわかるけれども、たとえば activerecord をモンキーパッチで拡張するような gem が複数あった場合、たまたま同じメソッドに対する alias_method_chainprepend が混在していると、初期化順でエラーになったりならなかったりする。

2015年の思い出

しゃかいじんにねんめ

去年は http://eagletmt.hateblo.jp/entry/2014/12/31/032313

仕事

去年末から今年の前半にかけて簡単な外向きの API サーバや社内アプリを書いていて、仕事で初めて最初から自分で書いた Web アプリになった。 まぁ最初からといっても既にある機能の置き換えみたいなやつなので、ちょっと設計考えて実装したくらい。

今年の中盤くらいからは開発環境向けの MySQL サーバをさわる機会を得た。個人ではずっと PostgreSQLMySQL は全然わかってなかったのでいい経験だった。 去年と比べてサーバの面倒を見る時間が圧倒的に増えた。 MySQL もそうだけど、CI サーバを CentOS から Ubuntu にしたり、RRRSpec の面倒を見たり。 Web アプリ書くのも全然嫌いじゃないけど今はこっちのほうが楽しい。

発表

なんか隣の人に唆されて RubyKaigi 2015 で発表してた http://k0kubun.hatenablog.com/entry/2015/12/12/000037

hamlx の噂を聞いてから全然動きないなーと思って、haml も slim も文法同じなんだから slim と同じパフォーマンスはいけるでしょと 書き始めて *1 、完全に趣味で書いてたけど色々あって 本番で動くレベルになって 、最終的に RubyKaigi での発表になって一年を通して haml だった。

僕は勉強会とかカンファレンスはあんまり行かないけど、まぁこのまま1年に1回くらいのペースでできたらいいな。

そういえば RubyConf 2015 に行った。海外は ICPC で行ったことがあったけど、アメリカというか英語圏は初めて。 英語の案内とかは結構読めるんだけど、人が何言ってるかなかなか聞き取れなくて大変だった。人と話せないのは英語関係なく日本語でもそうなのである意味いつも通りだった…

インフラ

仕事で少しさわってるけどよくわからんなーというのは、積極的に個人の環境でも使うようにしてみている。 去年は puppet とか zabbix だったけど、今年は itamae とか Jenkins だった。あとはつい最近 OpenLDAP も立てた。 仕事で使ってるツールOSS だと個人でも使えていい。 来年はネットワークの知識をなんとかしたいと思っている。ルータ自作してみたり自宅と VPS の間に拠点間 VPN 張ってみたりすると経験値貯まるのかなー。

あと ISUCON 5 に参加した。去年とは違って普通に予選を (ギリギリ) 勝ちぬいて、本選でもそこそこの順位をとれてよかった。 http://eagletmt.hateblo.jp/entry/2015/11/03/013045

来年は Ubuntu 16.04 LTS がリリースされてついに systemd を使えるようになるので楽しみ。

アニメ

去年末からの続きだけど SHIROBAKO と クロスアンジュがとてもよかった。全然違う作品だけどどちらも今後しばらく忘れないと思う。 あとあんまり話題にならなかったけどデス・パレードが本当に大好きだった。ぜひ最初の2話を順番に見てほしい。

新規に見たものの中でとくに好きだったのはユーフォニアム、終わりのセラフ、SHOW BY ROCK、グリザイアの楽園あたり。グリザイアはまぁ去年からの続編だけど。 DOG DAYS''、ジョジョ、デレマス、黒バス、Fate UBW銀魂、プリズマイリヤゆるゆりうたわれるものあたりはもう鉄板というか当然見て当然面白かった。 あともうずっと見続けてるけどアイカツも。未だにソレイユ大好きだけどダンシングディーヴァもいいですね。

BD 買ったのは SHIROBAKO、アイカツ (映画含む)、プリズマイリヤ、SHOW BY ROCK、ストライクウィッチーズOVAゆるゆりなちゅやちゅみ、くらいか。来年はもっと増やしていいかも。

漫画・ラノベ

bookwalker で183冊買ってた。セールのときに一気に買ってる割合もそれなりにあるけど… 新規に買ったものだと終わりのセラフ明日、今日の君に逢えなくてもやがてきみになる小百合さんの妹は天使、あたりかなぁ。 終わりのセラフは小説版も買おうか迷ってる。

音楽

ついに日本でも始まった Google Play Music を使うようになった。 といっても手元にある mp3 をアップロードして便利に同期するツールとしてで、曲は相変わらず Amazon から買ってダウンロードしている。Google Play Music 品揃え悪い… 本当は全部 mp3 で買いたいんだけど、一部 CD でしか出てないので仕方なく CD も買っている。アイカツとか…

ゲーム

ムジュラの仮面3D、FE if、Splatoon、そして最近シャープ FE。 普段買わない系統で Splatoon を買って長く楽しめたのは大きい。今も定期的にやってる。 Splatoon のおかげで久しぶりに結構ゲームやってたと思う。

*1:このときはまだ fast_haml という名前だった

systemd-run によるリソース制御

systemd というと unit ファイルを書いてデーモンを起動して、というイメージが強いかもしれないけど、systemd-run を使うと単発のコマンドを systemd の管理下で実行できる。 こうすることで、CPUQuota=50% とか MemoryLimit=10M とか BlockIOWeight=10 のようにリソースを制限でき、しかも実行中に変更することもできる。

たとえば http://hb.matsumoto-r.jp/entry/2015/12/02/133448 にあるような CPU 使用率を制限しながら yes を実行する例だと、

% sudo systemd-run --scope --uid=eagletmt -p CPUQuota=10% yes > /dev/null
Running scope as unit run-rcab5dc0a5f8e4620a996d95d40f7c95a.scope.

のようになる。ここから

% sudo systemctl set-property --runtime run-rcab5dc0a5f8e4620a996d95d40f7c95a.scope CPUQuota=50%

というように動的にリソース制限を変更できる。 systemd のすべてのディレクティブを systemd-run に指定できるわけではないが、リソース制御関連のディレクティブは対応している。

この例では --scope を使ったので出力はそのままになっているけど、--scope をつけなければ service unit として実行されるので、コマンドの出力は通常の service unit と同様にデフォルトでは journald に送られる。

参考

Unicorn 5.0.0 の sd_listen_fds(3) emulation とは

Unicorn 5.0.0 がリリースされていて、most boring major release と自称している中、新機能として sd_listen_fds(3) emulation がある http://bogomips.org/unicorn-public/20151101-unicorn-5.0.0-rele@sed/t/

sd_listen_fds(3) は systemd の socket activation という機能に対応するための単純な関数で、socket activation は簡単に言うと systemd が listen してその fd を service unit のプロセスに渡す機能。 この機能の詳細やモチベーションを説明するのは面倒なので作者のブログ記事などを読んでほしい http://0pointer.de/blog/projects/socket-activation.html

今回 Unicorn はそれに対応したため、以下のような unicorn.service と unicorn.socket を用意することで、socket activation が可能になる。

[Unit]
Description=Unicorn

[Service]
User=non-root
WorkingDirectory=/path/to/current
ExecStart=/usr/bin/bundle exec --keep-file-descriptors unicorn -c config/unicorn.rb -E production
ExecReload=/bin/kill -HUP $MAINPID
ExecStop=/bin/kill -QUIT $MAINPID
PIDFile=/path/to/current/tmp/pids/unicorn.pid

[Install]
WantedBy=multi-user.target
[Socket]
ListenStream=/run/unicorn.sock

[Install]
WantedBy=sockets.target

設定ファイルの unicorn.rb 側には変更は不要。socket activation を利用すると listen の設定が無視されるくらい。

bundle exec に --keep-file-descriptors を渡す必要がある点に気付くまでしばらくはまっていた。 ちゃんと LISTEN_PIDS と LISTEN_FDS は設定されてるのに、なぜか fd=3 が socket ではない別の何かになっていて謎だった…… Ruby 2.0.0 から 0, 1, 2 以外の fd は Kernel#exec するときにデフォルトでは閉じられてしまうそうで、それを防ぐためのオプションが bundle exec にある。 https://github.com/bundler/bundler/pull/2629 https://bugs.ruby-lang.org/issues/5041

また Unicorn のアナウンスでは「You may now stop using PID files and other process monitoring software when using systemd.」とあるけど、Unicorn の SIGUSR2 による graceful restart を利用している場合、 master の pid (systemd 的に言うと main pid) が変わるので、PIDFile は相変わらず必要になるはず。 勘違いだったら教えてください。

実際 Unicorn が socket activation に対応して嬉しいかというと、まぁそんなにメリットは無いと思う。 ほとんどアクセスが無いような個人のサービスで、実際にアクセスがくるまで Unicorn の起動を遅延できる、とかはあるかも…?

socket activation することで、Unicorn 自身が graceful restart しなくても、graceful shutdown ができればクライアントからのコネクションを途中で切ることなくアプリケーションを再起動できるメリットはある。 ただ、新しい worker を用意しつつ準備ができたら切り替える、というような細かい制御は当然 systemd 側からはできず、一旦完全に Unicorn を落としてから再度 socket activation によって起動する流れになるので、接続に失敗することはないけど Unicorn の起動にかかる時間だけブロックする形になる。 どうしても Unicorn を SIGUSR2 によるリロードではなく真っ新な状態から再起動したいときの手段が用意されているのは便利かもしれない。

ISUCON5 本戦に参加して5位でした

去年 に引き続き†空中庭園†《ガーデンプレイス》として @ryotarai@gfx と参加して、結果は5位でした http://isucon.net/archives/45869121.html 。コードは https://github.com/ryotarai/isucon5

ISUCON5 予選の反省として、最初はプロファイルと手元での動作に最低限必要な変更以外はせずにコードを読むと決めていて、nginx の log_format を変えたり stackproflineprof を仕込んだりしてベンチマークを実行した後は、各自アプリケーションの理解に集中していた。 僕は /data から叩かれている外部 API の挙動をとくに調べていて、

  • GET リクエストしかないし、エンドポイントの情報は DB に入ってるけど更新されることもない
  • ken と ken2 という2つの似たエンドポイントがあるけど、実は全く同じっぽくて、しかも zipcode に対して返ってくる答えは一定っぽいので、2つをまとめた上で zipcode をキーにしてキャッシュできそう
  • surname、givenname もそれぞれ q をキーにしてずっとキャッシュできそう
  • tenki は毎回違う値が返ってくる
    • 実際には3秒くらいで変わることに @ryotarai が後で気付いて、その期間だけキャッシュするようにしていた
  • perfectsec、perfectsec_attacked の2つは https でリクエストするようになっていて、http だと通らない
  • perfectsec、perfectsec_attacked の2つも毎回違う値が返っていてキャッシュできなさそう
    • 実際には perfectsec_attacked は20秒くらいはキャッシュできることが後からわかって、最終的にはキャッシュが入ってた
  • それぞれのエンドポイント間に依存関係はなくて、全部並列にとってこれそう
    • このときは適当に Thread.start すればいいかと思っていたけど、@gfx が並列化を実装するときにちゃんとスレッドプールくらいは使いたいという話から expeditor 使おうということになった

あたりを見ていた。

だいたい12:30頃から実際に app.rb を編集し始めて、僕は

  • ken2 の結果を Redis に無期限でキャッシュしてベンチマークが通るかどうか → 通った
  • endpoints を DB から Ruby の定数にもってくる
  • ken を ken2 にマージしてベンチマークが通るかどうか → 通った (これで実質 ken もキャッシュされている)
  • surname、givenname も無期限でキャッシュしてベンチマークが通るかどうか → 通った
  • users を PostgreSQL から Redis へ。同時にパスワードをハッシュするのをやめて直接保存するように

ということをしていた。このへんが終わったのが15時過ぎくらいで、1台のみ使って2万点くらい出していた気がする。 ここから @ryotarai が3台使うような構成に変更していって、5万点をこえ始めていた。 Web API コールがどうしても遅くて /data が詰まると unicorn のワーカを使い切ってしまうので、unicorn のワーカ数をかなり大きめにしつつ3台に配置し、a にはベンチマーカからリクエストを受ける nginx を置いて3台にプロキシし、b には Redis を置くようにした。 endpoints は Ruby の定数にしたし、users は /initialize で Redis に入れてるし、subscriptions も @gfx が /initialize で Redis に入れるようにしていたので、すでに PostgreSQL は /initialize 以外では不要になっていた。

ベンチマークがたまに fail するようになっていて、そのデバッグで16:30頃から1時間くらい使っていた。何で fail しているのか分かるまで時間がかかってしまったのがつらい。 最終的にわかったのはどうも /initialize がたまに30秒以内に終わってないっぽくて、/initialize は Redis がいる b に常にプロキシするようにしたら安定した。 スコアは5万点だったり3万点だったりして、あんまり安定しなくて結構不安だった。

最後は a にいる nginx の CPU 使用率が高いのが気になっていて、@ryotarai が振り分けの重み付けを調整しながら僕はアセットを gzip して gzip_static するようにしたりしていた。 終了15分前くらいに念のため3台とも reboot してみて、ちゃんと Redis が起動してベンチマークが fail しないことを確認して終了。 最終結果のスコアは86,210点で、競技中のベストよりも高い結果だった。

終了後に聞いた話の中では RACK_ENV=production が非常に痛かった http://diary.sorah.jp/2015/11/02/isucon5f 。3位との差はそんなに大きくなかったので、もしかしたら3位には入れていたかもしれないと思うと悔しいけどしょうがない。

去年から参加し始めたけど去年よりよくなってる感触はあるので次は入賞したい。