Azure Cost Management APIをGo SDKで高速化:ClientType指定(ApplicationID/ヘッダー)でレート制限を賢く回避する実装ガイド

Azure Cost Management API を大量に実行すると、既定のレート制限にすぐ当たります。ところが「クライアント識別子」を明示すると緩和されることが知られており、Go SDK でも実装可能です。本記事では ClientType を付与して呼び出し上限を引き上げる実践的な方法を、Telemetry.ApplicationID の活用とカスタムポリシーでのヘッダー直挿入という 2 通りで詳解します。動作サンプル、並列実行の設計、429対策、検証・ログの取り方まで一気通貫でまとめました。

目次

Azure Cost Management API とレート制限の考え方

Azure Cost Management(以下 ACM)は、サブスクリプション・リソースグループ・管理グループ・課金アカウントなど複数スコープに対してコスト・使用量の集計を提供します。高頻度アクセス時は HTTP 429(Too Many Requests)が返され、レスポンスヘッダーの Retry-After に従って再試行するのが基本です。
一方で、クライアントが「誰」で「何の目的で」叩いているのかを Azure 側が識別できる場合、実運用に支障が出ないレベルまで許容量が調整されることがあります。Go SDK では以下の 2 つの実装パターンが現実的です。

  • パターンA:Telemetry.ApplicationID を設定する(ユーザーエージェントの先頭に付与)。ACM を含む多くのサービスでクライアント識別に利用され、実質的に ClientType 相当の扱いになります。
  • パターンB:カスタムポリシーで HTTP ヘッダー ClientType を明示的に追加する(より厳密)。

どちらか一方でも効果がありますが、確実性を高めたい場合は A+B の併用を推奨します。以下、コードと手順を順に示します。

最短ルート:ApplicationID(ClientType 相当)を指定して呼び出す

ステップ1:資格情報(DefaultAzureCredential)を取得

cred, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
    log.Fatalf("credential 取得失敗: %v", err)
}

ステップ2:ClientType 相当の ApplicationID とリトライ戦略を設定

clientOpts := &arm.ClientOptions{
    ClientOptions: azcore.ClientOptions{
        Telemetry: azcore.TelemetryOptions{
            // クライアント識別子(= ClientType 相当)。一意で説明的な値を付ける
            ApplicationID: "contoso-cost-batch",
        },
        Retry: policy.RetryOptions{
            MaxRetries:    8,                   // 429 が出やすいワークロードは多め
            RetryDelay:    2 * time.Second,     // 初期待機
            MaxRetryDelay: 30 * time.Second,    // 待機上限
        },
    },
}

ステップ3:Cost Management クライアント生成とクエリ実行

// import: "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/costmanagement/armcostmanagement"
// SDK の世代差異に注意:新しめのトラック2では subscriptionID を不要とする形もあります。
client, err := armcostmanagement.NewQueryClient(cred, clientOpts)
// もしコンストラクタが subscriptionID を要求する版なら:
// client, err := armcostmanagement.NewQueryClient(subscriptionID, cred, clientOpts)
if err != nil {
    log.Fatalf("QueryClient 生成失敗: %v", err)
}

ctx := context.Background()

// スコープ例:サブスクリプション
scope := "/subscriptions/"

// 期間・集計の定義
params := armcostmanagement.QueryDefinition{
Type:      to.Ptr(armcostmanagement.QueryTypeUsage),
Timeframe: to.Ptr(armcostmanagement.TimeframeTypeCustom),
TimePeriod: &armcostmanagement.QueryTimePeriod{
From: to.Ptr(time.Date(2025, 10, 1, 0, 0, 0, 0, time.UTC)),
To:   to.Ptr(time.Date(2025, 10, 31, 23, 59, 59, 0, time.UTC)),
},
Dataset: &armcostmanagement.QueryDataset{
Granularity: to.Ptr(armcostmanagement.GranularityTypeDaily),
Aggregation: map[string]*armcostmanagement.QueryAggregation{
"totalCost": {
Name:     to.Ptr("Cost"),
Function: to.Ptr(armcostmanagement.FunctionTypeSum),
},
},
Grouping: []*armcostmanagement.QueryGrouping{
{
Type: to.Ptr(armcostmanagement.QueryColumnTypeDimension),
Name: to.Ptr("ResourceId"),
},
},
},
}

// 実行(Usage エンドポイント)
resp, err := client.Usage(ctx, scope, params, nil)
if err != nil {
log.Fatalf("Usage クエリ失敗: %v", err)
}
fmt.Printf("行数: %d\n", len(resp.QueryResult.Rows)) 

