ECSで稼働するGoアプリケーションに PGOを導入する現実的な戦略を考える

ECSで稼働するGoアプリケーションに PGOを導入する現実的な戦略を考える

本記事の内容は個人的な見解であり、会社を代表するものではありません。

この記事は mediba Advent Calendar 2025 の20日目にエントリーしたものと同じ内容です。

はじめに

Go 1.20でプレビュー、1.21で正式機能となったPGO(Profile-Guided Optimization)ですが、バージョン更新とともに改善が行われてきました。最新ではGo 1.25も登場し、すでに商用環境でも安心して使える標準的な最適化手段になってきているかと思います。

PGOの基本的な概念や、ローカル環境でのベンチマーク検証については下記の記事で日本語で非常に詳しく解説されています。まずは「PGOとは何か」を把握するためにもご一読されることをおすすめします。

私が見ている各PJではまだPGO導入に至っていないのですが、近い将来にでも導入してみたい気持ちがあり「実際にPGOをどう組み込むべきか、運用フローやPGO適用時において浮かぶ疑問に対して検討した内容」を紹介したいと思います。

対象読者

本記事は、主に以下のような方を対象としています。

  • AWS (ECS/Fargate) 環境でGoアプリケーションを運用している方
  • GoのPGO機能に興味はあるが、本番環境への導入方法や運用フローに悩んでいる方
  • シングルバイナリで APIサーバやバッチ処理を運用している方
  • パフォーマンス改善やコスト最適化 (CPUリソース削減) に関心がある方

前提アーキテクチャ

想定する環境は、multi-stage-buildされたシングルバイナリ運用での典型的な構成です。

  • APIサーバ: ECS (Fargate) 上で稼働
  • バッチ処理: S3に配置され、ECS Task Schedulingで起動

APIサーバへの安全な組み込み

APIサーバにPGO用のプロファイル収集機能を持たせる場合、標準ライブラリのnet/http/pprofを使うのが定石ですが、そのままimportするとデフォルトでHandlerが実装されるため、公開ポート (:80/:443) でデバッグ情報が見えてしまうリスクがあります。具体的にどのような情報が見えてしまうのかというと、以下のようなものが挙げられます。

サーバ起動時のコマンドライン引数が表示される (/debug/pprof/cmdline)

  • フラグでAPIキーやパスワードなどを渡している場合は平文で丸見えになる恐れがある

稼働している全てのGoroutineのスタックトレースが表示される (/debug/pprof/goroutine)

  • 内部ロジックの露呈に加え、ロック待ちの状態などが分かるため、特定の処理を攻めてサーバを停止させるような脆弱性の発見に使われる可能性がある

メモリ上のオブジェクトの統計が見える (/debug/pprof/heap)

  • 直接顧客データが見えるわけではないものの、アプリケーションの内部構造 (使用している構造体など) がバレるので、リバースエンジニアリングの手助けとなる懸念がある

情報漏洩以外に負荷の問題もある

  • GET /debug/pprof/profile?second=30のようにCPUを監視し続ける処理を大量連打されると、正規のAPIリクエストを捌くリソースが枯渇する可能性も考えられる

このような理由からも安易に導入するのは避けるべきでしょう。

そこで、「管理用ポートを分離する」方式で商用環境に安全にPGO導入するパターンを検討してみました。

実装例

net/http/pprof用のHandlerをメインのAPIとは別の内部用ポート (:6060等) でListenさせる構成を取ります。

package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	_ "net/http/pprof" // importするだけでDefaultServeMuxにハンドラが登録される
	"os/signal"
	"syscall"
	"time"
)

