7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

LinuxAdvent Calendar 2021

Day 6

バックグラウンドのSSH処理がジョブを停止させる問題

Last updated at Posted at 2021-12-05

はじめに

背景

まず、こちらのコマンド実行ログをご覧ください。

SSHを絡めたコマンド実行のログ
[angel@cent8-1 ~]$ ssh cent8-2 'echo I am $HOSTNAME'   #<= パスワードレスな遠隔コマンド実行
I am cent8-2
[angel@cent8-1 ~]$ ./job.sh                            #<= 上記sshコマンドを組み込んだスクリプト
Job start
Remote operation start
I am cent8-2
Remote operation end
Job end
[angel@cent8-1 ~]$ ./job.sh &                          #<= バックグラウンド実行すると…?
[3] 2115
…(略)
[3]+  停止                  ./job.sh

1つ目のSSHコマンドでは、パスワードレス ( 公開鍵認証等 ) での遠隔実行ができていることを確認しています。
2つ目では、そのSSHコマンドを組み込んだシェルスクリプトを実行しました。
ところが3つ目、自動化できる処理とみてバックグラウンド実行を行い、他の処理を行っていたところ、バックグラウンドで動作し続けるはずが「停止」となってしまいました。これはなぜでしょうか?
※厄介なことに停止は毎回必ずではなく、停止せずに処理を全うすることもままあります。

なお、スクリプトの内容は次にように、なんの変哲もないスクリプトです。

job.sh
#!/bin/bash

echo "Job start"
sleep 10
echo "Remote operation start"
ssh cent8-2 'echo I am $HOSTNAME'
echo "Remote operation end"
echo "Job end"

ここに「停止」に至るような、どのような原因があったのでしょうか。

先に原因

ここで勘の良い人なら、「sshを組み込んだスクリプトでトラブル…、入力データを横取りしちゃうからじゃないの?」と気付くかもしれません。これは、ちょうど次のようなスクリプトで発生します。

SSHの入力データ横取りが問題になる例
while read LINE; do
  ssh USER@HOST 遠隔コマンド
  …
done < input.txt

この while read の構造を見ると、while文の入力リダイレクトで指定した input.txt の行数分ループを回す意図があると見られるものかと思います。
しかし実際は、ループ1回目のreadの後のsshが残りデータを大量に読み込んでしまうため、大抵の場合、次のreadでは読み込むべきデータが残っておらず、ループも1度で終了することになります。
※このトラブルを避けるためには、sshの-nオプションを指定するなり、</dev/nullで明示的に入力元を切り替えることで、データの横取りを防ぎます。

今回の「ジョブ停止」に関しても、この「sshが入力を横取りする」と根が同じ話になっています。
※ですので、直接的な解決策は、やはりsshの-nオプションや</dev/nullによる明示的な入力元の切り替えです。

環境

ということで、次からsshの挙動や停止の発生する過程に迫っていこうと思いますが。
今回はVirtualBox上のCentOS8で検証しています。が、どの環境でもそう大きくは変わらないと思います。

SSHの挙動

なぜ意外に感じたのか

ということで、まずsshの挙動から見ていくことにします。
しかし、人によっては次のような疑問を持つかもしれません。
「sshが入力を横取りするなんて予め分かってることじゃないの?」「なんで経験もある人たちがそんなところで引っかかってるの?

実は、多少慣れた人でもその感覚を裏切られる挙動が隠されています。その点をまず明らかにします。

  • ローカルでのコマンド実行

まず、ローカルでのコマンド実行ですが、LinuxというかUNIX系のシステムでは、次の図のように入出力経路が整えられるのが基本です。
image.png

よく俗に「標準入力はキーボード、標準出力は画面」と言われるのは、次のようなターミナルエミュレータからのシェルでの対話実行の状況を指しています。
image.png

しかしながら、入力が発生するのはあくまでそのプログラムがkernelに入力を要求するからであって、要求がなければ経路があるだけで使われません。
なので、シェルビルトインの echo、他にも多くのコマンドでは入力が発生しないはずなのです。

image.png

  • リモートでのコマンド実行

上で見たように、入力を要求しないアプリであれば入力は発生しません。であるならば、**リモートで同様のアプリを実行しても入力は発生しないのでは?**と考えるのが自然です。
image.png

しかし実際には、確かにリモートで入力は発生していないものの、SSHの途中の経路まで入力が発生しデータが運ばれます。
※結果的にそのデータは使われず、事実上捨てられることになります。
image.png

