サーバーが作業を完了するまで待つ

Go 言語Beginner
オンラインで実践に進む

はじめに

ダウンロード窓口は、すでに実地ノートを受信している人の転送を切らずに閉じる必要があります。即時の接続切断を Go の通常の HTTP 終了調整に置き換え、元のプロセスから実際の完全なダウンロードを残します。

この単元は準備済みハンドラーと同じ 2 文書を使って独立して始まります。前単元の正確な PID と受け付け済みリクエストの観測、または同等の経験に加え、ハンドラー、エラー、goroutine、channel の知識が必要です。シグナル配送、有限の context、エラー照合、main の待機責任は以下で説明します。対象は通常の HTTP であり、WebSocket、分散トラフィック切り替え、本番環境の無停止保証は扱いません。約 60 分という著者見積もりです。

main に終了処理の完了を待たせる

このステップでは、準備済み HTTP ハンドラーを保ったまま、有限時間の終了調整を実装します。

Downloads から始め、独立した作業ディレクトリに入ります。

cd /home/labex/project/download-desk
cat lifecycle.go

変更できるのは lifecycle.go だけです。main.go のハンドラー、両文書、go.mod、準備済み観測ツールと観測記録は変更しません。現在の関数は SIGTERM を捕捉しますが、Close を呼んで活動中の接続を中断します。シグナルを処理するだけでは、正常な終了調整にはなりません。

http.Server はリスナーと活動中の HTTP 接続を管理します。ListenAndServe は作業を受け付けながらブロックします。Shutdown はまずリスナーと既存のアイドル接続を閉じ、通常の活動中リクエストがアイドルになるまで待ちます。終了を始めると、受け付け済みハンドラーがまだ動いていても、ListenAndServe は直ちに http.ErrServerClosed を返します。この時点で main が戻ると全 goroutine が消え、転送が切れます。したがって主処理は Shutdown 自体が返るのを待つ必要があります。

context はキャンセルまたは期限の予算を持ちます。context.Background() が出発点の context です。context.WithTimeout(..., 45*time.Second) は有限の 45 秒終了予算と cancel 関数を作ります。defer cancel() は作業が早く終わった場合も、関数から戻る際に関連リソースを解放します。キャンセルは完了の証明ではありません。公開上限は停止要求から 60 秒です。受け付け済み応答が選んだ予算内に終わらない場合は、エラーを報告し、その上限までに非ゼロで終了します。参考実装は 45 秒ですが、60 秒以内の同等な有限予算も認められます。チェックには 60 秒内に終わらない有限の長い応答が含まれるため、context.Background() だけでの終了は不十分です。この隔離タイムアウト試験では、部分転送と報告されたサーバーの非ゼロ終了が正しい失敗結果であり、処理の正常完了ではありません。予算が切れると Shutdown がエラーを返し、関数はそのエラーを返して main は非ゼロで終了します。それを処理完了と呼んだり、黙ってサーバーを再起動したりしません。

signal.Notify(stopped, syscall.SIGTERM) はそのシグナルを channel に送るよう Go に要求します。シグナル配送は受信者を待てないため、バッファー容量は 1 です。この場面では停止要求 1 件の保留で十分です。signal.Stop(stopped) は関数終了時にこの channel の登録を解除するもので、HTTP サーバーの停止とは異なります。同様にバッファー付き結果 channel によって、main が終了処理中でもサービス goroutine が 1 つだけの結果を報告できます。

最初の select はサービスエラーか停止シグナルを待ちます。これによりバインド/起動エラーがシグナル待ちの陰に永久に隠れることも防ぎます。正常終了処理の後、errors.Is(err, http.ErrServerClosed) が期待どおりのサービス結果を認識します。別のサービスエラーは成功扱いせず返します。main が runServer を呼ぶので、この関数が終了処理後にだけ戻ることで、プロセスが生存し続けます。

この 1 ファイルを置き換えます。引用符付き here-document 区切りにより、シェルは最後の単独行 GO まで Go ソースを文字どおり書き込みます。> は lifecycle.go だけを置き換えます。

cat > lifecycle.go <<'GO'
package main

import (
 "context"
 "errors"
 "net/http"
 "os"
 "os/signal"
 "syscall"
 "time"
)

func runServer(server *http.Server) error {
 stopped := make(chan os.Signal, 1)
 signal.Notify(stopped, syscall.SIGTERM)
 defer signal.Stop(stopped)
 result := make(chan error, 1)
 go func() { result <- server.ListenAndServe() }()
 select {
 case err := <-result:
  return err
 case <-stopped:
 }
 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
 defer cancel()
 if err := server.Shutdown(ctx); err != nil {
  return err
 }
 err := <-result
 if !errors.Is(err, http.ErrServerClosed) {
  return err
 }
 return nil
}
GO

gofmt -w で整形します。-w は整形済みファイルを書き戻します。新しいターミナルが隠れた PATH 設定に依存しないよう、実行プログラムの明示的パスで現在のパッケージをビルドします。

/usr/local/go/bin/gofmt -w lifecycle.go
/usr/local/go/bin/go build -o desk .

