Platform Team/Sys-Infra Unitの伊豆です。今回は、pt-online-schema-changeにnohupをつけてバックグラウンドで実行してもSIGHUPを受け取って終了する現象に遭遇したので、そのときに調査した内容を共有します。
調査は以下の環境で行いました。
背景
ReproではAurora MySQLを使っていて、レコード数の多いテーブルのスキーマを変更するときに、pt-online-schema-changeを使うことがあります 1 。pt-online-schema-changeを実行するときは、実行用のサーバーにSSHでログインして実行しています。

中には10億レコード以上あるテーブルもあり、pt-online-schema-changeが完了するまで数日かかる場合があります。そのため、実行用のサーバーとのSSH接続が切れてもpt-online-schema-changeの実行が続くように、nohupをつけてバックグラウンドでpt-online-schema-changeを実行しています。
それにもかかわらず、SIGHUPを受け取ってpt-online-schema-changeが終了してしまう現象が時々発生し、そのたびにpt-online-schema-changeをやり直していました。
Copying `repro`.`users`: 4% 4+15:40:44 remain Copying `repro`.`users`: 4% 4+15:29:30 remain Copying `repro`.`users`: 4% 4+15:18:51 remain Copying `repro`.`users`: 4% 4+15:07:47 remain Copying `repro`.`users`: 4% 4+14:57:25 remain Copying `repro`.`users`: 4% 4+14:47:09 remain # Exiting on SIGHUP. Not dropping triggers because the tool was interrupted. To drop the triggers, execute:
何回かこの現象に遭遇して、以下の2つのことが分かりました。
- SSHクライアントが起動しているPCのスリープなどによってSSH接続が切れると、
pt-online-schema-changeがSIGHUPを受け取って終了する exitで終了した場合は、pt-online-schema-changeはSIGHUPを受け取らずに実行が継続される
なぜnohupをつけて実行したpt-online-schema-changeがSIGHUPを受け取ったときに終了するのか
結論からいうと、pt-online-schema-changeがSIGHUPを受け取ったら終了するようにシグナルハンドラーを設定しているからです 2 。normal-signalsはSIGHUP、 SIGINT、 SIGPIPE、 SIGTERMを表しています。
nohupはSIGHUPを無視するシグナルハンドラーを設定して引数として渡されたコマンドを実行(execvp)します。execveのmanページによると、POSIXでは無視するように設定されたシグナルハンドラーはexec後も引き継がれることになっています。
POSIX.1 specifies that the dispositions of any signals that are ignored or set to the default are left unchanged
これによって、nohupで実行したコマンドはSIGHUPを受け取ってもそれを無視するようになります。しかし、pt-online-schema-changeのようにSIGHUPを受け取ったときに終了するシグナルハンドラーを設定していると、最後に設定したシグナルハンドラーが有効になり、たとえnohupをつけて実行していてもSIGHUPを受け取ると終了してしまいます。
なぜSSH接続が切れたときにSIGHUPを受け取り、exitで終了した場合はSIGHUPを受け取らないのか
nohupをつけて実行したpt-online-schema-changeがSIGHUPを受け取ったときに終了する理由は分かったので、次に「SSH接続が切れたときにSIGHUPを受け取り、exitで終了した場合はSIGHUPを受け取らない」理由を調査しました。
その理由は、技術/UNIX/なぜnohupをバックグランドジョブとして起動するのが定番なのか?(擬似端末, Pseudo Terminal, SIGHUP他) - Glamenv-Septzen.netにとても詳しく書いてありました。
まとめると以下のような理由で、pt-online-schema-changeにSIGHUPが送られたり、送られなかったりしていました。
- SSH接続が切れた場合
bashはSIGHUPを受け取って終了する。このときbashは全てのジョブ(プロセスグループ)にSIGHUPを送る
exitで終了する場合huponexitがオンの場合、bashは全てのジョブ(プロセスグループ)にSIGHUPを送り、オフの場合は送らない。デフォルトはオフ。
SSHでログインした直後の状態
SSHでログインした直後のsshdやbashプロセスは以下のようになっています。