このリモートで要求されてないはずなのに入力が発生するという状況が、内実を知らないと意外に感じられるポイントになると思います。

なぜ読み込みを行うのか

ではなぜSSHでのリモートコマンド実行で読み込みが発生するか。それには、通信を経由する各アプリ ( sshコマンド、sshd ) も含め、全体での入出力経路を把握する必要があります。それが次の図になります。

image.png

ざっくり言うと、

  • ローカルでは標準入力・標準出力・標準エラーの複製が作られる。( 図中 in2~err2 )
  • リモートコマンドの標準入力・標準出力・標準エラーとはそれぞれ専用のパイプを通じてsshdがデータの入出力を行う
  • ssh,sshd間はSSH通信により入出力データを中継する

ということで、あたかもリモートコマンドがローカルで入出力してるかのような状況を作り出しています。
※パイプを使うのは非対話の遠隔コマンド実行のためで、遠隔ログインとして対話的にシェルを起動する場合は代わりにPTYが使われます

ここで、入力部分に着目します。
image.png

結果的には、ローカルの入力用ファイル ( 図中「ファイル(in)」 ) からリモートコマンドへデータが供給されるのですが、矢印は2つに分かれています。
これは、次のように処理が2系統に分かれているためです。

  • ssh~sshd の連携で行うのは、入力データのパイプへの蓄積
  • アプリが行うのはパイプからの入力 ( SSH通信を意識しない )

この2段階に分かれていることが、入力を行わないリモートコマンドの実行でも、ローカルで入力が発生する理由となります。
なぜならば、リモートで動作するアプリが要求するのはパイプからの入力であって、sshdへ直接データ要求を行うわけではありません。そうするとsshdはいつデータが必要になるか判断する方法がないのです。
結局、ssh~sshdの連携ではパイプへの入力がいつあっても良いように可能な限りデータを蓄積しておく動作が必要であって、リモートコマンドで入力を行わなくても、自発的にsshがデータ入力を行うことになる、ということです。

なぜ停止しないことがあるのか

これで、入力を行わないリモートコマンドを実行する場合でも、sshが入力を発生させることが分かりました。
そして、まだ機構は詳らかにしていませんが、入力の発生 ( より正確には「kernelへの入力要求」 ) が、記事冒頭で紹介したジョブ停止につながっていることは分かっていることにします。

ではなぜ、停止するときとしないときがあるのか。そちらについても触れておきます。

この点に関して、結論は非常に単純です。sshは必ず入力を行うとは限らないから、です。つまり、入力を行えばジョブ停止が発生しますし、行わないなら発生しません。
そして、発生するかどうかの鍵はデータ有無を事前に問い合わせ、有るなら入力を行う、無いなら行わないという点にあります。
詳細は省きますが、この「問い合わせ」は、複数の入力経路を同時に扱う「多重I/O」のための典型的な手法です。

image.png

この図のように、対話シェルがTTY/PTYから入力データを取得し続けているような状況だと、SSHがデータ有無を問い合わせても「有り」となるとは限りません。これが「必ず入力を行うとは限らない」につながるわけです。

逆に、もし停止を確実に発生させたい場合、シェルがTTY/PTYからデータを吸い上げない状況を作れば簡単です。例えば以下のような手順です。

  1. SSH遠隔コマンド実行を含むシェルスクリプトをバックグラウンド起動する
  2. sleepコマンドを適当な秒数指定で実行する
  3. sleepコマンド実行中にEnterキーを押す
    ※ここまでをSSHが起動される前に完了させる

こうすることで、TTY/PTYには入力したEnterキー分のデータが、シェルから吸い上げられない状態で残っており、SSHは「データが残っている」TTY/PTYから入力を行おうとする、そしてジョブ停止を引き起こすということになります。

読み込みから停止につながる仕組み

前章では、( 入力が必要そうでない場面でも ) sshが入力を行う仕組みについて触れました。
ここからは、それがプロセスの停止につながる部分の話になります。

入力の競合という問題

冒頭で、「SSHが入力を横取りすることでトラブルになる例」を紹介しましたが、そのスクリプトを再掲します。

SSHの入力データ横取りが問題になる例(再掲)
while read LINE; do
  ssh USER@HOST 遠隔コマンド
  …
done < input.txt

このスクリプトでは次の図のように、while でループを制御しているシェルが read コマンドで入力する分、起動されたsshが入力する分で、同じ input.txt からデータを取り合うことになります。

image.png

