Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

Dockerコンテナ上のプロセスが残り続ける問題をPTYを使って回避した話

こんにちは、バックエンドエンジニアのogatasoです。今回はDockerコンテナ上でプロセスが残り続けてしまう問題をPTY(pseudo terminal)を噛ませたタイムアウト処理で対応した話を紹介します。

はじめに

ミラティブでは、開発環境や本番環境のMySQLのレコードを確認する際、sshで踏み台サーバに接続し、docker exec -it を使ってdockerコンテナ上でMySQL接続用のスクリプトを実行しています。しかし、このときMySQLクライアントのプロセスを終了させずにターミナルを閉じてしまうと、プロセスがサーバ上に残り続けてしまうという現象に悩まされていました。

原因調査

原因は docker exec -it にあったようです。このコマンドは、すでに動いているコンテナ内で追加のプロセスを起動し、擬似TTY (-t) と標準入出力のアタッチ (-i) を通して対話することができるというものです。ただし、この擬似TTY は ホストの端末(TTY) とは別物となっており、ホスト側でターミナルを閉じても、 SIGHUP がコンテナ上のプロセスに伝わらずそのまま残ってしまいます。

対応方法の調査

ChatGPTと相談しながら、解決策を調査しました。

docker execを使って起動したプロセスをターミナルが閉じられた際に終了させる方法として有効な手段はなく、きちんとexitする、もしくはホストから明示的にkillするといった対応策しか見つかりませんでした。

そのため、MySQLの接続用に使っているGoスクリプトに手を入れる方針に切り替えました。

最初に試したのは MySQLクライアント側にタイムアウトを設定できるオプションがないか というアプローチです。以下のような、それっぽい名前のオプションは存在しましたが、どれも問題解決には至りませんでした。

  • connect-timeout : 接続確立時のタイムアウトを設定するもの

  • interactive_timeout : 一定時間のアイドル状態ののち、MySQLサーバとの接続は切れるが、接続元のプロセス自体は終了しない

次に検討したのは、単純にタイマーを仕込んでN時間後にプロセスを終了させる方法です。ただし、この方式だと「実際に利用中でもN時間を過ぎれば強制終了される」というデメリットがありました。

また、spawnexpect コマンドを使って「一定時間MySQLからの出力がなければ exit を発行する」という方法も考えましたが、パスワードや接続先を取得するための処理なども含むそれなりのロジックをシェルで書く必要があり、メンテナンスコストがかかりそうという懸念がありました。

そこで最終的に選んだのは、creack/pty を利用する方法です。擬似ターミナル(PTY)上でMySQLを実行し、自前でアイドルタイマーを実装することで、「放置された場合のみ終了させる」という理想的な挙動を実現できました。

シンプルなN時間タイマー方式でも実運用上は大きな問題はなさそうでしたが、PTY方式のほうが技術的に面白く、またテックブログネタとしても価値があると判断しました。

実装概要

実装は メイン処理+5本のgoroutine で構成されています。軽く頭に入れておいていただけるとコード解説の章で理解がしやすいかと思います。

  • メイン : PTYを作成し、MySQLコマンドを実行して終了を待機
  • G1 : PTYの出力を stdout へ転送
  • G2 : SIGWINCH を監視して、端末サイズの変更をPTYに反映
  • G3 : SIGINT / SIGTERM を子プロセスに転送
  • G4 : アイドルタイマーの管理
  • G5 : stdin の入力をPTYに転送し、入力発生時にタイマーをリセット

利用したチャネルは以下の通りです。

  • winchC, winchStopC : G2用
  • fwdC, fwdStopC : G3用
  • idleResetC : 入力があったらG4のタイマーをリセット
  • quitC : cleanup時にG4を終了
  • timer.C : 指定時間入力がなければ発火し、子プロセスに SIGTERM → 一定時間後に kill
  • stdinDoneC : G5の終了をcleanupに通知

入力から出力までを表すシーケンス図

コード解説

メイン処理 & G1

setupInteractivePTY でPTYを作成し、MySQLクライアントの出力を os.Stdout に転送しつつ、終了を待ちます。

mysqlCmd := exec.CommandContext(ctx, "mysql", args...)
ptmx, ttyCleanup, err := setupInteractivePTY(mysqlCmd, timeout)
if err != nil {
    return err
}
defer ttyCleanup()

// MySQL → stdout
go func() {
    _, _ = io.Copy(os.Stdout, ptmx)
}()

if err := mysqlCmd.Wait(); err != nil && !errors.Is(err, context.Canceled) {
    return errors.WithStack(err)
}

setupInteractivePTYでは以下で紹介していくgoroutineの設定等を行なっています。

端末をRawモードに設定