func main() {
	// シグナルハンドリングの設定 (SIGINT, SIGTERM)
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	// 1. PGOプロファイル取得用の裏口 (Port 6060)
	// DefaultServeMux (pprofが登録済み) を使用
	// ALB等からはルーティングせず、Security Groupで外部からのアクセスを完全に遮断する
	// ECS Execを用いてlocalhost経由でのみアクセスさせる
	go func() {
		log.Println("Pprof admin server listening on :6060")
		if err := http.ListenAndServe(":6060", nil); err != nil {
			log.Printf("pprof server error: %v", err)
		}
	}()

	// 2. メインのAPIサーバ (Port 8080)
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello, World!"))
	})

	srv := &http.Server{
		Addr:    ":8080",
		Handler: mux,
	}

	// サーバー起動 (非同期)
	go func() {
		log.Println("Main API server listening on :8080")
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("listen: %s\n", err)
		}
	}()

	// シグナル待ち
	<-ctx.Done()
	log.Println("shutting down gracefully...")

	// グレースフルシャットダウン (5秒の猶予)
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Fatal("Server forced to shutdown: ", err)
	}

	log.Println("Server exiting")
}

内部用ポート (`:6060`等)をコンテナ定義のポートマッピングに追加しておくのも忘れずに行います。

resource "aws_ecs_task_definition" "main" {
  family                   = "my-sample-task"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = 256
  memory                   = 512
  execution_role_arn       = aws_iam_role.ecs_task_execution_role.arn
  task_role_arn            = aws_iam_role.ecs_task_role.arn

  container_definitions = jsonencode([
    {
      name      = "my-sample"
      image     = "my-sample-image:latest"
      essential = true
      portMappings = [
        {
          # メインのAPI用
          containerPort = 8080
          hostPort      = 8080
          protocol      = "tcp"
        },
        {
          # PGOプロファイル収集用
          containerPort = 6060
          hostPort      = 6060
          protocol      = "tcp"
        }
      ]
      # ... その他の設定
    }
  ])
}

ECS Fargateの場合、ECS Execを使ってコンテナ内部 (あるいはサイドカー) からlocalhost経由で安全にプロファイルを生成・取得することができます。GitHub Actionsなど利用されているCI/CDに組み込むのが良さそうです。

aws ecs execute-command \
    --cluster my-cluster \
    --task <TaskID> \
    --container sample-app \
    --interactive \
    --command "curl -o /tmp/default.pgo http://localhost:6060/debug/pprof/profile?seconds=30"

ECS ExecをONに設定することが必要ですので忘れずに行いましょう。

resource "aws_ecs_service" "main" {
  name            = "my-sample-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.main.arn
  desired_count   = 1
  launch_type     = "FARGATE"

+ # ECS Execを有効化する
+ enable_execute_command = true

  network_configuration {
    subnets         = var.private_subnets
    security_groups = [aws_security_group.ecs_task.id]
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.main.arn
    container_name   = "my-app"
    container_port   = 8080
  }
}

複数ECSサービスのプロファイル統合

「1つのコンテナイメージを使い回し、ALBのリスナールール (パスルーティング) によって複数のECSサービスに振り分ける」ような構成を取っている場合は注意が必要です。起動するサービスによってCPUの使い方が全く異なるためです。

  • serviceA (v1/users) : ユーザ一覧のレスポンス返却処理に対して、大量のJSON Marshal/Unmarshalが行われる
  • serviceB (v1/search) : 商品や記事の検索リクエスト処理に対して、文字列検索やソートアルゴリズムがCPUリソースを消費する
  • serviceC (v1/reports) : データ集計やCSV生成等の処理に対して、DB待機やディスク書き込み等のI/Oバウンドな時間が長く発生する

もし特定のserviceのプロファイルだけでビルドすると、その他のserviceのロジックは最適化されず、全体としての効率は最大化されない懸念があります。そのため、各ECSサービスのプロファイルを収集してマージする方針が良いと考えられます。

# 取得した全ファイルを引数に渡して、1つの default.pgo にまとめる
go tool pprof -proto service_users.prof service_search.prof service_reports.prof > default.pgo

これで、どのサービスとして起動しても最適化が効くバイナリの生成が期待できます。

