🔌

ALBが404しか返さない:ECS Service Connectとポートを共有してはいけない(+ CDKの暗黙デフォルト)

に公開

はじめに

ALBのヘルスチェックが全部404で落ちるという現象に遭遇しました。原因を追うとECS Service ConnectのEnvoyとポートを奪い合っていて、さらにCDKの暗黙デフォルトに二重で刺さる、という二段構えのハマり方だったので記録を残しておきます。

起きたこと

Go製のConnect/gRPCサーバーをECS Fargate上で動かしています。既存サービスにService Connectを導入した直後、以下が起きました。

  • ALBターゲットグループのヘルスチェックが404で失敗
  • 外部クライアントからのAPIリクエストも404
  • ただし、ECSタスク内からService Connect経由で呼ぶと正常

外から見えない内部通信だけが通っていて、ALB経由の通信だけが壊れている、という状態です。

当時の構成

  • ECSサービス: Fargate
  • アプリのConnect/gRPCサーバー: 9090でlisten
  • Service Connect: 有効、portMapping名 app-container を9090に紐付け
  • ALB: 同じ9090ポートをターゲットに

ポートは1つしかないのでService ConnectとALBの両方が9090を指していました。今思えばこれが全ての始まりです。

原因: Service ConnectのEnvoyが9090を先取りしていた

ECS Service ConnectはタスクにEnvoyプロキシをサイドカーとして注入します。サービスを「提供側」として登録すると、portMappingに指定したポートでEnvoyがlistenし、その背後でアプリコンテナがサーブする構成になります。

つまり9090で実際にlistenしていたのはアプリではなくEnvoyでした。ALBは9090に向けてリクエストを送っていたので、ALBのトラフィックは一旦Envoyを経由します。Envoyは内部向けのルーティング設定しか持っていないため、外部のHTTP/2リクエストに対してはマッチするルートがなく404を返していた、というのが起きていたことです。

内部クライアントから見ると期待通りに動く一方、ALBから見ると404しか返ってこない、という非対称が起きていました。

解決策: 外部用と内部用でポートを分ける

このサービスは外部クライアントに加えて他の内部Goサービスからもgrpcで呼ばれており、Service Connectの提供側登録は外せないという前提がありました。そのため「Service Connectを外す」という選択肢は取れず、ポート側で分離する方針にしました。

9090を両者で共有しているのが諸悪の根源なので、アプリ側で2つのHTTPサーバーを立ち上げ、それぞれ別ポートにします。

  • 9090: ALB専用(外部トラフィック)
  • 19090: Service Connect専用(内部通信)

アプリコンテナ内で同じmuxを2つのサーバーが共有する形です。

Goサーバーの実装

cmd/server.goでデュアルHTTPサーバーを起動します。ポイントは、両サーバーが同じmuxを共有することと、graceful shutdownを並列で走らせることです。

func runServer(albPort, serviceConnectPort int) error {
    // 実コードではDatadogの httptrace.NewServeMux を使っているが、
    // 本質は標準の http.NewServeMux と同じ
    mux := http.NewServeMux()
    AddServiceHandlers(mux, container.DependencyInjection())

    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/plain")
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte("healthy"))
    })

    newServer := func(port int) *http.Server {
        return &http.Server{
            Addr: fmt.Sprintf(":%d", port),
            Handler: h2c.NewHandler(
                withCORS(mux),
                &http2.Server{},
            ),
            // gRPCストリーミングを許容するため長め
            ReadTimeout:    5 * time.Minute,
            WriteTimeout:   5 * time.Minute,
            MaxHeaderBytes: 8 * 1024,
        }
    }

    albSrv := newServer(albPort)
    scSrv := newServer(serviceConnectPort)

    // シグナル or どちらかのサーバーがエラーで終了したら全体を落とす
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()

    eg, egCtx := errgroup.WithContext(ctx)

    eg.Go(func() error {
        log.Printf("ALB server listening on :%d", albPort)
        if err := albSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            return fmt.Errorf("ALB server: %w", err)
        }
        return nil
    })
    eg.Go(func() error {
        log.Printf("Service Connect server listening on :%d", serviceConnectPort)
        if err := scSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            return fmt.Errorf("service connect server: %w", err)
        }
        return nil
    })

    // ctx がキャンセルされる(= シグナル受信 or どちらかがエラー)まで待つ
    eg.Go(func() error {
        <-egCtx.Done()

        shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer shutdownCancel()

        var wg sync.WaitGroup
        wg.Add(2)
        go func() {
            defer wg.Done()
            if err := albSrv.Shutdown(shutdownCtx); err != nil {
                log.Printf("ALB server shutdown error: %v", err)
            }
        }()
        go func() {
            defer wg.Done()
            if err := scSrv.Shutdown(shutdownCtx); err != nil {
                log.Printf("Service Connect server shutdown error: %v", err)
            }
        }()
        wg.Wait()
        return nil
    })

    return eg.Wait()
}

ポイントは次の2点です。

  • 片方のサーバーがエラーで落ちたら全体が落ちる: errgroup.WithContextで共有contextを作り、どちらかがreturn errするとegCtxがキャンセルされて、もう一方のshutdownも走ります。最初の実装ではgo func()内でlog.Fatalfを叩いていて、「片方が死んでももう片方は動き続ける」挙動になっていたのを修正しました
  • shutdownは並列: アプリ内部のshutdownはsync.WaitGroupで並列化。最初の実装ではshutdownを直列で呼んでいて、最悪60秒待ちになっていました