これはあくまでスクリプトを実装した人の問題であって、シェルやsshに責を問うのは筋違いです。
しかし、多数のプログラムが同時に入力を行いデータを取り合ってしまうと困る状況があります。それは、シェルからバックグラウンド実行により多重コマンド起動状態となっている状況です。

image.png

もしシェル、各コマンドがTTY/PTYから自由に入力することを許してしまうと大混乱が予想されます。シェルへのコマンド文字列のつもりが、実行中のコマンドへの入力として横取りされたり、あるいは特定のコマンドへ渡すつもりのデータが別のコマンドへ渡ってしまったり。
だからといって、それぞれのコマンドの入力を予め封じてしまうようだと、多重起動する便利さを目減りさせてしまうことになります。

実のところ、入力元が通常のファイルやパイプの場合への有効な手段はないのですが、対話用のTTY/PTYと連携する対策手段をLinuxやUNIX系OSで用意しています。

ジョブとプロセスグループ

上の「入力の競合」に対する解決策、それは起動するコマンドをジョブという単位で管理し、ただ一つのジョブにだけ入力を行う権利を与えるというものです。そのために用意されているOSの機構がプロセスグループです。
※というか、「シェル上の『ジョブ』という管理単位」=「OS上の『プロセスグループ』」と思って構いません。

このプロセスグループに関して、OSの与える機能は次のものです。

  • シェルから生み出したプロセスの塊を「プロセスグループ」という単位で管理できる
    • 間接的に生み出されたプロセス ( シェルにとっての孫 ) も割り当てられたプロセスグループを引き継ぐため、シェルスクリプト等、一連の処理のかたまりがグループ化される
    • 別々のプロセスを同じプロセスグループにまとめることもできるので、command1 | command2 のようなパイプライン単位でグループ化できる
  • シェルから生み出した中で、ただ1つのプロセスグループのみがTTY/PTYの入力権を得る
    • このようなプロセスグループ(ジョブ)を「フォアグラウンド」と呼ぶ
    • その他は「バックグラウンド」と呼び、入力権が得られない
    • フォアグラウンドのジョブがない場合は、シェル自身がフォアグラウンドとなる ( コマンド文字列入力のために入力権を得る )
    • TTY/PTYのドライバレベルで入力権を制御する。
      TTYのデバイス属性を扱うtty_ioctl(4)のTIOCSPGRPが該当します。

なお、プロセスグループを束ねる単位は「セッション」です。CUIログインするとTTY/PTYデバイスが割り当てられ、ログインシェルが起動し、そこからジョブを様々動作させられますが、この「ログインシェルを中心としたプロセス群」がセッションに相当します。
ジョブ管理者としてのシェルの役割は、フォアグラウンドジョブの停止や終了を検知してシェル自身をフォアグラウンドに引き戻したり、&なしで起動したジョブや、fgコマンドによって指定されたジョブをフォアグラウンドに設定することが挙げられます。

ところでプロセスグループやセッションは、プロセスIDと同様、数字で区別されており、ps コマンドで見ることができます。( -o での pgidsidという項目が該当します )
以下、実際に複数のジョブを起動し、そのプロセスグループの状況を出力させた例です。

プロセスグループの状況
[angel@cent8 ~]$ sleep 1000 &
[1] 1567
[angel@cent8 ~]$ sleep 2000 &
[2] 1568
[angel@cent8 ~]$ xargs -n 1 -P 0 sleep <<< "3001 3002 3003" &
[3] 1569
[angel@cent8 ~]$ pstree -ap $$
bash,1356
  ├─pstree,1575 -ap 1356
  ├─sleep,1567 1000
  ├─sleep,1568 2000
  └─xargs,1569 -n 1 -P 0 sleep
      ├─sleep,1570 3001
      ├─sleep,1571 3002
      └─sleep,1572 3003
[angel@cent8 ~]$ ps -o pid,ppid,sid,pgid,args
    PID    PPID     SID    PGID COMMAND
   1356    1355    1356    1356 -bash
   1567    1356    1356    1567 sleep 1000
   1568    1356    1356    1568 sleep 2000
   1569    1356    1356    1569 xargs -n 1 -P 0 sleep
   1570    1569    1356    1569 sleep 3001
   1571    1569    1356    1569 sleep 3002
   1572    1569    1356    1569 sleep 3003
   1576    1356    1356    1576 ps -o pid,ppid,sid,pgid,args
[angel@cent8 ~]$ 

