bekkou68 の日記

Gogengo! や IT 技術など。

『Working with Unix Processes』の Daemon Processes を読みました

はじめに

Processes Can Communicate の続きです。

主旨

デーモン化の仕組みを知るために Rack の rakupコマンドの実装を読みます。

コードリーディング大会

デーモン化の処理を理解するため、コードリーディングや勉強会メンバーとディスカッションを行いました。さらに以下のエントリをあわせて読むことで理解が進みました。多謝!

デーモン化処理の解説ダイジェスト

def daemonize_app
  if RUBY_VERSION < "1.9"
    # Process.daemon は Ruby 1.9 から使えるようになった
    # 以下のコードは Process.daemon の C言語実装と同じことをやっている
    # see https://github.com/ruby/ruby/blob/c852d76f46a68e28200f0c3f68c8c67879e79c86/process.c#L4817-4860

    # ユーザの端末による制御下から除外する(詳説は後述)
    exit if fork
    Process.setsid
    exit if fork

    # デーモンの実行中にカレントディレクトリーが無くならないことを保証する
    # 保証しないとどういった問題が起きるかは理解できてない…。
    Dir.chdir "/"

    # 端末からデタッチしたので標準入出力・エラーは必要ない
    # 単純に close すると問題が起きる ※A ので /dev/null で再オープンして無効化
    STDIN.reopen "/dev/null"
    STDOUT.reopen "/dev/null", "a"
    STDERR.reopen "/dev/null", "a"
  else
    # 1.9 以降はこの一行でデーモン化できる。素晴らしい!
    Process.daemon
  end
end

ユーザの端末による制御下から除外する

ユーザーは端末からプロセスの一時停止や終了などの制御ができます。そういったユーザの制御下におかれないプロセスがデーモンです。以下の手順でプロセスと端末との関連づけをなくしています。

1度目の fork …「後続の Process.setsid を成功させる」

setsid を実行すると新しいセッションができ、プロセスはそのセッションのリーダーとしてアサインされます。アサインされるためには条件があります。その条件とはプロセスグループリーダーでないことです。通常、プログラムは起動時にプロセスグループリーダーになるため、そのまま setsid を実行すると失敗してしまいます。
そのため `exit if fork` して親なし子プロセスをつくっています。このとき子プロセスは initプロセスの子になるので、プロセスグループリーダーでなくなります。setsid を実行する準備が整いました。

Process.setsid …「プロセスが端末を"持たなく"する」

setsid で新しいセッションをつくり、プロセスをセッションリーダーにアサインします。ここでつくった新しいセッションには端末が設定されていません。端末が設定されていないセッションはユーザの制御下に無いので、デーモンの性質として良いです。
ちなみに、setsid を実行するとプロセスは新しいプロセスグループのプロセスリーダーにもなります。
さらに蛇足ですが、Process.setsid の実行には sudo権限が必要そうです。sudo ナシだと実行できませんでした。

2度目の fork …「プロセスと端末とを"関連づけられなく"する」

端末を持たないプロセスをつくることができました。ですが、いま"持たない"だけであって、今後"持つ"ことはできてしまいます。例えば、セッションリーダーが端末デバイスを開くとそれが制御端末になってしまいます。これは良くない性質です。そのため、端末を完全に関連づけられなくする必要があります。そこで 2度目の fork を行います。
`exit if fork` によってセッションリーダーである親プロセスが終了し、端末を関連づけることはできない子プロセスを生成できます。端末はセッションリーダーにしかアサインできないという性質を利用しています。

理解を助けるための知識

こちらと合わせて読むと理解が深まると思います。

プロセスグループ

プロセスグループはプロセスの集まりです。
irbプロセスを立ち上げると、そのプロセスはプロセスグループのグループリーダーになります。
プロセスグループのID はプロセスリーダーの PID と一緒になることが多いです。プロセスリーダーのプロセスから fork してつくられた子プロセスは同じプロセスグループに属すので、プロセスリーダーと同じプロセスグループのIDを持ちます。

セッショングループ

セッショングループはプロセスグループの集まりです。
たとえば、コマンドラインから `a | b | c` といったようにコマンドをパイプで連携して実行した場合を考えます。それぞれのコマンドのプロセスは別々のプロセスグループをもちます。一連の実行は一つのセッショングループを持ちます。セッショングループリーダーにシグナルを送ると、そのセッショングループに属す全てのプロセスグループへ送られ、さらに各プロセスグループに属す全てのプロセスに送られます。※B

※A STDIN, STDOUT, STDERR を単純に close したときに起きる問題とは?

本文中には close したときに具体的にどのような問題が起きるかは書いてありませんでした。どういった問題が起きるのかを検証するためにファイル std.rb を以下の内容で用意しました。

STDIN.close
STDOUT.close
STDERR.close

これを実行してみました。

$ ruby std.rb
(特にエラーなく終了)

ふむ…。それではこれはどうかとファイル std2.rb を用意しました。

STDOUT.close
puts 'hoge'

そして実行。