バッチ処理への組み込み

バッチ処理はHTTPリクエストを受け付けないため、curlでプロファイルを取得できません。そこで「アプリケーション内で計測を開始し、終了時にS3へアップロードする」仕組みを組み込む方法が考えられます。

実装コード例

package main

import (
	"log"
	"os"
	"runtime/pprof"
	// ... aws sdk imports
)

func main() {
	// --- PGO計測開始 ---

	f, err := os.Create("cpu.prof")
	if err != nil {
		log.Printf("failed to create profile: %v", err)
	} else {
		if err := pprof.StartCPUProfile(f); err != nil {
			log.Printf("failed to start profile: %v", err)
			f.Close()
		} else {
			// 終了時に確実に停止 & S3アップロード
			defer func() {
				pprof.StopCPUProfile()
				f.Close()
				uploadProfileToS3("cpu.prof") // S3アップロード関数(実装省略)
			}()
		}
	}
	// ------------------

	log.Println("バッチ処理開始...")
	// ... 実際のビジネスロジック ...
}

バッチの場合、実行ごとにプロファイルが生成されますが、全てを使う必要はないでしょう。「典型的なデータ量を処理した日」のプロファイルを1つ選んでリポジトリに取り込むだけで十分な効果が期待できそうです。

運用サイクルについて

PGO導入でよくある疑問が 「コードを変更したら過去のプロファイルは無駄になるのでは?」 という点です。

結論から言うと、**無駄にはなりません。**

Go公式ドキュメントの AutoFDO のセクションには、GoのPGOが Source Stability (ソースの安定性)Iterative Stability (反復の安定性) を考慮して設計されていることが明記されています。

Source stability is achieved using heuristics to match samples from the profile to the compiling source. As a result, many changes to source code, such as adding new functions, have no impact on matching existing code.

ドキュメントによると、ソースコードに行追加などの変更があっても、コンパイラが 「このプロファイルはこの関数のものだ」 と柔軟に推測して適用してくれます。 もし変更が大きすぎてマッチしなかったとしても、エラーでビルドが止まることはなく、その部分の最適化がスキップされるだけ (Graceful Degradation) なので安全です。

また、「最適化済みのアプリからプロファイルを取って、次のビルドに使う」というサイクルを繰り返しても大丈夫なように設計されています。 例えば、「前回速くなりすぎてプロファイルに現れなくなった結果、今回は最適化されずに逆に遅くなる」といった不安定な挙動にならないよう、コンパイラ側で極端な最適化を避けるような調整が行われているようです。

つまり、公式が想定しているワークフローも以下のサイクルです。

  1. Build: (PGOなしか、前回のプロファイルで) ビルドしてリリース
  2. Collect: 本番環境からプロファイルを収集
  3. Update: 新しいプロファイルで次のバージョンをビルド
  4. Loop: 1に戻る

そのため、毎回厳密にプロファイルを更新しなくとも、「四半期に一度」や「大規模な機能追加の後」といった頻度でプロファイルをリポジトリに取り込む運用でも、Goの設計思想に沿った形で恩恵を受けられそうです。

まとめ

CPU使用率に余裕がある環境であっても、PGO導入には「レイテンシの改善」や「将来のスパイクに対する基礎体力向上」といったメリットがあるようです。 まずはCI/CDパイプラインを作り込む前に、「手動で一度だけ本番プロファイルを回収し、リポジトリに置いてみる」ことから始めていければと思います。

\ シェアする /

この記事を書いた人

プロフィール画像

1996年生まれ。中部大学大学院にてコンピュータビジョン関連の深層学習の研究に注力したのち、2021年4月に株式会社medibaに入社。 KDDIグループ企業各社のDeveloperが集い、エンジニアが楽しめるイベントを提供するコミュニティ「KGDC」のイベント運営も行っています。

記事に関する質問は、フッター掲載の各種SNSにてお問い合わせください。