この例では、PID 1356のログインシェルを中心としたセッション1356が構成されていて、以下のような状況になっています。
※この中心になっているシェルは「セッションリーダー」という扱いになります。

  • シェル自身 ( 暗黙のジョブ ) がプロセスグループ1356、フォアグラウンドでTTY/PTYからコマンド文字列を入力
  • sleep 1000 がプロセスグループ 1567 のジョブ %1 番
  • sleep 2000 がプロセスグループ 1568 のジョブ %2 番
  • xargsおよび、そこから起動された sleep 3001, 3002, 3003 がまとめてプロセスグループ1596のジョブ %3 番

なお、プロセスグループのことをシグナルをまとめて送れる単位ということでご存知の方もいるかも知れません。実際、コマンドとしての kill や、API(システムコール)としての kill はマイナスのPID値を指定することでプロセスグループに対するシグナル送信を行うことができます。
これは、kill %1のように、シェルのビルトインのkillコマンドで、ジョブ単位にシグナルを送るためにも活用されています。

制御端末とシグナル

さて、上の「プロセスグループに対するTTY/PTYの入力権」の話ですが、これは全プロセスの中で特定のプロセス群にだけ許可を与えるという話とはちょっと違います。実際、ユーザレベルでのパーミッションが適切なら、全然無関係なプロセスがTTY/PTYから読み込んで入力データを横取りできてしまうからです。

この入力権の制御は、シェルがセッションを構成する時に、割り当てられたTTY/PTYを「制御端末」とすることで行っています。
以前「TTY/PTYに関するクイズ」という記事の「制御端末」の章で取り上げた**「『紐づいているプロセスに何か働きかける』そのような対象となるTTY/PTYデバイス」**ということです。

その記事でも話題に上げた通り、制御端末はTTY/PTYドライバからのシグナル送信と密接に関連しています。ざっと挙げると、

  • キーボード割込みをTTY/PTYドライバで解釈してフォアグラウンドジョブに対してシグナル送信を行う
    • つまり、フォアグラウンド・バックグラウンドはPTY/TTYの入力権だけでなく、割り込みが発生する範囲の管理も担う
    • 一般にCtrl-CはINTシグナルによるジョブ終了を引き起こす
    • 一般にCtrl-ZはTSTPシグナルによるジョブ停止を引き起こす
    • どの文字がどのシグナルに該当するかは、sttyコマンドで調整することができる
  • TTY/PTYが回線切断扱いになった時の、セッションリーダーへのHUP(HangUP:回線切断)シグナル送信
    • セッションリーダーとなっているシェルは、このHUPシグナルを受けて、各ジョブにHUPシグナルを配る
    • 「接続が切れても動作が途切れないようにnohupコマンドを」というのは、このHUPシグナル対策として習慣化されているもの。
      ※nohup以外にも、disownによる管理対象からの切り離しや、setsidによるセッション分離等の対策がある
  • バックグラウンドなプロセスグループからのPTY/TTYの入力に対する、TTINシグナルの送付
    • シグナルを受けたプロセスグループはTSTP等のシグナルと同じように、通常は停止される
      ※アプリ側で意図的に当該シグナルを無視したとしても、バックグラウンドだと入力権がないので、結局読み込みには失敗する
    • シグナルで止められることによって、フォアグラウンドで復帰した時に、ちょうどPTY/TTYの入力処理から再開できるようになっている

このシグナルの最後の項目で出てきましたが、冒頭の「シェルスクリプト内のsshによってジョブ自体が停止した」というのは、当にバックグラウンド状態のsshのTTY/PTY読み込みによるTTINシグナルによるものです。
このように、制御端末とシグナルとは密接な関係があるということです。

おわりに

まとめ

  • シェルスクリプト内のsshがジョブ停止を引き起こした原因は、sshがTTY/PTYに対して入力を行ったことによるもの
  • リモートコマンドが入力を行わない場合も、ssh~sshd側はその判断ができないため、入力データがあれば入力処理を行い、sshdとリモートコマンドを繋ぐパイプに蓄積させる
  • バックグラウンドのsshがTTY/PTYへ入力を行うことでジョブ停止になるのは、TTY/PTYドライバによるTTINシグナルによるもの
  • TTY/PTYはフォアグラウンドジョブのみ入力権が得られるよう、キーボード割込みがフォアグラウンドジョブに限られるよう、OSで制御されている

所感

ということで、最近体験したssh入りスクリプトの停止にかこつけて、sshの入出力経路の話、ジョブ制御や制御端末の話を書いてみました。
なんの変哲もないCUI実行の裏側でも、ジョブを多重実行し制御するための工夫がつまっているということで、その一端に触れることができれば良いかなと思います。

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?