$ ruby std2.rb 
std2.rb:3:in `write': closed stream (IOError)
        from std2.rb:3:in `puts'
        from std2.rb:3:in `puts'
        from std2.rb:3:in `<main>'

おお。エラーが出てプロセスが落ちた。では /dev/null で無効化した場合はどうなるのでしょう。ファイル std3.rb を用意しました。

STDOUT.reopen '/dev/null', 'a'
puts 'hoge'

実行。

$ ruby std3.rb
(特にエラーなく終了)

おお。エラーがでない。落ちない。
ということで、おそらく問題というのは、close してから IO にアクセスするとプロセスが落ちるようなエラーが発生してしまう。だから /dev/null にリダイレクトして再オープンすることでプロセスが不用意に落ちる恐れを回避しているのではないか。と思ったのでした。合ってるかな。。
ちなみに irb で各IO を close したら以下のような挙動になりました。
STDIN.close ... エラーがでてプロセス終了

irb> STDIN.close
/../ruby/1.9.1/irb/context.rb:159:in `tty?': closed stream (IOError)

STDOUT.close ... エラーがでてプロセス終了

irb> STDOUT.close
/../ruby/1.9.1/irb.rb:168:in `write': closed stream (IOError)

STDOUT.close ... プロセスは終了せず

irb> STDERR.close
nil

STDIN と STDOUT は close するとエラーが出てプロセスが終了してしまいました。STDERR だけエラーが起きなかったのは不思議です…。
ファイルから実行した場合とは結果が異なりました。

※B 端末からでなく killコマンドでプロセスグループを終了する方法

本文からはそれるのですが、標題の操作をしたくなったので調べました。
以下のコード process.rb を実行してみます。

fork do
  puts "#{Process.pid}: in fork [child]"
  sleep
end

puts "#{Process.pid}: after fork [parent]"
sleep

実行します。

$ ruby process.rb 
2853: after fork [parent]
2854: in fork [child] # ここで出力が止まる
^C # Ctrl+C をタイプ
process.rb:7:in `sleep': Interrupt
        from process.rb:7:in `<main>'
process.rb:3:in `sleep': Interrupt
        from process.rb:3:in `block in <main>'
        from process.rb:1:in `fork'
        from process.rb:1:in `<main>'

上記のように、端末上で Ctrl+C を送ると親と子のプロセスそれぞれが終了します。これを kill でやってみたら思ったように動きませんでした。確認するため再実行します。

$ ruby process.rb
2879: after fork [parent]
2880: in fork [child]

別端末から

$ kill -INT 2879

を送ると、

$ ruby process.rb
2879: after fork [parent]
2880: in fork [child]
process.rb:7:in `sleep': Interrupt
        from process.rb:7:in `<main>'

親プロセスしか終了しませんでした。ps で確認すると子プロセスは残っていました。
調べてみると、プロセスグループID を指定して kill できることがわかりました。記法は次のとおりです。

$ kill -TERM -<プロセスグループID>

先ほどのケースで以下のようにシグナルを送ったら、見事、親子のプロセスが終了しました。

$ kill -TERM -2879

端末から Ctrl+C でシグナルを送ることと kill でシグナルを送ることは、似ているようで違うこともあるのですね。

セッションリーダーの探し方

psコマンドで探せそうです。psコマンドの stateキーワードに s が含まれるプロセスがセッションリーダーです。さらに sessキーワードがセッションID を示すようです。実際に探してみました。

$ ps aux -o sess | grep Ss
USER             PID  %CPU %MEM      VSZ    RSS   TT  STAT STARTED      TIME COMMAND            SESS
root             297   1.3  0.0  2446552   1524   ??  Ss    5:03PM   5:38.31 /usr/libexec/act ffffff800ab98840
...

どうやらセッションID は "ffffff800ab98840" といった文字列のようです。
せっかくなのでコマンドからセッションID を指定してシグナルを送ってみようかと思い調べてみたのですが見つけられず。今回はおあずけとなりました。そのうち調べてみたいです。

参考: 『ps auxw したときに STAT 列に表示される値の意味 - ablog

その他勉強会で話題になったこと

  • kill_allコマンドはプロセス名の(たぶん)部分一致で kill できる。関係ないプロセスを落とさないよう注意
  • セッションリーダーが不在になることはありえる

フレーズ

  • 'ding': ベル音(Unixをさわっててよく鳴るアレのことだと思う)
  • Turtles all the way down: 無限後退(理論をつきつめてもきりがないこと。命題1 を証明するには命題2 の証明が必要。命題2 の証明には 命題3の証明が必要…)

感想

ふう…。すごい歯ごたえでした。デーモン化の仕組みを読み解くことはこの本の最難関だったような気がします。
前提として必要な知識が多く、そのためには、そのためには、そのためには…と続くオンパレードで理解をするのに時間が随分かかりました。
かなり複雑なことをやっているにもかかわらず rackup のソースコードにはコメントはほとんどありませんでした。調べていくにつれ、お決まりのパターンなのかなと思うようになりました。ただ、ゼロベースの知識からある程度読み解けるようになるまでには相当つらかったです…w
まだまだ沢山わからないことがあるので、時間を見つけて調べてみようと思います。