この構成では ApplicationID の値がユーザーエージェントに反映され、Azure 側でカスタムクライアントとして識別されます。多くのケースで既定より高い上限(体感で数倍)を享受できます。

より厳密に:ClientType ヘッダーを直挿入するカスタムポリシー

環境やポリシー運用上、「ヘッダー名としての ClientType を常に送りたい」場合は、PerRetryPolicies にカスタムポリシーを挿入します。再試行のたびに確実にヘッダーが付与されるため堅牢です。

type clientTypePolicy struct {
    header string
    value  string
}

func newClientTypePolicy(header, value string) policy.Policy {
    return &clientTypePolicy{header: header, value: value}
}

func (p *clientTypePolicy) Do(req *policy.Request) (*http.Response, error) {
    req.Raw().Header.Set(p.header, p.value)
    return req.Next()
}

このポリシーをクライアントオプションに組み込みます。

clientOpts := &arm.ClientOptions{
    ClientOptions: azcore.ClientOptions{
        Telemetry: azcore.TelemetryOptions{
            ApplicationID: "contoso-cost-batch", // 併用推奨
        },
        Retry: policy.RetryOptions{
            MaxRetries:    8,
            RetryDelay:    2 * time.Second,
            MaxRetryDelay: 30 * time.Second,
        },
        PerRetryPolicies: []policy.Policy{
            newClientTypePolicy("ClientType", "contoso-cost-batch"),
        },
    },
}

これで、ACM へ向かうすべての HTTP リクエストに ClientType: contoso-cost-batch が付きます。ApplicationID との併用により、可観測性(ログ・指標の相関)と緩和効果の両立がしやすくなります。

動作確認:送信ヘッダーをログで検証する

SDK のログをオンにして、リクエストヘッダーに ClientType と ApplicationID 由来のユーザーエージェントが乗っているかを確認します。

azlog.SetListener(func(e azlog.Event, s string) {
    // 送信直前のヘッダーを出力
    if e == azlog.EventRequest {
        fmt.Println("--- REQUEST ---")
        fmt.Println(s)
    }
})
azlog.SetEvents(azlog.EventRequest, azlog.EventResponse)

併せて、429 が返されたときに Retry-After を拾えているか、再試行で成功しているかも観察しましょう。

スコープ指定とよく使う書式

対象scope 文字列備考
サブスクリプション/subscriptions/<subId>最も一般的
リソース グループ/subscriptions/<subId>/resourceGroups/<rg>特定のプロジェクト単位
管理グループ/providers/Microsoft.Management/managementGroups/<mgId>大規模組織で便利
課金アカウント/providers/Microsoft.Billing/billingAccounts/<accountId>請求集計向け

大量実行の設計:並列度・バッチング・再試行

ACM は集計系 API のため、超高スループットでの連射は 429 を招きやすくなります。以下の指針で安定化させます。

項目推奨理由
並列度サブスクリプションあたり 3〜8 ゴルーチンから開始429 を観測しつつ徐々に引き上げるのが安全
バッチング期間やグルーピングは必要最小限に結果セット削減で処理時間・課金・失敗率が下がる
リトライpolicy.RetryOptions で指数バックオフ429/5xx 時の自動再試行を SDK に委任
待機ジッタ(±20%)を入れるスパイクや同期化を緩和
識別子ApplicationID と ClientType の両方を設定可観測性と緩和効果の両面でメリット

ワーカー方式の簡易サンプル

type job struct {
    scope string
}

const workerCount = 6

jobs := make(chan job)
var wg sync.WaitGroup

for i := 0; i < workerCount; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := range jobs {
// 429 スロットリングの平準化のために少量のジッタを入れる
time.Sleep(time.Duration(rand.Intn(250)) * time.Millisecond)


        _, err := client.Usage(ctx, j.scope, params, nil)
        if err != nil {
            // ログだけ出して継続(ポリシーで自動再試行済み)
            log.Printf("[worker %d] %s: %v", id, j.scope, err)
        }
    }
}(i)


}

// 複数スコープを投入
for _, sc := range scopes {
jobs <- job{scope: sc}
}
close(jobs)
wg.Wait() 

ヘッダー値の命名・運用ベストプラクティス

項目推奨内容
ApplicationID / ClientType の値組織名-サービス名-用途(例:contoso-cost-batch)。短く一意で意味が分かる文字列。
変更管理値を変えると観測上は別クライアント扱い。必要性がない限り固定。
付与タイミングPerRetryPolicies で毎回付与。PerCallPolicies でも基本は可だが、厳密には再試行ごとに入れる方が安全。
PII 回避メールや個人名は入れない。チームやシステム名で表現。
検証SDK ログやプロキシで実際の送信ヘッダーを確認する。