矢印キー入力を正しく扱えるよう、端末をrawモードに設定します。これをしていないと を押してもカーソルが左に移動せず、\x1b[D のようなエスケープシーケンスとして入力されてしまいます。

var oldState *term.State
if term.IsTerminal(int(os.Stdin.Fd())) {
    if st, e := term.MakeRaw(int(os.Stdin.Fd())); e == nil {
        oldState = st
    }
}

G2: 端末サイズの追従

SIGWINCH を監視し、ターミナルサイズが変わったら pty.InheritSize を再度呼び出して追従させます。

// 端末サイズ追従
_ = pty.InheritSize(os.Stdin, ptmx)
winchC := make(chan os.Signal, 1)
signal.Notify(winchC, syscall.SIGWINCH)
winchStopC := make(chan struct{})
go func() {
    for {
        select {
        case <-winchStopC:
            return
        case <-winchC:
            _ = pty.InheritSize(os.Stdin, ptmx)
        }
    }
}()

G3: シグナル転送

親プロセスが SIGINT / SIGTERM を受け取った場合、MySQLクライアントへ転送します。この処理がなければ子プロセスであるMySQLクライアントが終了せず、孤児プロセスになります。

// 親の SIGINT/SIGTERM を子へ転送
fwdC := make(chan os.Signal, 2)
signal.Notify(fwdC, os.Interrupt, syscall.SIGTERM)
fwdStopC := make(chan struct{})
go func() {
    for {
        select {
        case <-fwdStopC:
            return
        case s := <-fwdC:
            if command.Process != nil {
                _ = command.Process.Signal(s)
            }
        }
    }
}()

G4: アイドルタイマー

タイマー部分はtimer.C, idleResetC, quitCの3つのチャネルからの入力を待ちます。

それぞれ以下のような働きをします。

  • 入力がない状態が一定時間続くと SIGTERM を送信
  • 入力があればタイマーをリセット
  • cleanupされるタイミングで残っていれば kill を実行
// タイマー
idleResetC := make(chan struct{}, 1)
quitC := make(chan struct{})
timer := time.NewTimer(timeout)

go func() {
    for {
        select {
        case <-timer.C:
            fmt.Fprintf(os.Stdout, "idle timeout: no input for %s; sending SIGTERM...\n", timeout)
            terminate(command.Process)
            return
        case <-idleResetC:
            if !timer.Stop() {
                select {
                case <-timer.C:
                default:
                }
            }
            timer.Reset(timeout)
        case <-quitC:
            if !timer.Stop() {
                select {
                case <-timer.C:
                default:
                }
            }
            return
        }
    }
}()

また、ここで呼んでいるterminateは以下のようになっています。

func terminate(p *os.Process) {
    if p == nil {
        return
    }
    _ = p.Signal(syscall.SIGTERM)

    // しばらくしても終了しなければkillする
    time.AfterFunc(5*time.Second, func() {
        _ = p.Kill()
    })
}

G5: 入力転送とタイマーリセット

入力が発生するたびに idleResetC を通じてタイマーをリセットします。 また、Ctrl+C(^C = 0x03)が入力された場合は子プロセスに SIGINT を送る処理も入れています。

rw := resetWriter{dst: ptmx, proc: command.Process, idleResetC: idleResetC}
stdinDoneC := make(chan struct{})
go func() {
    _, _ = io.Copy(rw, os.Stdin)
    close(stdinDoneC)
}()

ここでresetWriterは以下のようになっています。Writeメソッドをオーバーライドし、タイマーリセットやCtrl+Cの処理を追加しています。

type resetWriter struct {
    dst        io.Writer
    proc       *os.Process
    idleResetC chan struct{} // 入力時にタイマーへリセット通知
}

func (w resetWriter) Write(p []byte) (int, error) {
    select {
    case w.idleResetC <- struct{}{}:
    default:
    }

    // Ctrl+C (^C = 0x03) に対応させる
    if len(p) == 1 && p[0] == 0x03 && w.proc != nil {
        _ = w.proc.Signal(syscall.SIGINT)
        return 1, nil
    }

    n, err := w.dst.Write(p)
    if err != nil {
        return 0, errors.Wrap(err, "failed to write to PTY")
    }
    return n, nil
}

クリーンアップ

最後に、rawモードを元に戻し、各goroutineを終了させ、チャネルをクローズしてリソースを解放します。

cleanup = func() {
    // 停止・復元の順序に注意
    if oldState != nil {
        _ = term.Restore(int(os.Stdin.Fd()), oldState)
    }
    close(quitC)
    signal.Stop(winchC)
    close(winchStopC)
    signal.Stop(fwdC)
    close(fwdStopC)

    select {
    case <-stdinDoneC:
    case <-time.After(100 * time.Millisecond):
    }
    _ = ptmx.Close()
}

結果

以上の対応で、しばらく放置していたらプロセスが勝手に切れるという動作を実現することができ、放置されたMySQLクライアントがサーバに残り続ける問題を解決できました!

MySQLクライアントのプロセスがタイムアウトで落ちる様子

タイムアウト機能の導入以前と変わらない操作感となっており、問題も特に発生していません。 また、実装の中でターミナル制御やシグナル転送などの知見も得られ、技術的にも面白い題材になりました。

We are hiring

ミラティブではエンジニアを絶賛募集中です。日本全国どこからでもフルリモート勤務が可能です。ご興味のある方はお気軽にご連絡ください!

speakerdeck.com

mirrativ.notion.site

www.mirrativ.co.jp