構文エラーにはファイル名と行番号が示されます。コードブロック全体と照らし、必要なら lifecycle.go だけを置き換えてください。VM はリセットしません。チェックは隔離された一時環境でも変更されていない関数をビルドし、実際のダウンロード時間や完了順序を変えます。正常成功だけでなく長い応答の失敗分岐も調べるため、終了まで約 1 分待ってください。実行中の窓口にシグナルを送らず、ソースも変更しません。

ダウンロードを保全してリスナーを閉じる

このステップでは、新規接続を拒否して元のサーバーを待ちながら、受け付け済みの実際のダウンロードを保全します。

Server に切り替え、作業ディレクトリに入り、新しい観測試行を始めます。元のプロセスが終了するまでこのターミナルは使用中です。

cd /home/labex/project/download-desk
python3 desk.py serve drained

実際の準備完了メッセージを待ち、Downloads に切り替えます。次の有限な低速転送を開始する前に、想定されるプローブの失敗と復旧を含め、このステップ全体を読んでください。 その後順番に実行し、転送中に新しい概念を学ばないでください。前単元と同じように有限の低速転送をバックグラウンドで開始します。32 バイト書き込み間の 300ms は通常の読む時間を作るためで、無制限にシグナルを待つ仕組みではありません。

python3 desk.py download drained completed north-pier.txt 300 & download_job=$!
python3 desk.py accepted drained completed

観測ツールは何かを停止する前に本当の受け付け済みリクエストを読みます。元のビルド済みプロセスの識別情報を取得・確認し、このターミナルから SIGTERM を送ります。

server_pid=$(python3 desk.py pid drained)
python3 desk.py identity drained
kill -TERM "$server_pid"

受け付け済みダウンロードの進行中に、新しい接続を試します。準備済みプローブは最大 2 秒の範囲で実際の接続を試し、拒否が現れるまで各ネイティブ結果を保存します。最初の接続失敗を観測する前に、Go が配送済みシグナルを処理する余地を作ります。kill が同期的にリスナーを閉じるとは仮定しません。次のコマンドは 7 を返し、接続拒否メッセージを出す想定です。

python3 desk.py probe drained

これは新規接続の作成拒否であり、HTTP 403 でも受け付け済み転送の破損でもありません。元のダウンロードは継続するはずです。バックグラウンドジョブを待ち、保存された実際のバイトを原本と比べます。

wait "$download_job"
cmp documents/north-pier.txt downloads/completed.txt
cat downloads/completed.txt

curl のネイティブ状態 0、cmp の無出力、完全な最終章が期待されます。Server に切り替えると Original server native exit: 0 が表示されるはずです。サーバーは、クライアントが読み込みやファイルへのフラッシュを終える前に送信を完了する場合があります。終了処理はクライアントアプリケーションの確認応答を待ちません。これは別々の観測であり、2 つのターミナルメッセージの順序は規定しません。

Downloads に戻ります。lifecycle.go、完全なダウンロード、ネイティブ観測記録を保持します。チェックはこの実際の結果を読み、同じソースを隔離されたランダムポートで、既存アイドル接続と異なる順序の受け付け済みリクエストに対してテストします。直接の Close、Shutdown 中の main からの復帰、作業終了までリスナーを開けておくこと、推測した時間の sleep は契約を満たしません。

accepted や停止操作の前にダウンロードが終わったなら、ファイルを残して、まだ動いている試行で新ラベルを選びます。サーバー終了後なら Server で新しい試行名を明示して起動し、新しい低速出力と identity、accepted、probe にその名前を使います。プロセス終了後だけの拒否プローブでは、リスナーが早期に閉じた証拠になりません。新しい名前で活動中のワークフローを再観測してください。先の成功ファイルを上書きせず、観測履歴をリセットしないでください。

前の試行が終了した後に完全に再試行するには、Server で次を実行します。

python3 desk.py serve drained-retry

準備完了メッセージの後、Downloads を使います。先に全ブロックを読んでください。新しい名前によって先のファイルと観測が保持されます。

python3 desk.py download drained-retry completed-retry north-pier.txt 300 & download_job=$!
python3 desk.py accepted drained-retry completed-retry
server_pid=$(python3 desk.py pid drained-retry)
python3 desk.py identity drained-retry
kill -TERM "$server_pid"

次のプローブは 7 を返す想定です。

python3 desk.py probe drained-retry
wait "$download_job"
cmp documents/north-pier.txt downloads/completed-retry.txt

curl 0、完全なバイトの一致、Server で元のネイティブ終了 0 を確認します。先のサーバーが動いていれば、最後まで読んでからその試行で新たな低速出力を開始します。download と accepted に新ラベルを使い、identity と probe は同じ試行名を保持します。先のプローブがすでに存在するなら、それを保存して自分の古い試行を明示的に停止し、終了を待ってから上記の完全な新試行を実行します。

本当の起動エラーは既存のサーバー stderr に現れ、非ゼロで返ります。説明済みのソース/ビルドミスを直すか、自分の前の試行が終わるのを待って 8080 を再使用します。同名のプラットフォームプロセスを止めないでください。終了期限エラーは、有限予算で完了を確認できなかったことを意味します。代替プロセスや過去文書のコピーに置き換えず、正直に読み取ってください。

まとめ

リスナー、受け付け済み HTTP ハンドラー、有限の終了 context、main を調整しました。元のプロセスは新規接続の受け付けを停止しつつ、既存作業を完了しました。正確な最終バイトとネイティブ結果によって、その成果を観測できました。