トラブルシュート:典型的な失敗と対処

症状原因対処
HTTP 429(Too Many Requests)レート制限到達、バースト、同時実行過多並列度を下げる/バッチ縮小/RetryOptions を強化。Retry-After を尊重。
HTTP 401 / 403認可不足、スコープ不整合クレデンシャルを点検(MI/環境変数)。API 権限・RBAC を再確認。スコープ文字列のスペルを確認。
HTTP 400 / 422クエリ定義の誤り存在しないディメンション名/期間の妥当性を見直す。最小構成で通し、徐々に拡張。
タイムアウト結果セット過大、ネットワーク遅延期間やグルーピングを縮小。context.WithTimeout の値を適切化。

完全サンプル:A+B 併用の堅牢実装

package main

import (
"context"
"fmt"
"log"
"math/rand"
"net/http"
"sync"
"time"


"github.com/Azure/azure-sdk-for-go/sdk/azcore"
azlog "github.com/Azure/azure-sdk-for-go/sdk/azcore/log"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/costmanagement/armcostmanagement"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"


)

type clientTypePolicy struct{ header, value string }
func (p *clientTypePolicy) Do(req *policy.Request) (*http.Response, error) {
req.Raw().Header.Set(p.header, p.value)
return req.Next()
}
func newClientTypePolicy(h, v string) policy.Policy { return &clientTypePolicy{h, v} }

func main() {
cred, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
log.Fatalf("credential 取得失敗: %v", err)
}


clientType := "contoso-cost-batch"

clientOpts := &arm.ClientOptions{
    ClientOptions: azcore.ClientOptions{
        Telemetry: azcore.TelemetryOptions{ApplicationID: clientType},
        Retry: policy.RetryOptions{
            MaxRetries:    8,
            RetryDelay:    2 * time.Second,
            MaxRetryDelay: 30 * time.Second,
        },
        PerRetryPolicies: []policy.Policy{newClientTypePolicy("ClientType", clientType)},
    },
}

// SDK 世代によりコンストラクタが異なる場合があります
client, err := armcostmanagement.NewQueryClient(cred, clientOpts)
if err != nil {
    log.Fatalf("QueryClient 生成失敗: %v", err)
}

azlog.SetEvents(azlog.EventRequest, azlog.EventResponse)
azlog.SetListener(func(e azlog.Event, s string) {
    if e == azlog.EventRequest {
        fmt.Println("--- REQUEST ---")
        fmt.Println(s) // ヘッダーに ClientType / UA があるか目視確認
    }
})

ctx := context.Background()
scope := "/subscriptions/<your-subscription-id>"

params := armcostmanagement.QueryDefinition{
    Type:      to.Ptr(armcostmanagement.QueryTypeUsage),
    Timeframe: to.Ptr(armcostmanagement.TimeframeTypeCustom),
    TimePeriod: &armcostmanagement.QueryTimePeriod{
        From: to.Ptr(time.Now().UTC().AddDate(0, -1, 0).Truncate(24*time.Hour)),
        To:   to.Ptr(time.Now().UTC().Truncate(24*time.Hour)),
    },
    Dataset: &armcostmanagement.QueryDataset{
        Granularity: to.Ptr(armcostmanagement.GranularityTypeDaily),
        Aggregation: map[string]*armcostmanagement.QueryAggregation{
            "totalCost": {Name: to.Ptr("Cost"), Function: to.Ptr(armcostmanagement.FunctionTypeSum)},
        },
    },
}

// 小規模にまず 1 回叩く
r, err := client.Usage(ctx, scope, params, nil)
if err != nil {
    log.Fatalf("初回 Usage 失敗: %v", err)
}
fmt.Printf("初回行数: %d\n", len(r.QueryResult.Rows))

// 大量スコープがある場合の並列処理
scopes := []string{scope /* 他スコープを列挙 */ }

jobs := make(chan string)
var wg sync.WaitGroup
workerCount := 6

for i := 0; i < workerCount; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        for sc := range jobs {
            time.Sleep(time.Duration(rand.Intn(250)) * time.Millisecond)
            if _, err := client.Usage(ctx, sc, params, nil); err != nil {
                log.Printf("[worker %d] %s: %v", id, sc, err)
            }
        }
    }(i)
}
for _, sc := range scopes {
    jobs <- sc
}
close(jobs)
wg.Wait()


} 

