pt-online-schema-changeにnohupをつけてバックグラウンドで実行してもSIGHUPを受け取って終了する現象

Platform Team/Sys-Infra Unitの伊豆です。今回は、pt-online-schema-changenohupをつけてバックグラウンドで実行してもSIGHUPを受け取って終了する現象に遭遇したので、そのときに調査した内容を共有します。

調査は以下の環境で行いました。

  • pt-online-schema-change v3.6.0
  • Amazon Linux 2023
  • bash 5.2.15
  • OpenSSH 8.7p1

背景

ReproではAurora MySQLを使っていて、レコード数の多いテーブルのスキーマを変更するときに、pt-online-schema-changeを使うことがあります 1pt-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-changeSIGHUPを受け取って終了する
  • exitで終了した場合は、pt-online-schema-changeSIGHUPを受け取らずに実行が継続される

なぜnohupをつけて実行したpt-online-schema-changeがSIGHUPを受け取ったときに終了するのか

結論からいうと、pt-online-schema-changeSIGHUPを受け取ったら終了するようにシグナルハンドラーを設定しているからです 2normal-signalsSIGHUPSIGINTSIGPIPESIGTERMを表しています。

nohupSIGHUPを無視するシグナルハンドラーを設定して引数として渡されたコマンドを実行(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-changeSIGHUPを受け取ったときに終了する理由は分かったので、次に「SSH接続が切れたときにSIGHUPを受け取り、exitで終了した場合はSIGHUPを受け取らない」理由を調査しました。

その理由は、技術/UNIX/なぜnohupをバックグランドジョブとして起動するのが定番なのか?(擬似端末, Pseudo Terminal, SIGHUP他) - Glamenv-Septzen.netにとても詳しく書いてありました。

まとめると以下のような理由で、pt-online-schema-changeSIGHUPが送られたり、送られなかったりしていました。

  • SSH接続が切れた場合
    • bashSIGHUPを受け取って終了する。このときbashは全てのジョブ(プロセスグループ)にSIGHUPを送る
  • exitで終了する場合
    • huponexitがオンの場合、bashは全てのジョブ(プロセスグループ)にSIGHUPを送り、オフの場合は送らない。デフォルトはオフ。

SSHでログインした直後の状態

SSHでログインした直後のsshdbashプロセスは以下のようになっています。

sshdbashは疑似端末を通して通信し、疑似端末のmaster側(pty master)がsshd、slave側(pty slave)がbashにつながっています。このとき、疑似端末のslave側がセッションの制御端末になりbashは新しいセッションのセッションリーダーになります。

ここから、nohupをつけてpt-online-schema-changeをバックグラウンドで実行すると以下のようになります。

バックグラウンドで実行しているpt-online-schema-changeは、bashと同じセッションにいますがプロセスグループは異なっています。

SSH接続が切れた場合

SSH接続が切れた場合、bashカーネルからSIGHUPを受け取り終了します。このときの流れのイメージは以下のようになっています。

sshdSSH接続が切れたことを検知すると、疑似端末の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);
}

https://github.com/openssh/openssh-portable/blob/V_8_7_P1/serverloop.c#L362

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__);
}

https://github.com/openssh/openssh-portable/blob/V_8_7_P1/dispatch.c#L129

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;
~~
}

https://github.com/openssh/openssh-portable/blob/V_8_7_P1/packet.c#L1714

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");
}

https://github.com/openssh/openssh-portable/blob/V_8_7_P1/packet.c#L1897C1-L1907C2

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);
~~
}

https://github.com/openssh/openssh-portable/blob/V_8_7_P1/sshd.c#L2429

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);
}

https://github.com/openssh/openssh-portable/blob/V_8_7_P1/session.c#L2643

このとき、カーネルからセッションリーダーであるbashSIGHUPが送られます。

擬似端末の場合、"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

bashSIGHUPを受け取る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*/
}

https://git.savannah.gnu.org/cgit/bash.git/tree/builtins/exit.def?h=bash-5.2#n58

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);
}

https://git.savannah.gnu.org/cgit/bash.git/tree/shell.c?h=bash-5.2#n834

exit_shellではhuponexitがオフの場合、hangup_all_jobsを実行しないのでpt-online-schema-changeを実行しているプロセスグループにはSIGHUPが送られません。huponexitはデフォルトでオフになっています。

また、セッションリーダーが終了したときにカーネルからSIGHUPが送られるのはフォアグラウンドのプロセスグループのみなので、バックグラウンドで実行しているpt-online-schema-changeSIGHUPを受け取らずそのまま実行が継続されます。

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を実行する
  • screentmuxなどのターミナルマルチプレクサを使う

バックグランドでpt-online-schema-changeを実行してexitで終了する

上述したようにhuponexitがオフの場合、exitで終了するとpt-online-schema-changeを実行しているプロセスグループにはSIGHUPが送られません。

バックグラウンドで実行したpt-online-schema-changeに対して、disownを実行する

bashSIGHUPを受け取ると、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);
~~
}

https://git.savannah.gnu.org/cgit/bash.git/tree/builtins/jobs.def?h=bash-5.2#n234

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;
}

https://git.savannah.gnu.org/cgit/bash.git/tree/jobs.c?h=bash-5.2#n1598

J_NOHUPフラグが付いていればbashはそのジョブにSIGHUPを送らないのでそのまま実行が継続されます。

また、オプションをつけずにdisownを実行しpt-online-schema-changeをジョブリストから除外するのでも大丈夫です。

screentmuxなどのターミナルマルチプレクサを使う

tmuxで新しくセッションを作り、そのセッションでpt-online-schema-changeを実行したとき、sshdbashtmuxの関係は以下のようになっています。

tmuxで新しくセッションを作ると新しくbash2が起動し、そのbash2tmux-serverと疑似端末を通して通信します。sshdから起動されたbash1のセッションでは、tmux-clientがフォアグラウンドで起動し疑似端末を通してsshdと通信します。

SSH接続が切れると、カーネルからセッションリーダーのbash1SIGHUPが送られbash1から各ジョブに対してSIGHUPを送りますが、SIGHUPが送られるのはtmux clientであるため、pt-online-schema-changeは何も影響を受けずそのまま実行が継続されます。

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

まとめ

今回は、pt-online-schema-changenohupをつけてバックグラウンドで実行しても、SIGHUPを受け取って終了する現象の調査内容を共有しました。nohupをつけてバックグラウンドで実行しているコマンドがSIGHUPを受け取って終了してしまうことに驚きましたが、対策は良く言及されているものが有効であることを再確認できました。

最後に、Reproではエンジニアを募集中です。もし興味を持っていただけるようでしたら、採用情報ページからぜひご応募ください。

参考にしたサイトやドキュメント


  1. pt-online-schema-change の実行が必要かどうか判断するタイミングをより早くした話 - Repro Tech Blog
  2. sig_intという名前でSIGINT以外のシグナルも対象にシグナルハンドラーを設定しているのが本当に意図したことなのか疑問ですが、ここでは意図通りであるという前提で話を進めます