深夜2時、PagerDutyが鳴り響きました。原因はAPIサーバーの一時的な502エラー多発。ログを確認すると、アプリケーションのエラーではなく、基盤側のノードローテーションに伴う接続断が原因でした。我々のチームでは、FinOpsの観点からAWS EKSのワーカーノードをオンデマンドからスポットインスタンスに移行したばかりでしたが、これが「安かろう悪かろう」の結果になってしまっては意味がありません。スポットインスタンスは最大70%以上のコスト削減が可能ですが、AWS都合による「2分前通知」での強制終了(中断)というリスクが伴います。
中断通知とトラフィック遮断のタイムラグ分析
当時運用していた環境は、Kubernetes 1.27、Go言語で記述されたマイクロサービス群、そしてIngress ControllerとしてAWS Load Balancer Controllerを使用していました。TPSは常時数千を超え、瞬断も許されない決済系サービスです。
スポットインスタンスが回収される際、AWSはインスタンスメタデータサービスを通じて「Interruption Notice(中断通知)」を発行します。これを受け取ってから実際にインスタンスがシャットダウンされるまで、猶予はわずか2分間です。
upstream prematurely closed connection while reading response header from upstream...
このログは、ALB(Application Load Balancer)がリクエストをPodに転送した瞬間に、Podがシャットダウンプロセスに入り、TCPコネクションを切断してしまったことを示しています。つまり、「Podが終了すること」を「Load Balancer」が知るまでにタイムラグがあり、その間に流れたリクエストがエラーになっていたのです。
失敗例:SIGTERMハンドリングだけでは不十分
当初、我々は「アプリケーション側でSIGTERMを正しくハンドリングすれば解決する」と考えていました。Goのコードで `signal.Notify` を使い、SIGTERMを受け取ったらサーバーをGraceful Shutdownするロジックは実装済みでした。
しかし、これは間違いでした。KubernetesがPodを削除する際、EndpointリソースからPodのIPを削除する処理と、PodへSIGTERMを送る処理は非同期(ほぼ同時)に行われます。さらに、iptablesの更新やALBへのTarget Deregistration(登録解除)が完了するまでには数秒から数十秒の遅延が発生します。
その結果、アプリが「もう終了します」とSIGTERMを受けて接続を閉じようとしている最中に、ALBは「まだ元気だ」と判断して新規リクエストを送り続け、クライアントにエラーが返るという現象が起きていました。
解決策:Node Termination HandlerとpreStopの併用
この問題を解決し、高い可用性(Availability)を維持するためには、以下の2段階の防御策が必要です。
- インフラ層: AWS Node Termination Handler (NTH) または Karpenterを使用し、AWSからの中断通知を検知して、即座にノードをCordon(スケジュール除外)し、Drain(Podの退避)を開始する。
- アプリ層: Kubernetes Drainingのプロセスにおいて、SIGTERMを受け取る前に「意図的な待機時間」を設け、ロードバランサーからの切り離しを待つ。
特に重要なのがアプリ層の対策です。以下に、本番環境で実際に効果を発揮した設定コードを公開します。
// deployment.yaml の抜粋
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-service
spec:
template:
spec:
containers:
- name: app
image: my-registry/payment-app:v2
lifecycle:
preStop:
exec:
# 重要: SIGTERMが送られる前に実行される
# ALBのターゲットグループから外れるまでの時間を稼ぐ
command: ["/bin/sh", "-c", "sleep 15"]
terminationGracePeriodSeconds: 45
ports:
- containerPort: 8080
上記のYAMLにおいて、`preStop` フックで `sleep 15` を実行している点が肝です。KubernetesはPodを削除する際、まずこの `preStop` コマンドを実行し、それが完了してからプロセスにSIGTERMを送ります。
この15秒の間に、KubernetesのEndpoint ControllerはPodをEndpointから削除し、その情報がALB Controllerやkube-proxyに伝播します。結果として、アプリが実際にシャットダウンを開始する(SIGTERMを受け取る)頃には、新しいリクエストは一切来なくなっています。
Go言語側でのシグナル処理
もちろん、`preStop` が終わった後のSIGTERM処理も重要です。処理中のリクエスト(In-flight requests)を完了させてから終了する必要があります。
// main.go
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{Addr: ":8080"}
// 別のゴルーチンでサーバー起動
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("HTTP server ListenAndServe: %v", err)
}
}()
// シグナル待ち受け
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
// シャットダウン処理
// preStopのsleepが終わった後にここに来る
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("HTTP server Shutdown: %v", err)
}
log.Println("Server gracefully stopped")
}
詳しくは、Kubernetes公式ドキュメントのPod Lifecycleを参照してください。
導入効果と検証結果
この構成を全マイクロサービスに適用し、カオスエンジニアリングツールで強制的にスポットインスタンスの中断をシミュレートしました。以下がその結果です。
| 指標 | 対策前 | 対策後 |
|---|---|---|
| 5xxエラー率 (中断時) | 1.2% | 0.00% |
| 月間AWSコスト | $12,000 (On-Demand) | $3,800 (Spot) |
| 運用負荷 | 深夜対応あり | 自動回復のみ |
特筆すべきは、エラー率が完全にゼロになったことです。これにより、SLAを遵守しつつ、大幅なコスト削減(FinOpsの成果)を達成できました。ALBのDeregistration Delay設定と `preStop` のsleep時間を調整することで、環境に合わせた最適な値を見つけることが可能です。
注意点とエッジケース
この手法はHTTP/REST APIには極めて有効ですが、いくつかのエッジケースには注意が必要です。
長時間持続する接続(Long-lived connections)は、Graceful Shutdownのタイムアウト(上記の例では30秒〜45秒)を超えると強制切断されます。クライアント側での再接続ロジックが必須です。
また、`terminationGracePeriodSeconds` の値は、必ず `preStopのsleep時間` + `アプリのシャットダウン時間` よりも長く設定してください。デフォルトの30秒では足りないケースが多々あります(例:`sleep 20` + `処理待ち 15` = 35秒必要)。
AWS Node Termination Handler (GitHub)結論
AWS EKSでスポットインスタンスを活用することは、コスト最適化の強力な武器ですが、適切な終了処理を実装しない限り、それは「技術的負債」となり顧客体験を損ないます。Node Termination Handlerによる中断検知と、preStopフックによるトラフィック制御を組み合わせることで、オンデマンドインスタンスと遜色ない信頼性を確保しましょう。
Post a Comment