sshdとbashは疑似端末を通して通信し、疑似端末のmaster側(pty master)がsshd、slave側(pty slave)がbashにつながっています。このとき、疑似端末のslave側がセッションの制御端末になりbashは新しいセッションのセッションリーダーになります。
ここから、nohupをつけてpt-online-schema-changeをバックグラウンドで実行すると以下のようになります。

バックグラウンドで実行しているpt-online-schema-changeは、bashと同じセッションにいますがプロセスグループは異なっています。
SSH接続が切れた場合
SSH接続が切れた場合、bashはカーネルからSIGHUPを受け取り終了します。このときの流れのイメージは以下のようになっています。

sshdはSSH接続が切れたことを検知すると、疑似端末のmaster側(pty master)をクローズします。
sshdが入力を待っているときにSSH接続が切れたことを検知すると、clean_exitが実行されて疑似端末のmaster側(pty master)がクローズされます。
void server_loop2(struct ssh *ssh, Authctxt *authctxt) { ~ for (;;) { process_buffered_input_packets(ssh); ~~ } ~~~ static void process_buffered_input_packets(struct ssh *ssh) { ssh_dispatch_run_fatal(ssh, DISPATCH_NONBLOCK, NULL); }
int ssh_dispatch_run(struct ssh *ssh, int mode, volatile sig_atomic_t *done) { ~~ for (;;) { ~~ r = ssh_packet_read_poll_seqnr(ssh, &type, &seqnr); if (r != 0) return r; ~~ } void ssh_dispatch_run_fatal(struct ssh *ssh, int mode, volatile sig_atomic_t *done) { int r; if ((r = ssh_dispatch_run(ssh, mode, done)) != 0) sshpkt_fatal(ssh, r, "%s", __func__); }
int ssh_packet_read_poll_seqnr(struct ssh *ssh, u_char *typep, u_int32_t *seqnr_p) { ~~ for (;;) { ~~ r = ssh_packet_read_poll2(ssh, typep, seqnr_p); ~~ switch (*typep) { ~~ case SSH2_MSG_DISCONNECT: ~~ do_log2(ssh->state->server_side && reason == SSH2_DISCONNECT_BY_APPLICATION ? SYSLOG_LEVEL_INFO : SYSLOG_LEVEL_ERROR, "Received disconnect from %s port %d:" "%u: %.400s", ssh_remote_ipaddr(ssh), ssh_remote_port(ssh), reason, msg); ~~ return SSH_ERR_DISCONNECTED; ~~ }
static void sshpkt_vfatal(struct ssh *ssh, int r, const char *fmt, va_list ap) { switch (r) { ~~ case SSH_ERR_DISCONNECTED: ssh_packet_clear_keys(ssh); logdie("Disconnected from %s", remote_id); ~~ } void sshpkt_fatal(struct ssh *ssh, int r, const char *fmt, ...) { va_list ap; va_start(ap, fmt); sshpkt_vfatal(ssh, r, fmt, ap); /* NOTREACHED */ va_end(ap); logdie_f("should have exited"); }
void sshlogdie(const char *file, const char *func, int line, int showfunc, LogLevel level, const char *suffix, const char *fmt, ...) { va_list args; va_start(args, fmt); sshlogv(file, func, line, showfunc, SYSLOG_LEVEL_INFO, suffix, fmt, args); va_end(args); cleanup_exit(255); }
https://github.com/openssh/openssh-portable/blob/V_8_7_P1/log.c#L435
void cleanup_exit(int i) { if (the_active_state != NULL && the_authctxt != NULL) { do_cleanup(the_active_state, the_authctxt); ~~ }
void session_pty_cleanup2(Session *s) { /* * Close the server side of the socket pairs. We must do this after * the pty cleanup, so that another process doesn't get this pty * while we're still cleaning up. */ if (s->ptymaster != -1 && close(s->ptymaster) == -1) error("close(s->ptymaster/%d): %s", s->ptymaster, strerror(errno)); ~~ } ~~ void do_cleanup(struct ssh *ssh, Authctxt *authctxt) { ~~ session_destroy_all(ssh, session_pty_cleanup2); }
このとき、カーネルからセッションリーダーであるbashにSIGHUPが送られます。
擬似端末の場合、"master"側デバイスのファイル記述子が全てclose()されたのを"hangup"の合図として、端末のデバイスドライバがセッションリーダーのプロセスにSIGHUPを送信する。
技術/UNIX/なぜnohupをバックグランドジョブとして起動するのが定番なのか?(擬似端末, Pseudo Terminal, SIGHUP他) - Glamenv-Septzen.netより
If fildes refers to the manager side of a pseudo-terminal, and this is the last close, a SIGHUP signal shall be sent to the controlling process
https://pubs.opengroup.org/onlinepubs/9799919799/functions/close.html
bashはSIGHUPを受け取るとhangup_all_jobs()を実行し、J_NOHUPフラグが付いていないジョブにSIGHUPを送ります。
The shell exits by default upon receipt of a SIGHUP. Before exiting, an interactive shell resends the SIGHUP to all jobs, running or stopped. Stopped jobs are sent SIGCONT to ensure that they receive the SIGHUP. To prevent the shell from sending the signal to a particular job, it should be removed from the jobs table with the disown builtin (see SHELL BUILTIN COMMANDS below) or marked to not receive SIGHUP using disown -h.
https://linux.die.net/man/1/bash
SIGHUPを受け取ってもnohupをつけて実行したコマンドはそれを無視しますが、pt-online-shema-changeのようにSIGHUPを受け取ったときに終了するようにシグナルハンドラーを再設定していると、上述したようにたとえnohupをつけていたとしてもSIGHUPを受け取ったときに終了します。
exitで終了した場合
exitで終了するときは、SSH接続が切れた場合とは異なりexit_shellで終了します。
該当コード
int exit_builtin (list) WORD_LIST *list; { ~~ return (exit_or_logout (list)); } ~~ static int exit_or_logout (list) WORD_LIST *list; { ~~ /* Exit the program. */ jump_to_top_level (EXITBLTIN); /*NOTREACHED*/ }
int reader_loop () { ~~ case EXITBLTIN: current_command = (COMMAND *)NULL; EOF_Reached = EOF; goto exec_done; ~~ exec_done: QUIT; ~~ return (last_command_exit_value); }
https://git.savannah.gnu.org/cgit/bash.git/tree/eval.c?h=bash-5.2#n98
int main (argc, argv, env) int argc; char **argv, **env; #endif /* !NO_MAIN_ENV_ARG */ { ~~ /* Read commands until exit condition. */ reader_loop (); exit_shell (last_command_exit_value); }
exit_shellではhuponexitがオフの場合、hangup_all_jobsを実行しないのでpt-online-schema-changeを実行しているプロセスグループにはSIGHUPが送られません。huponexitはデフォルトでオフになっています。
また、セッションリーダーが終了したときにカーネルからSIGHUPが送られるのはフォアグラウンドのプロセスグループのみなので、バックグラウンドで実行しているpt-online-schema-changeはSIGHUPを受け取らずそのまま実行が継続されます。
SIGHUPの受信にかかわらず、プロセスが終了するとき、そのプロセスがセッションリーダーだった場合、カーネルからそのセッションのフォアグラウンドプロセスグループに対してSIGHUPが送信される。
技術/UNIX/なぜnohupをバックグランドジョブとして起動するのが定番なのか?(擬似端末, Pseudo Terminal, SIGHUP他) - Glamenv-Septzen.netより
If the process is a controlling process, the SIGHUP signal shall be sent to each process in the foreground process group of the controlling terminal belonging to the calling process.
https://pubs.opengroup.org/onlinepubs/9799919799/functions/_Exit.html
対策
SSH接続が切れた場合でもpt-online-schema-changeの実行が継続されるようにするためには、以下のような対策が必要でした。
- バックグランドで
pt-online-schema-changeを実行してexitで終了する - バックグラウンドで実行した
pt-online-schema-changeに対して、disownを実行する screen、tmuxなどのターミナルマルチプレクサを使う
バックグランドでpt-online-schema-changeを実行してexitで終了する
上述したようにhuponexitがオフの場合、exitで終了するとpt-online-schema-changeを実行しているプロセスグループにはSIGHUPが送られません。
バックグラウンドで実行したpt-online-schema-changeに対して、disownを実行する
bashはSIGHUPを受け取ると、J_NOHUPフラグが付いていないジョブにSIGHUPを送ります。このJ_NOHUPフラグはdisown -hコマンドで指定したジョブに付けることができます。
該当コード
int disown_builtin (list) WORD_LIST *list; { ~~ case 'h': nohup_only = 1; break; ~~ else if (nohup_only) nohup_job (job); ~~ }
void nohup_job (job_index) int job_index; { register JOB *temp; if (js.j_jobslots == 0) return; if (temp = jobs[job_index]) temp->flags |= J_NOHUP; }
J_NOHUPフラグが付いていればbashはそのジョブにSIGHUPを送らないのでそのまま実行が継続されます。
また、オプションをつけずにdisownを実行しpt-online-schema-changeをジョブリストから除外するのでも大丈夫です。
screen、tmuxなどのターミナルマルチプレクサを使う
tmuxで新しくセッションを作り、そのセッションでpt-online-schema-changeを実行したとき、sshd、bash、tmuxの関係は以下のようになっています。

tmuxで新しくセッションを作ると新しくbash2が起動し、そのbash2はtmux-serverと疑似端末を通して通信します。sshdから起動されたbash1のセッションでは、tmux-clientがフォアグラウンドで起動し疑似端末を通してsshdと通信します。
SSH接続が切れると、カーネルからセッションリーダーのbash1にSIGHUPが送られbash1から各ジョブに対してSIGHUPを送りますが、SIGHUPが送られるのはtmux clientであるため、pt-online-schema-changeは何も影響を受けずそのまま実行が継続されます。

最終的にはpt-online-schema-changeは以下のような状態になり、SSH接続が切れた場合でも実行が継続されます。

まとめ
今回は、pt-online-schema-changeにnohupをつけてバックグラウンドで実行しても、SIGHUPを受け取って終了する現象の調査内容を共有しました。nohupをつけてバックグラウンドで実行しているコマンドがSIGHUPを受け取って終了してしまうことに驚きましたが、対策は良く言及されているものが有効であることを再確認できました。
最後に、Reproではエンジニアを募集中です。もし興味を持っていただけるようでしたら、採用情報ページからぜひご応募ください。
参考にしたサイトやドキュメント
- 技術/UNIX/なぜnohupをバックグランドジョブとして起動するのが定番なのか?(擬似端末, Pseudo Terminal, SIGHUP他) - Glamenv-Septzen.net
- https://man7.org/linux/man-pages/
- https://pubs.opengroup.org/onlinepubs/9799919799/
- Terminals and pseudoterminals | Viacheslav Biriukov
- Terminal under the hood - TTY & PTY - Ahmed Yakout
- Tmux How-To - Product Documentation and Knowledge Base - Stromasys Wiki
- pt-online-schema-change の実行が必要かどうか判断するタイミングをより早くした話 - Repro Tech Blog↩
-
sig_intという名前でSIGINT以外のシグナルも対象にシグナルハンドラーを設定しているのが本当に意図したことなのか疑問ですが、ここでは意図通りであるという前提で話を進めます↩
