0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【改善編】スケーラブルなOCR基盤 (Go×Python×EasyOCR×GKE) の検証

0
Last updated at Posted at 2026-06-21

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. 「データフロー」処理フェーズ(①〜⑪)

image.png

2. 「デプロイ・通信」フェーズ(①〜⑤)

image.png

3. 「認証・セキュリティ」フェーズ(①〜⑤)

image.png

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

◆各工程での処理時間

T1-全リクエスト表示.JPG

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負荷率・期待値比較結果

T1-全リクエスト表示(CPU負荷率).JPG

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)
k6実行結果

T1-k6.JPG

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

◆各工程での処理時間

T1-処理時間.JPG

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負荷率・期待値比較結果

T1-CPU負荷・実行結果.JPG

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;

◆リクエスト毎の処理時間

T1-全リクエスト表示.JPG

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 %
k6実行結果

T2-k6.JPG


②実行結果(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

◆各工程での処理時間

T2-処理時間.JPG

◆CPU負荷率・期待値比較結果

T2-CPU負荷・実行結果.JPG

◆Pub/Sub の滞納件数の推移

リージョン別の未確認メッセージ.png

◆各PODの増減推移

T2-PythonPod推移.JPG

◆各PODごとの処理件数

T2-POD別の稼働状況.JPG

◆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 %
k6実行結果

T3-k6.JPG

②実行結果(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 %
取得画像とSQL

◆各工程での処理時間

T3-処理時間.JPG

◆CPU負荷率・期待値比較結果

T3-CPU負荷・実行結果.JPG

◆Pub/Sub の滞納件数の推移

リージョン別の未確認メッセージ.png

◆各PODごとの処理件数

T3-POD別の稼働状況.JPG

◆各PODの増減推移

T3-PythonPod推移.JPG

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 %
k6実行結果

T4-k6.JPG

②実行結果(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 %
取得画像とSQL

◆各工程での処理時間

T4-処理時間.JPG

◆CPU負荷率・期待値比較結果

T4-CPU負荷・実行結果.JPG

◆Pub/Sub の滞納件数の推移

リージョン別の未確認メッセージ.png

◆各PODごとの処理件数

T4-POD別の稼働状況.JPG

◆各PODの増減推移

T4-PythonPod推移-2.JPG

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)による大量リクエスト流入時の挙動を検証・考察。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?