外部サービスとAPI連携する時のTips

こんにちは。バックエンドエンジニアの上野です。

近年、どんどん便利に進化し続けているWebサービスは
AWS,GCPなどを含む外部プラットフォームとの連携が必須になっています。
僕が開発を担当しているATOMというサービスでも数多くの外部サービスと連動しています。

特にATOMでは、さまざまな広告媒体からAPI経由でデータを収集を行う性質上、
自社管理のインフラに加え、外部との連携をしっかりと監視していく必要があります。

今回は自分が外部サービスとの連携を行う際に気をつけているポイントをご紹介したいと思います。

API連携時におこる、よくある問題

まず、外部サービスとAPI連携を行う時に基本的に意識すべきなのは
「(連携先は)想定外の振る舞いをしてくる」ということです。
具体的には、以下のような事態は想定しておくべきです。

  • エラーレスポンスが返ってくる (あるいはそもそもレスポンスが返ってこない)
    • 接続先がレスポンスボディ等で正しく「失敗」を表現して返してくる
    • HTTPステータスコードがエラー
    • 応答がいつまでも返ってこない (タイムアウト)
  • パフォーマンスが低下する
    • 通常時はすぐにレスポンスが返ってきていたが、応答に時間がかかるようになる
  • リクエストレートリミットに達する
    • 大量にリクエストを行うとエラーが発生する
    • 接続試験の際は問題なかったが、本番運用時に発覚することも
  • 仕様が変化する
    • 接続先がアップデートされ、意図しない形にレスポンスが変化する

このような事態になった時、連携している処理がどのような振る舞いになるのかを事前に考えておくことが大切です。
アプリケーション全体への影響を最小限に抑え、できるだけ早く対応できるようにしてきましょう。

実際に行っている工夫

ATOMチームでは外部連携を行う際、上記のような問題に備えて
実装を工夫しているため、それをいくつかご紹介します。

バックエンドにはGo言語が用いられているため、Go言語での例になります。

HTTPクライアント

API連携に必須となるHTTPクライアントライブラリは 基本的に外部サービスが標準的に用意しているSDKを使用しています。
公式で用意されている場合、致命的な問題がない限りそのドキュメントに従うのが良いです。

SDKが用意されていない場合、自分たちでHTTPクライアントを用意することになりますが
接続先のAPI管理のため、「OpenAPI定義」を用意してクライアントを自動生成して使用しています。

エンドポイントの情報(リクエスト/レスポンスの構造など)はハードコードされがちですが、 これをOpenAPIで定義しておくことで仕様が明示的になり、コードの可読性・メンテナンス性が向上します。

クライアントの自動生成には「ogen」をオススメしています。

QPS制御

特に並列で大量のリクエストを送る場合、
リクエストのレートリミットに引っかからないように制御する必要があります。

レートリミット制限には sync/semaphorebundler パッケージが有用です。

sync/semaphore を使って同時実行数に制限を入れて並列処理を行うサンプル

以下の例では5つの並列タスクで実行していますが、
QPS制限に備えて同時に3リクエストまでしか実行されないように制御しています。

func main() {
    // セマフォの最大許可数を3に設定
    maxTasks := int64(3)
    sem := semaphore.NewWeighted(maxTasks)
    var wg sync.WaitGroup

    // 5つのタスクを同時に実行
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()

            // セマフォを獲得
            err := sem.Acquire(context.Background(), 1)
            if err != nil {
                fmt.Printf("failed to acquire semaphore: %v\n", err)
                return
            }

            // ここでリクエストを実行

            // セマフォを解放
            sem.Release(1)
        }(i)
    }
    wg.Wait()
}

bundler を使ってリクエストを集約するサンプル

以下の例ではQPS制限に備え、同期的にリクエストを行うのではなく
最大1秒間の遅延を行い、その間に積まれたリクエストの内容を集約して実行できるようにしています。

// Request はバンドルに追加されるリクエストデータ
type Request struct {
    Data string
}

func main() {
    // バンドラーの設定
    b := bundler.NewBundler((*Request)(nil), func(bundle interface{}) {
        requests := bundle.([]*Request)

        // ここで内容を集約したうえでリクエストを実行する
    })
    b.DelayThreshold = time.Second // バンドルがフラッシュされるまでの遅延時間


    // 直接リクエストを実行せず、バンドルに追加することで
    // 遅延・集約をかけた上でリクエストを実行できる
    if err := b.Add(item, 0); err != nil {
        log.Fatalf("failed to add request to bundler: %v", err)
    }
}

自動リトライ

API連携で必須なのが「リトライ処理」です。
リトライのスコープは、局所的にAPIリクエストに対して行うケースと、1つの処理プロセスまるごとを行うケースがあります。
スコープが広すぎると、その分失敗時のロールバックのコストが大きくなってしまうため、
まずは失敗が想定される場所にリトライ処理を実施し、
広いスコープでのリトライはあくまでも補助的に施しておくと良いです。

以下は具体的なリトライ方法の一例です。

  • 失敗時、3回は再試行する
  • 再試行は即座に実行せず、5秒間遅延(スリープ)させる
    • 遅延時間は試行回数ごとに増やす (バックオフリトライ)
  • 最終的なエラーのみが返される
    • 再試行の際は警告レベルのログを出力する

avast/retry-go を使用するとシンプルにリトライを実装することができます。

func WithRetry(ctx context.Context, do func() error) error {
    return retry.Do(
        do,
        retry.Context(ctx),
        retry.Attempts(3),
        retry.Delay(5*time.Second),
        retry.DelayType(retry.BackOffDelay),
        retry.LastErrorOnly(true),
        retry.OnRetry(func(n uint, err error) {
            log.Printf("[warn] Finished retrying request. retried count:[%d] error:[%s]", n+1, err)
        }),
    )
}

監視

サービスと連携する場合は、その連携処理を簡易することが非常に大切です。
外部のサービスが起因した障害であれば、連携元からは対応ができないことがほとんどですが、
問題を迅速に検知して対応することで、影響を最小限に抑え、
外部要因からの連鎖的な障害を防ぐことができます。

最低限、以下のメトリクスは収集しておき、異常値を検出できるようにしておきましょう。

  • エラーレート
    • 高いエラーレートは外部サービスの障害の可能性を示しています。
  • レスポンスタイム
    • 遅延が大きい場合はパフォーマンスが低下している可能性があります。
  • リトライ回数
    • リトライ頻度が多い場合、外部サービスの不安定さを示しています。
  • エラーの詳細
    • エラーや遅延の原因を特定するのに必要になります。
    • 可能であれば発生したエラーは全てエンジニアの目の届くところに通知し、すぐに対処するのが理想です。

まとめ

外部サービスとのAPI連携は、不安定な要素を多く含むので
実装の際はさまざまなことに気を配る必要があります。
上記のポイントをぜひ参考にしてみてください。