1.はじめに
前回の検証では、同期通信(gRPC)とサイドカー構成を採用した結果、スパイク負荷時に特定Podへ負荷が集中し、処理速度落ちがる課題が明確になりました。
このボトルネックを打破するため、今回は Cloud Pub/Subをバッファに挟んだ「プル型非同期データパイプライン」 へとアーキテクチャを全面刷新しました。
本記事では、負荷試験ツール k6 を用いて大量のリクエストを投入し、新基盤の耐障害性とスケーラビリティを徹底的に検証します。
k6の実行メトリクスと、Cloud LoggingからBigQueryにリアルタイム集約した構造化ログ(JSON)を元に、データエンジニアの視点から以下の4つの切り口でシステム性能を徹底解析します。
💡 本記事は、3部作の「検証編」です。
・【改善版】詳細説明編:「Go/Python/Terraform/Helm」の詳細解説。
・【改善版】総括編: Pub/Subを用Push型データパスの要点・効果を解説。
・【改善版】検証編: 負荷試験(k6)による大量リクエスト流入時の挙動を検証・考察。
| ◆構成 |
|---|
| 1.はじめに |
| 2.技術スタックとシステム構成 |
| 3.検証環境と計測手法 |
| 4.基本性能の測定 |
| 5.負荷試験 |
| 6.考察 |
| 7.まとめ |
2.技術スタックとシステム構成
2-1. 技術スタック
本システムでは、以下の技術スタックを採用しています。前回の負荷集中の課題を解決するため、「疎結合」「非同期バッファリング」「イベント駆動型オートスケール」 を軸とした構成へ刷新しました。
| カテゴリ | 技術・ツール | 用途 |
|---|---|---|
| Frontend API | Go (Standard) | HTTPリクエスト受付、GCSアップロード、Pub/Subメッセージ送信、構造化ログ出力 |
| Inference Engine | Python (EasyOCR) | Pub/Subメッセージ受信(Pull)、GCS画像ダウンロード、文字認識処理 |
| Messaging (Buffer) | Cloud Pub/Sub | Go-Python間の通信を仲介するメッセージキュー。スパイク負荷のバッファリング |
| Autoscaling | KEDA | Pub/Subの未処理メッセージ数(Backlog)を検知し、Podを高速にイベント駆動スケール |
| Infrastructure | GKE (Standard) | 完全管理型K8s環境。スポットインスタンスを活用した超低コスト運用 |
| IaC | Terraform | VPC、Cloud NAT、GKE、Pub/Sub、GCS、Log Sinkのコード管理(IaC) |
| Observability | Cloud Logging / BigQuery | 構造化ログの集約、およびSQLによる推論精度・性能分析 |
| Testing | k6 | 大量リクエストを用いた負荷試験。キューの滞留とKEDAのスケーリング挙動の検証 |
2-2. 主要ライブラリ
新アーキテクチャのパフォーマンスとコスト最適化を最大化するために採用した主要ライブラリ・ツールです。
| 対象 | ライブラリ | 採用理由 |
|---|---|---|
| Go | google.com | GCSへの画像アップロードおよびPub/Subへの超高速なメッセージ発行(Publish)のため |
| Go | gopsutil | 1リクエストの処理期間中における純粋なCPU使用率を正確に計測し、ログへ含めるため |
| Python | EasyOCR | PyTorchベースで精度が高く、日本語・英語に標準対応。今回は起動時にプレロードしてコールドスタートを排除。 |
| Python | psutil | OCR推論時のCPU負荷をノンブロッキングで計測し、BigQuery分析用の構造化ログに出力するため。 |
| K8s | KEDA (GCP Pub/Sub Scaler) | Pub/Subのキュー滞留数に応じてPod数を「0台⇄最大5台」までミリ秒単位で高感度スケールさせるため |
| GCP | Workload Identity | サービスアカウントの秘密鍵(JSON)の発行を完全に撤廃し、Pod単位で安全にGCP権限を借用するため |
2-3. ディレクトリ構成
インフラ管理(Terraform)コマンドで、デプロイできる以下の構成となっています。
このデータセットは、GitHubレポジトリ に登録しています。
.
├── terraform/ # GKE, VPC, Artifact Registry, Log Sinkの定義
├── chart/ # Kubernetesデプロイ用リソース (Helm Chart形式)
│ └── templates/ # Deployment, Service, HPA, RBACのマニフェスト
├── go-api/ # フロントエンドAPI (Go / Gin)
├── python-api/ # 推論エンジン (Python / EasyOCR / gRPC Server)
├── pb/ # gRPC定義ファイル (.proto) と自動生成コード
└── k6/ # 負荷試験スクリプト
└── test_images/ # 検証用画像および正解データ(mapping.json)
2-3. システム構成
1. 「データフロー」処理フェーズ(①〜⑪)
2. 「デプロイ・通信」フェーズ(①〜⑤)
3. 「認証・セキュリティ」フェーズ(①〜⑤)
3.検証環境と計測手法
本システムの実効性を証明するため、負荷試験ツール k6 を活用し、「データパイプラインの耐久性」「イベント駆動スケール」「推論精度」の多面的なアプローチから検証を行いました。
3-1. テストフェーズと手順
今回は、gRPCのような同期通信(リクエスト-レスポンスが直結するモデル)とは異なり、Cloud Pub/Subがリクエストを一度すべてバッファリング(背圧制御)します。そのため、k6の負荷フェーズは「処理遅延(レイテンシ)」ではなく、 「大量のキューが溜まった際、KEDAとPythonワーカーがどれだけ迅速かつ安定してデータを捌き切れるか」 を観測するシナリオへと最適化しました。
・Phase 1:ベースライン測定(VU: 1)
パイプラインに負荷がないクリーンな状態で、1リクエストあたりの「システム本来の基礎レイテンシ」とデータの完全な開通を測定。
・Phase 2:中規模負荷・水平スケーリングの初動(VUs: 5)
Pub/Subへのメッセージ流入速度がPythonワーカー1台の処理能力を超えた際、KEDAがそれを検知してPod数を自動で増やす「垂直スケーリングの初動」を観測。
・Phase 3:大規模負荷・スケーリング限界検証(VUs: 10)
大量の画像を連続投入し、Pub/Subの未処理メッセージ(Backlog)が最大化した状態を作ります。PythonのPod数が最大リミット(5台)までフルスケールし、負荷が分散されることで、後段の処理詰まり(ボトルネック)を起こさずに安定並行処理ができるかを確認。
・Phase 4:限界値の検証(VUs: 20)
最大リミット(5台)に達した状態でさらに負荷を倍増させ、システム全体(GCS/Pub/Sub/ログ基盤)のボトルネックや崩壊点を特定。
・負荷シナリオ:
100枚のテスト画像をプールし、各フェーズで安定した高頻度のリクエストを注入。フロント(Go)が受け取ったリクエストは即座にPub/Subへ退避されるため、クライアント(k6)側から見ると一瞬でHTTP 200が返ります。この「Goがキューに貯める速度」と「Pythonがキューから消化する速度」の差分(バックログの山)に、KEDAがどう追従するかを評価します。
k6 で実行する`JavaScript`(./k6/script.js)
import http from 'k6/http';
import exec from 'k6/execution';
import { check, sleep } from 'k6';
const mapping = JSON.parse(open('./test_images/mapping.json'));
const imageFiles = [];
// 2. 100枚の画像をメモリに読み込む
for (let i = 1; i <= 100; i++) {
const filename = `${String(i).padStart(3, '0')}.png`;
imageFiles.push({
name: filename,
expected: mapping[filename], // JSONから正解を取得
bin: open(`./test_images/${filename}`, 'b')
});
}
export default function () {
const url = 'http://34.84.190.255:8080/upload';
// 順番に1枚選ぶ (0, 1, 2... 99, 0, 1...)
const index = __ITER % imageFiles.length;
const targetImage = imageFiles[index];
const incrementId = `req-${exec.vu.idInTest}-${exec.vu.iterationInInstance + 1}`;
// Bodyデータ(Multipart Form)にパラメータを格納
const data = {
image: http.file(targetImage.bin, targetImage.name, 'image/png'),
request_id: incrementId, // 🌟 UUIDからインクリメントデータへ変更
expected: targetImage.expected,
};
const params = {};
const res = http.post(url, data, params);
check(res, {
'Status is 200': (r) => r.status === 200,
'Response contains success text': (r) => r.body.includes('成功'),
});
sleep(1); // 待機時間
}
3-2. 精度検証(データ品質保証)
大量データ処理環境下において、 「リソース逼迫時でもデータが欠落せず、正しくOCR解析が行えているか」 をエンドツーエンドで自動評価する仕組みを構築しました。
・正解データの準備: 100枚の画像と、対応する正解テキストを事前にmapping.jsonとして用意。
画像を作成したpythonスクリプト(./k6/gen_image.py)
import os
import json
from PIL import Image, ImageDraw, ImageFont
# フォルダ作成
os.makedirs("./test_images", exist_ok=True)
# 100個の重複のない厳選英単語(3〜6文字)
words = [
"apple", "bird", "cat", "dog", "fish", "frog", "jump", "blue",
"green", "book", "desk", "milk", "moon", "star", "tree", "rose",
"lion", "bear", "duck", "king", "queen", "lamp", "fire", "ice",
"snow", "rain", "wind", "ship", "boat", "car", "bus", "train",
"gold", "iron", "wood", "road", "path", "city", "town", "home",
"door", "wall", "room", "roof", "desk", "pen", "cup", "bowl",
"rice", "corn", "bean", "soup", "meat", "pork", "beef", "salt",
"leaf", "root", "stem", "seed", "lily", "pink", "red", "dark",
"sky", "star", "sun", "cloud", "gray", "gold", "bear", "wolf",
"deer", "fox", "owl", "hawk", "swan", "lake", "river", "sea",
"sand", "rock", "clay", "dirt", "hill", "farm", "park", "yard",
"shoe", "hat", "coat", "bag", "ball", "silver", "bell", "coin",
"hand", "foot", "eye", "ear", "nose", "face", "hair", "skin"
]
# 単語数が足りない場合の保険(ユニークな100個を確実に取得)
words = list(set(words))[:100]
mapping = {}
# Windows標準のArialフォント(特大サイズ40)
try:
font = ImageFont.truetype("arial.ttf", 40)
except IOError:
font = ImageFont.load_default()
# 100枚生成
for i, target_text in enumerate(words, start=1):
filename = f"{i:03d}.png"
mapping[filename] = target_text
# 黒背景の画像を作成 (横250px, 縦100px)
img = Image.new('RGB', (250, 100), color='black')
draw = ImageDraw.Draw(img)
# 白文字でハッキリと描画
draw.text((30, 25), target_text, fill='white', font=font)
img.save(f"./test_images/{filename}")
# 正解リストをJSONで保存
with open('./test_images/mapping.json', 'w') as f:
json.dump(mapping, f)
print(f"100枚の重複のない画像生成が完了しました。 (単語数: {len(words)})")
・期待値(Expected)のバトンパス:
| 工程 | 内容 |
|---|---|
| k6(負荷注入) | リクエスト送信時、フォームデータに正解文字列をセット |
| Go-api (フロント) | これを受取り、Pub/Subメッセージの属性(Attributes)に注入 |
| Python-worker(推論) | Pub/Subからメッセージをプルした際、この属性値を復元 |
・判定の自動化(データ品質モニタリング):
Python側で「EasyOCRの推論結果」に「Goから引き継いだ正解文字列」が含まれているかを部分一致で判定(IsMatch: 1 or 0)。
その結果を構造化JSONログとして標準出力へ吐き出し、Cloud Logging経由でBigQueryへリアルタイム集約。
インフラの負荷状況とデータ精度を1つのテーブルでクロス分析可能にしました。
3-3. 計測指標 (Metrics) と分析基盤
収集したすべてのログとメトリクスは、GKEからCloud Loggingのルーターシンクを通り、サーバーレスでBigQueryへ集約されます。今回は以下の多角的なアプローチで、非同期パイプラインの健全性を評価します。
① クライアント指標(k6 Metrics)
非同期プル型に移行したことで、Go APIフロントがPub/Subへメッセージを投げ終えた時点でクライアントへレスポンスが戻ります。そのため、k6では「フロント側の受付の安定性」を測定します。
| 項目 | 内容 |
|---|---|
| Status is 200 | 通信が正常終了したか(HTTP 200) |
| http_req_duration | クライアントから見た応答時間(APIフロントの受取レイテンシ) |
② バックエンド指標(BigQuery分析)
Cloud Logging経由で集約された、GoとPython双方の構造化ログをBigQuery上で突合(JOIN)し、非同期システムにおける真のボトルネックを特定します。
| 分析項目 | 目的 |
|---|---|
| Queue Latency | メッセージがキューに滞留していた時間(Pub/Sub内待機時間) |
| Inference Latency | 純粋なOCR推論・保存の所要時間 |
| Accuracy (IsMatch) | OCR正解率のモニタリング(リソース逼迫時のデータ破損検証) |
| Resource Efficiency | Python PodのCPU使用効率 |
| Traffic Distribution | 負荷分散の偏り検証(各Podの処理件数の均等さ) |
BigQueryに出力するデータからSQLを使用して、リクエストIDごとの結果を算出できます。
取得画像とSQL
◆各工程での処理時間
WITH aggregated_logs AS (
SELECT
jsonPayload.RequestID,
MAX(CASE WHEN jsonPayload.Step = 'go' THEN timestamp END) AS `リクエスト受付時刻`,
-- ミリ秒数値を数値型(INT64)としてガッチャンコ
MAX(CASE WHEN jsonPayload.Step = 'go' THEN CAST(jsonPayload.duration_start AS INT64) END) AS go_start_ms,
MAX(CASE WHEN jsonPayload.Step = 'go' THEN CAST(jsonPayload.duration_end AS INT64) END) AS go_end_ms,
MAX(CASE WHEN jsonPayload.Step = 'python' THEN CAST(jsonPayload.duration_start AS INT64) END) AS py_start_ms,
MAX(CASE WHEN jsonPayload.Step = 'python' THEN CAST(jsonPayload.duration_end AS INT64) END) AS py_end_ms
FROM
`<your-project-id>.aes_verification_dataset.stdout_20260614`
WHERE
jsonPayload.RequestID IS NOT NULL
GROUP BY
jsonPayload.RequestID
)
SELECT
RequestID,
`リクエスト受付時刻`,
-- 1. Goの内部処理時間
(go_end_ms - go_start_ms) AS Go_duration_ms,
-- 2. 🌟修正:Pythonの起点からGoの終点をシンプルに引き算
(py_start_ms - go_end_ms) AS Pubsub_duration_ms,
-- 3. Pythonの内部処理時間(主にsleepの5000msが入る)
(py_end_ms - py_start_ms) AS Python_duration_ms,
-- 4. 総パイプライン時間
(go_end_ms - go_start_ms) + (py_start_ms - go_end_ms) + (py_end_ms - py_start_ms) AS Total_pipeline_duration_ms
FROM
aggregated_logs
WHERE
go_start_ms IS NOT NULL AND py_start_ms IS NOT NULL
ORDER BY
`リクエスト受付時刻` DESC;
◆CPU負荷率・期待値比較結果
WITH aggregated_logs AS (
SELECT
jsonPayload.RequestID,
MAX(CASE WHEN jsonPayload.Step = 'go' THEN timestamp END) AS `リクエスト受付時刻`,
-- GoのCPU負荷率(数値を抽出、後段で切り捨て)
MAX(CASE WHEN jsonPayload.Step = 'go' THEN CAST(jsonPayload.cpuusage AS FLOAT64) END) AS go_cpuusage,
-- PythonのCPU負荷率と一致判定
MAX(CASE WHEN jsonPayload.Step = 'python' THEN CAST(jsonPayload.cpuusage AS FLOAT64) END) AS py_cpuusage,
MAX(CASE WHEN jsonPayload.Step = 'python' THEN CAST(jsonPayload.ismatch AS STRING) END) AS py_ismatch
FROM
`<your-project-id>.aes_verification_dataset.stdout_20260614`
WHERE
jsonPayload.RequestID IS NOT NULL
GROUP BY
jsonPayload.RequestID
)
SELECT
RequestID,
`リクエスト受付時刻`,
-- GoのCPU負荷(小数点以下切り捨て)
CAST(TRUNC(go_cpuusage) AS INT64) AS go_cpu,
-- PythonのCPU負荷
py_cpuusage AS py_cpu,
-- 期待値検証結果(is_matchのみ)
py_ismatch AS is_match
FROM
aggregated_logs
WHERE
go_cpuusage IS NOT NULL AND py_cpuusage IS NOT NULL
ORDER BY
`リクエスト受付時刻` DESC;
4.基本性能の測定
負荷が最小(VU: 1)の状態における、システム全体の基準値(ベースライン)を測定しました。
4-1.実行内容
単発ではなく15秒間連続でリクエストを注入し、処理の安定性を確認します。
・実行コマンド: k6 run --vus 1 --duration 15s script.js
| 項目 | 内容 |
|---|---|
| POD数 | 1個 (Python_worker) |
| 負荷 (VUs) | 1vus(同時接続数1) |
| 実行時間 | 15秒間 |
| テストデータ | 100枚のテスト画像をループで送信 |
4-2.実行結果(クライアント側:k6)
バックエンドの重いOCR処理を非同期化したことで、フロントAPIの応答性能は劇的に向上しました。
| 指標 | 測定値 | 補足説明 |
|---|---|---|
| スループット | 0.795req/s | 15秒間で計11リクエストを処理 |
| 応答時間(avg) | 251.51ms | Goフロントの受付➔GCS保存➔Pub/Sub発行の合計時間 |
| 応答時間(p95) | 457.07ms | スパイクのない極めて安定した推移を確認 |
| 成功率(Checks) | 100% | 全リクエスト正常完了(Status 200) |
4-3.実行結果(BigQuery分析)
Cloud Loggingから集約した構造化ログをBigQueryで統合解析し、各フェーズの処理時間とCPU使用率をミリ秒単位で可視化しました(検証データ:13件)。
① 各フェーズの処理時間(タイムライン解析)
| 工程 | 平均(ms) | 最大(ms) | 最小(ms) |
|---|---|---|---|
| Ingress-Phase | 53 | 135 | 38 |
| Transit-Phase | 449 | 2,028 | 0 |
| Compute-Phase | 775 | 1,113 | 707 |
| End-to-End | 1,278 | 2,841 | 781 |
| 工程 | 内容 |
|---|---|
| Ingress-Phase | Goがリクエストを受けてからGCS保存、Pub/Sub発行を終えてクライアントへ応答を返すまでの純粋な受付時間 |
| Transit-Phase | Pub/Subに格納されてから、Pythonワーカーにメッセージが届くまでのタイムラグ。システムの「背圧バッファ量」の指標 |
| Compute-Phase | Pythonが画像ダウンロードを開始し、EasyOCRによる推論、結果のGCS保存、Ack送信を終えるまでの実処理時間 |
| End-to-End | リクエスト受付時刻から、最終的にOCR成果物がGCSへ書き込まれるまでの、非同期システム全体の総所要時間 |
💡 Transit-Phaseが「0ms」を記録する理由
PythonワーカーとCloud Pub/Sub間で gRPC(StreamingPull)による常時双方向ストリーミング接続 を確立しているためです。ハンドシェイクのオーバーヘッドが完全に排除されており、ミリ秒以下の極めて高い伝播速度が観測されました思われます。
②リソース使用率の傾向
| CPU使用率 | 平均(%) | 最大(%) | 最小(%) |
|---|---|---|---|
| go | 12 | 23 | 8 |
| python | 104 | 107 | 94 |
💡 CPU使用率が100%を超える理由(GKE仕様)
マニフェストで requests: 500m / limits: 1000m と設定しているためです。一般的に使用率は保証枠(Requests)に対する割合で算出されるため、上限枠(Limits:1.0コア=200%相当)までリソースをフル活用した結果、100%を超える高いパフォーマンスが記録されました。
取得画像とSQL
◆各工程での処理時間
WITH aggregated_logs AS (
SELECT
jsonPayload.RequestID,
MAX(CASE WHEN jsonPayload.Step = 'go' THEN timestamp END) AS `リクエスト受付時刻`,
-- ミリ秒数値を数値型(INT64)としてガッチャンコ
MAX(CASE WHEN jsonPayload.Step = 'go' THEN CAST(jsonPayload.duration_start AS INT64) END) AS go_start_ms,
MAX(CASE WHEN jsonPayload.Step = 'go' THEN CAST(jsonPayload.duration_end AS INT64) END) AS go_end_ms,
MAX(CASE WHEN jsonPayload.Step = 'python' THEN CAST(jsonPayload.duration_start AS INT64) END) AS py_start_ms,
MAX(CASE WHEN jsonPayload.Step = 'python' THEN CAST(jsonPayload.duration_end AS INT64) END) AS py_end_ms
FROM
`<your-project-id>.aes_verification_dataset.stdout_20260614`
WHERE
jsonPayload.RequestID IS NOT NULL
GROUP BY
jsonPayload.RequestID
),
calculated_metrics AS (
SELECT
RequestID,
-- 各ステップの正確な所要時間を算出
(go_end_ms - go_start_ms) AS go_duration,
(py_start_ms - go_end_ms) AS pubsub_duration,
(py_end_ms - py_start_ms) AS python_duration,
-- パイプライン全体の合計時間
((go_end_ms - go_start_ms) + (py_start_ms - go_end_ms) + (py_end_ms - py_start_ms)) AS total_duration
FROM
aggregated_logs
WHERE
go_start_ms IS NOT NULL AND py_start_ms IS NOT NULL
)
SELECT
-- 1. Goの統計
CAST(TRUNC(AVG(go_duration)) AS INT64) AS go_avg,
MAX(go_duration) AS go_max,
MIN(go_duration) AS go_min,
-- 2. Pub/Subの統計
CAST(TRUNC(AVG(pubsub_duration)) AS INT64) AS pub_avg,
MAX(pubsub_duration) AS pub_max,
MIN(pubsub_duration) AS pub_min,
-- 3. Pythonの統計
CAST(TRUNC(AVG(python_duration)) AS INT64) AS py_avg,
MAX(python_duration) AS py_max,
MIN(python_duration) AS py_min,
-- 4. パイプライン合計の統計
CAST(TRUNC(AVG(total_duration)) AS INT64) AS total_avg,
MAX(total_duration) AS total_max,
MIN(total_duration) AS total_min
FROM
calculated_metrics;
◆CPU負荷率・期待値比較結果
WITH aggregated_logs AS (
SELECT
jsonPayload.RequestID,
-- GoのCPU負荷率
MAX(CASE WHEN jsonPayload.Step = 'go' THEN CAST(jsonPayload.cpuusage AS FLOAT64) END) AS go_cpuusage,
-- PythonのCPU負荷率
MAX(CASE WHEN jsonPayload.Step = 'python' THEN CAST(jsonPayload.cpuusage AS FLOAT64) END) AS py_cpuusage,
-- Pythonの一致判定(文字列やブール型を考慮して判定)
MAX(CASE WHEN jsonPayload.Step = 'python' THEN CAST(jsonPayload.ismatch AS STRING) END) AS py_ismatch
FROM
`<your-project-id>.aes_verification_dataset.stdout_20260614`
WHERE
jsonPayload.RequestID IS NOT NULL
GROUP BY
jsonPayload.RequestID
),
calculated_metrics AS (
SELECT
RequestID,
-- 小数点以下を切り捨てて整数型に
CAST(TRUNC(go_cpuusage) AS INT64) AS go_cpu,
CAST(TRUNC(py_cpuusage) AS INT64) AS py_cpu,
-- 'true' または '1' の場合に成功(1)とし、後段でSUMを取れるようにフラグ化
CASE WHEN py_ismatch IN ('true', '1') THEN 1 ELSE 0 END AS is_match_flag
FROM
aggregated_logs
WHERE
go_cpuusage IS NOT NULL AND py_cpuusage IS NOT NULL
)
SELECT
-- 1. GoのCPU統計
CAST(TRUNC(AVG(go_cpu)) AS INT64) AS go_cpu_avg,
MAX(go_cpu) AS go_cpu_max,
MIN(go_cpu) AS go_cpu_min,
-- 2. PythonのCPU統計
CAST(TRUNC(AVG(py_cpu)) AS INT64) AS py_cpu_avg,
MAX(py_cpu) AS py_cpu_max,
MIN(py_cpu) AS py_cpu_min,
-- 3. 検証成功(is_match = 1)の合計数
SUM(is_match_flag) AS total_success_count,
-- 参考:全体のリクエスト件数
COUNT(RequestID) AS total_request_count
FROM
calculated_metrics;
◆リクエスト毎の処理時間
4-4.結果分析
ベースライン測定(VU: 1 / 計12リクエスト)の結果、以下の知穫が得られました。なお、この負荷量は初期の1 Podで十分に処理可能なため、GKEのオートスケールは発動せず1台を維持しています。
1.コールドスタートとパイプラインの余裕度:
全体平均に対して最大時間が長い(End-to-Endで2,841ms)のは、1リクエスト目におけるEasyOCRの起動(コールドスタート)や初期接続 に起因するものです。2リクエスト目以降は処理が完全に最適化され、Pub/Sub内での待ち時間が発生しないクリーンなパイプライン状態を確認できました。
2.ネットワーク遅延の切り分け(可観測性の担保):
k6が検知した応答時間(251.5ms)と、Go内部の実処理(53ms)の差分から、ネットワークの純粋な往復遅延(RTT)が約200ms であると推測できます。BigQueryでのログ統合により、「ネットワーク」「フロント受付」「キュー滞留」「推論演算」をミリ秒単位で完全に分離して可視化できる強力な基盤(オブザーバビリティ)が証明されました。
5.負荷試験
同時接続数を3段階(5VUs ➔ 10VUs ➔ 15VUs)に引き上げ、システムの耐障害性とオートスケール(KEDA)の追従性を検証しました。
5-1.負荷試験1(5VUs)
中規模負荷を3分間連続投入し、非同期バッファリングとスケーリングの初動を観測します。
・実行コマンド: k6 run --vus 5 --duration 3m script.js
①実行結果(クライアント側:k6)
リクエストが即座にPub/Subへ退避されるため、負荷が上昇してもクライアント側の応答時間はベースライン(251ms)と同等レベルを維持しています。
| 指標 | 測定値 |
|---|---|
| スループット | 3.87 req/s |
| 応答時間(avg) | 284.19 ms |
| 応答時間(p95) | 454,54 ms |
| 成功率(Checks) | 100 % |
②実行結果(BigQuery分析)
データパイプライン内部の挙動を可視化。フロントが高速に受け付けた分、Pub/Sub内にメッセージが一時的に滞留(バッファリング)している様子が数値から読み取れます。
| 工程/リソース | 平均値 | 最大値 | 最小値 |
|---|---|---|---|
| Ingress-Phase | 64 ms | 475 ms | 26 ms |
| Transit-Phase | 137,524 ms | 208,058 ms | 2,126 ms |
| Compute-Phase | 1,294 ms | 2,813 ms | 593 ms |
| End-to-End | 138,883 ms | 209,543 ms | 3,434 ms |
| Go CPU使用率 | 13 % | 100 % | 0 % |
| Python CPU使用率 | 81 % | 113 % | 51 % |
取得画像とSQL
◆各工程での処理時間
◆CPU負荷率・期待値比較結果
◆Pub/Sub の滞納件数の推移
◆各PODの増減推移
◆各PODごとの処理件数
◆SQL(各PODごとの処理件数)
WITH base_metrics AS (
SELECT
jsonPayload.PodName AS pod_name,
-- 🌟 待ち時間関連のカラム定義を削除
jsonPayload.CPUUsage AS cpu_usage,
-- 🌟 UTCのタイムスタンプを日本時間(JST)の読みやすい文字列に変換
FORMAT_TIMESTAMP('%Y-%m-%d %H:%M:%S.%E3S', timestamp, 'Asia/Tokyo') AS jst_time,
timestamp
FROM
`<your-project-id>.aes_verification_dataset.stdout_20260614`
WHERE
jsonPayload.Step = 'python'
AND jsonPayload.PodName IS NOT NULL
)
SELECT
pod_name AS python_pod_name,
-- 1. 処理件数
COUNT(1) AS process_count,
-- 2. 一番最初の処理完了タイムスタンプ(日本時間)
MIN(jst_time) AS first_completion_time_jst,
-- 3. 一番最後の処理完了タイムスタンプ(日本時間)
MAX(jst_time) AS last_completion_time_jst,
-- 4. 平均CPU負荷
ROUND(AVG(cpu_usage), 2) AS avg_cpu_usage_pct
FROM
base_metrics
GROUP BY
pod_name
ORDER BY
-- 🌟 削除したカラムを除外して処理件数の多い順のみに調整
process_count DESC;
5-2.負荷試験2(10VUs)
さらに負荷を倍増(10VUs)させ、大量のキューが滞留した状態におけるシステムの安定性を検証しました。
・実行コマンド: k6 run --vus 10 --duration 3m script.js
①実行結果(クライアント側:k6)
リクエスト数が秒間7.3件まで跳ね上がっても、フロントAPI(Go)は破綻せず、クライアントへは極めて安定した応答(p95で約0.6秒) を返し続けています。
| 指標 | 測定値 |
|---|---|
| スループット | 7.32 req/s |
| 応答時間(avg) | 359.91 ms |
| 応答時間(p95) | 602.09 ms |
| 成功率(Checks) | 100 % |
②実行結果(BigQuery分析)
Pythonワーカーの処理能力(最大5台)を超えるペースで画像が流入したため、Pub/Sub内にさらに大きな「キューの山(最大440秒超の滞留)」が発生しています。
| 工程/リソース | 平均値 | 最大値 | 最小値 |
|---|---|---|---|
| Ingress-Phase | 97 ms | 821 ms | 24 ms |
| Transit-Phase | 310,731 ms | 443,797 ms | 249 ms |
| Compute-Phase | 1,181 ms | 2,856 ms | 532 ms |
| End-to-End | 312,011 ms | 445,042 ms | 1,037 ms |
| Go CPU使用率 | go | 16 % | 100 % |
| Python CPU使用率 | 80 % | 115 % | 50 % |
5-3.負荷試験3(20VUs)
最終フェーズとして、同時接続数を20VUsまで引き上げ、システム全体の崩壊点とバッファリングの限界を検証しました。
・実行コマンド: k6 run --vus 20 --duration 3m script.js
①実行結果(クライアント側:k6)
秒間13.89件(3分間で約2,500リクエスト)の猛烈なトラフィックを注入したにもかかわらず、HTTP成功率は100%を維持し、応答性能もp95で1秒未満(874.97 ms) という驚異的なタフネスを見せました。
| 指標 | 測定値 |
|---|---|
| スループット | 13.89 req/s |
| 応答時間(avg) | 435.68 ms |
| 応答時間(p95) | 874.97 ms |
| 成功率(Checks) | 100 % |
②実行結果(BigQuery分析)
Pythonワーカー(上限5台)の最大処理能力を完全にオーバーしたため、Pub/Sub内の滞留時間(Transit-Phase)は最大で約15分(946,662 ms)に達しました。
| 工程/リソース | 平均値 | 最大値 | 最小値 |
|---|---|---|---|
| Ingress-Phase | 159 ms | 708 ms | 25 ms |
| Transit-Phase | 674,776 ms | 946,662 ms | 247,047 ms |
| Compute-Phase | 1,299 ms | 2,944 ms | 501 ms |
| End-to-End | 676,235 ms | 948,264 ms | 248,989 ms |
| Go CPU使用率 | 18 % | 100 % | 0 % |
| Python CPU使用率 | 71 % | 122 % | 52 % |
6.考察
6-1.負荷強度による変化(マトリクス解析)
各フェーズのメトリクスを横並びで比較すると、トラフィック増加に伴うシステム全体の挙動が明確に浮き彫りになります。
| 指標 | 1VUs | 5VUs | 10VUs | 20VUs |
|---|---|---|---|---|
| スループット() | 3.87 req/s | 7.32 req/s | 7.32 req/s | 13.89 req/s |
| k6応答(avg) | 284.19 ms | 359.91 ms | 359.91 ms | 435.68 ms |
| 応答時間(p95:ms) | 454,54 ms | 602.09 ms | 602.09 ms | 874.97 ms |
| OCR成功率 | 100 % | 100 % | 100 % | 100 % |
| Ingress-Phase(平均) | 53 ms | 64 ms | 97 ms | 159 ms |
| Transit-Phase(平均) | 449 ms | 137,524 ms | 310,731 ms | 674,776 ms |
| Compute-Phase(平均) | 775 ms | 1,294 ms | 1,181 ms | 1,299 ms |
| End-to-End(ms) | 1,278 ms | 138,883 ms | 312,011 ms | 676,235 ms |
| パイプラインの工程 | 内容 |
|---|---|
| Ingress-Phase | Goがリクエストを受けてからGCS保存、Pub/Sub発行を終えてクライアントへ応答を返すまでの純粋な受付時間 |
| Transit-Phase | Pub/Subに格納されてから、Pythonワーカーにメッセージが届くまでのタイムラグ。システムの「背圧バッファ量」の指標 |
| Compute-Phase | Pythonが画像ダウンロードを開始し、EasyOCRによる推論、結果のGCS保存、Ack送信を終えるまでの実処理時間 |
| End-to-End | リクエスト受付時刻から、最終的にOCR成果物がGCSへ書き込まれるまでの、非同期システム全体の総所要時間 |
6-2. アーキテクチャの分業化と並列化の評価
① 期待通りの完全な職能分立(疎結合化)
負荷の増減に対して、各コンポーネントが完璧にその責務を果たしていることが数値から証明されました。
・Go API(受付):
トラフィックが約18倍になっても、Ingress-Phase は53msから159msの微増に留まり、高速回転でリクエストを捌き続けました。
・Pub/Sub(緩衝):
ワーカーが捌ききれないリクエストを Transit-Phase(最大約11分〜15分)として安全に一時滞留させ、システム決壊を防ぎました。
・Python Worker(推論):
周辺のインフラが激変するなか、Compute-Phase(約1.2秒)は常に一定のペースを維持し、コールドスタート以外は淡々とOCR処理を完遂しました。
② KEDAによるイベント駆動型スケーリング
Pub/Subの未確認メッセージ数の急増をKEDAが高感度で検知し、並行処理(Pod増設)へ移行する挙動がすべての負荷帯で観測できました。初期の1台がフル稼働して処理を繋いでいる間に、KEDAが裏で残りの4台を高速プロビジョニングし、立ち上がり後は5台のPodへ均等に負荷が分散(ロードバランス)されていました。
本ポートフォリオ最大の目的であった「Pull型による負荷分散」の完全な達成が実証されました。
6-3. 極限負荷(20VUs)におけるインフラの挙動と改善策
10VUsまでは2台ずつ段階的にPodが立ち上がっていたのに対し、20VUs時のみ 「稼働中だった最初の1台が一時停止(EvictedまたはOOM等)した後、5台が一斉に再起動する」 という特異な挙動が観測されました。
これは、Pub/Subの急激なバッファ詰まりを検知したKEDAが一気に4台の追加Node/Podをプロビジョニングしようとした際、クラスタのリソース上限(Nodeのキャパシティ)に衝突し、一時的なリソース競合(スケジュール遅延やメモリ逼迫)によって既存Podが巻き込まれた可能性が推測されます。
今後、20VUs以上の極限スパイクに本格対応する場合は、「Nodeのインスタンスマシンスペックのスケールアップ」や「GKEのノード自動スケーリング(Cluster Autoscaler)の先行トリガー設定」 などのインフラ拡充が有効なアプローチとなります。
6-4. Go APIの最小CPU負荷「0%」の技術的背景
これに関しては、現在検討中で、検討結果がまとまり次第に記載します。
7.まとめ
本プロジェクトでは、前回の同期通信(gRPC)およびサイドカー構成における「スパイク負荷時の特定Podへの負荷集中と処理落ち」という重大なボトルネックを契機として、Cloud Pub/Subをバッファに据えた「プル型非同期データパイプライン」への大刷新を断行しました。
k6を用いた最大20VUs の3分間限界負荷試験において、システム全体の連鎖崩壊を防ぐ「背圧制御能力」と、KEDAによる「イベント駆動型オートスケーリング」の有効性を確認し、過酷な極限スパイク負荷環境下でもデータロスなく処理を完遂できる、真にスケーラブルなOCR基盤の構築 を定量データとして立証することに成功しました。
💡 本記事は、3部作の「検証編」です。
・【改善版】詳細説明編:「Go/Python/Terraform/Helm」の詳細解説。
・【改善版】総括編: Pub/Subを用Push型データパスの要点・効果を解説。
・【改善版】検証編: 負荷試験(k6)による大量リクエスト流入時の挙動を検証・考察。


