なお、ここで並列化しているのはあくまでアプリコンテナ内部のHTTPサーバーのshutdownです。ALBからのトラフィックをdrainして綺麗に止めるには、ALBの deregistration delay や preStop hook との協調が別途必要で、それは本記事の範囲外です。

二次災害: CDKのtargets: [service]ショートカット

ここからが本題かもしれません。ポート分離したあとCDKを書き換えたのですが、ALBが相変わらず19090(Service Connect側)に向かって404を返し続ける、という現象が起きました。アプリ側は9090でちゃんとlistenしているのに、ALBは19090を叩いている。

原因はCDKのApplicationTargetGrouptargets: [service]を渡していた部分でした。

// 問題のあったコード
const targetGroup = new ApplicationTargetGroup(this, 'TargetGroup', {
  vpc,
  port: 9090,
  protocol: ApplicationProtocol.HTTP,
  targets: [service], // ← これ
});

targetsにECSサービスを直接渡すと、CDKは内部でservice.loadBalancerTarget()をコンテナ名・ポート指定なしで呼び出します。このときtask definitionに複数のportMappingがあると、最初のportMappingが暗黙に選ばれます

これはCDK公式ドキュメントにも明記されていて、loadBalancerTargetの説明に「the first essential container or the first mapped port on the container」以外を使いたい場合にこの関数を使え、とあります(aws-cdk-lib.aws_ecs.BaseService.loadBalancerTarget)。同じ罠にハマった人がいて、issueとしても aws/aws-cdk#4463 に上がっています。

当時のtask definitionは偶然19090(Service Connect用)が先に書かれていて、ALBは律儀にそちらを掴みに行っていた、というオチでした。aws ecs describe-servicesで取れるload balancer設定を見ると19090が入っていて、「なぜ書き換えたのに変わらないのか」としばらく悩みました。

正しい書き方

service.loadBalancerTargetでコンテナ名とポートを明示するのが正解です。

const targetGroup = new ApplicationTargetGroup(this, 'TargetGroup', {
  vpc,
  port: 9090,
  protocol: ApplicationProtocol.HTTP,
  // targets: [service] は渡さない
});

targetGroup.addTarget(
  service.loadBalancerTarget({
    containerName: 'app-container', // task definition 上のコンテナ名
    containerPort: 9090,             // ← 明示
  }),
);

この書き方だと、portMappingsの順序を入れ替えてもALBが参照するコンテナポートは変わりません。

セキュリティグループ

Service Connect用ポートはVPC内からのみアクセスできれば十分なので、Ingressを分けます。

sg.addIngressRule(
  ec2.Peer.anyIpv4(),
  ec2.Port.tcp(9090),
  'ALB traffic',
);
sg.addIngressRule(
  ec2.Peer.ipv4(vpc.vpcCidrBlock),
  ec2.Port.tcp(19090),
  'Service Connect (VPC internal)',
);

簡略化のためVPC CIDRから一律で許可していますが、より厳密には呼び出し元サービスのSGに限定するほうが安全です。同じVPC内の別ワークロードからも19090を叩けてしまう構成なので、本番では要検討です。

運用で気をつけたこと

  • ヘルスチェックは両方叩く: curl localhost:9090/healthcurl localhost:19090/healthを別々に監視。片側だけ落ちるケースに気づけます
  • 段階的ロールアウト: dev → staging → prodの順で適用。ALBとService Connectの相互作用は環境差が出やすく、devで通ったからといってprodで通るとは限らないという印象です
  • 切り分けコマンド: aws ecs describe-tasks --tasks $TASK_ARN --query 'tasks[0].containers[0].networkBindings' でECSがどのポートをどう認識しているか確認できます

追加コスト

デュアルサーバー構成のオーバーヘッドを雑に測りました。

  • メモリ使用量: 約5MB増(HTTP/2サーバー1つ分)
  • CPU使用量: アイドル時は有意差なし

レイテンシについては構成変更前後で有意な差は確認できませんでした。目的は正しく動かすことであって、パフォーマンスは現状維持、という位置付けです。

学び

ECS Service ConnectとALBの併用は問題なくできますが、**「提供側portMappingのポートは実質Envoy用になる」**と考えておくと設計がブレません。外部トラフィック用のポートはそれとは別に用意する、という原則です。ALBとService Connectが同じポートを指さない、と言い換えてもいいです。

もう1つはCDKの話で、targets: [service]のような便利ショートカットには暗黙のデフォルト選択が含まれていることがあります。単一portMappingの初期は動いて、ポートを増やした瞬間に壊れる、という時限爆弾になりがちです。複数portMappingを扱うときはloadBalancerTargetでコンテナ名とポートを明示しておくと安全でした。

まとめ

  • Service Connectの提供側ポートは実質Envoyのlistenポートになるので、ALBと同じポートを指すと外部トラフィックがEnvoyに吸われて404になる
  • 外部用と内部用でポートを分ける(例: ALB 9090 / Service Connect 19090)
  • CDKのtargets: [service]は最初のportMappingを暗黙に選ぶので、複数portMappingがあるときはloadBalancerTargetでコンテナ名とポートを明示する
  • デュアルサーバーの起動はerrgroupで相互監視し、shutdownは並列化する

同じ罠にハマった方の時間節約になれば幸いです。

Discussion