パフォーマンスチューニングの実戦チェックリスト

  • 一意で説明的な ApplicationID を設定しているか。
  • ClientType ヘッダーをカスタムポリシーで付与しているか。
  • RetryOptions の上限・間隔・指数バックオフが現場のエラー率と合っているか。
  • 並列度は 429 を見ながら徐々に上げているか。CPU ネックに達していないか。
  • クエリの期間・粒度・グルーピングは最小か。不要なディメンションを削っているか。
  • スコープ設計(管理グループ/サブスクリプション/RG)を見直し、集計の重複を避けているか。
  • ヘッダーとレスポンスをログで検証し、期待通りに識別・再試行されているか。

FAQ:よくある疑問

Q. ApplicationID を設定するだけで十分?
A. 多くのケースで十分な緩和が得られます。確実性・説明責任を高めるなら ClientType のヘッダー直挿入も併用してください。

Q. ヘッダー名は大小文字の影響を受ける?
A. HTTP ヘッダーは仕様上ケースインセンシティブですが、コード上は ClientType と正確に記述するのが無難です。

Q. 値はどれくらいの長さまで良い?
A. 数十文字以内に収め、英数字・ハイフンで簡潔に。PII は避け、チームや用途が分かる命名にします。

Q. それでも 429 が多い。
A. 並列度を 1/2~1/3 に落として観測。Retry-After を尊重し、バッチサイズや期間を縮小。時間帯を分散し、複数リージョン・スコープで負荷平準化します。

まとめ

Go から Azure Cost Management API を大量に叩く場合、クライアント識別の明示が鍵です。最短は Telemetry.ApplicationID の設定、より堅牢にするならカスタムポリシーで ClientType ヘッダーを追加。これに適切な再試行・並列度・クエリ設計を組み合わせることで、429 を最小化しつつ高スループットで安定運用できます。この記事のコードをベースに、まずは小さく検証してから本番負荷に合わせてチューニングしていきましょう。

補足:サンプル最小構成(再掲)

  1. 資格情報を取得 cred, err := azidentity.NewDefaultAzureCredential(nil) if err != nil { log.Fatalf("credential 取得失敗: %v", err) }
  2. ClientType(相当)を含むオプション clientOpts := &azcore.ClientOptions{ Telemetry: azcore.TelemetryOptions{ ApplicationID: "my-custom-client-type", }, Retry: policy.RetryOptions{ MaxRetries: 8, RetryDelay: 2*time.Second, MaxRetryDelay: 30*time.Second, }, PerRetryPolicies: []policy.Policy{newClientTypePolicy("ClientType", "my-custom-client-type")}, }
  3. クエリ実行 client, err := armcostmanagement.NewQueryClient(cred, &arm.ClientOptions{ClientOptions:*clientOpts}) if err != nil { log.Fatalf("client 生成失敗: %v", err) } resp, err := client.Usage(ctx, scope, params, nil) if err != nil { log.Fatalf("Usage 失敗: %v", err) }
  4. 効果
    • ApplicationID がユーザーエージェントに入り、カスタムクライアントとして識別される。
    • ClientType ヘッダーを併用すれば識別がより明確になり、既定より高いレートリミットが適用されやすい。
    • 指数バックオフにより 429/5xx を自動吸収できる。

付録:コード断片(ユーティリティ)

Go モジュールの import 例

import (
  "github.com/Azure/azure-sdk-for-go/sdk/azcore"
  azlog "github.com/Azure/azure-sdk-for-go/sdk/azcore/log"
  "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
  "github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
  "github.com/Azure/azure-sdk-for-go/sdk/azidentity"
  "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/costmanagement/armcostmanagement"
  "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
)

最小限の QueryDefinition

params := armcostmanagement.QueryDefinition{
  Type:      to.Ptr(armcostmanagement.QueryTypeUsage),
  Timeframe: to.Ptr(armcostmanagement.TimeframeTypeCustom),
  TimePeriod: &armcostmanagement.QueryTimePeriod{
    From: to.Ptr(time.Date(2025, 10, 1, 0, 0, 0, 0, time.UTC)),
    To:   to.Ptr(time.Date(2025, 10, 31, 23, 59, 59, 0, time.UTC)),
  },
  Dataset: &armcostmanagement.QueryDataset{
    Granularity: to.Ptr(armcostmanagement.GranularityTypeDaily),
    Aggregation: map[string]*armcostmanagement.QueryAggregation{
      "totalCost": {Name: to.Ptr("Cost"), Function: to.Ptr(armcostmanagement.FunctionTypeSum)},
    },
  },
}

実務ポイントの総括

  • 「値を決める」「ログで送達を確認する」「429/5xx を自動で受け止める」の 3 点が成功の鍵。
  • まずは 1 スコープ・短期間で通し、次に並列度を上げていく段階的アプローチが安全。
  • 分析用途・バッチ用途それぞれで識別子を分けると、運用と可観測性がさらに向上。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次