AWS SDK の裏側を見てみよう !

~AWS SDK for Go (v1) のコードとともに (後編)

2022-06-01
デベロッパーのためのクラウド活用方法

王 力捷

皆さん、こんにちは !
ソリューションアーキテクト (SA) の王 (@elecho1_t)です。

本記事は、AWS SDK の裏側を見てみよう ! ~AWS SDK for Go (v1) のコードとともに (前編) の後編となります。

本シリーズでは、Go 言語向けの AWS SDK for Go Version 1 (v1.42.35) を例に、AWS SDK の裏側の動きをご紹介しています。

Go 言語の SDK を扱っていますが、Go 言語の仕様に関して深入りしておらず、動作自体は SDK で共通している部分も多いため、Go 言語に詳しくない方でも本記事を読むことで AWS SDK の処理の流れを追っていただくことができるかと思います

後編となる本記事では、Amazon DynamoDB のテーブルから item を取得する GetItem API を題材にして、AWSリソースの操作を行う Request 送信部分の AWS SDK の動きをソースコードと共に見ていきたいと思います。

このクラウドレシピ (ハンズオン記事) を無料でお試しいただけます »

毎月提供されるクラウドレシピのアップデート情報とともに、クレジットコードを受け取ることができます。 


1. 前編の復習 & 本記事で扱う内容

本シリーズでは、Amazon DynamoDB のテーブルから item を取得するための GetItem のサンプルコード を題材にしています。

このサンプルコード中で AWS サービスとやり取りするために AWS SDK が使われているのは以下の 3 文です。

    // (A) Session: 基本的な共通設定
    sess := session.Must(session.NewSessionWithOptions(session.Options{
        SharedConfigState: session.SharedConfigEnable,
    }))
    
    // (B) Service client: 単独のAWSサービスを利用するための設定(ここではAmazon DynamoDB)
    svc := dynamodb.New(sess)
    
    // (C) Request: 具体的なAWSサービス利用(ここではAmazon DynamoDBのitem取得)【本記事で扱うパート】
    result, err := svc.GetItem(&dynamodb.GetItemInput{
        TableName: aws.String(tableName),
        Key: map[string]*dynamodb.AttributeValue{
            "Year": {
                N: aws.String(movieYear),
            },
            "Title": {
                S: aws.String(movieName),
            },
        },
    })

ここで、Session, Service client, Request という 3 つの概念が出てきます。

  • Session : AWS サービスと接続するための基本的な共通設定です。例えば、利用するリージョンや AWS での認証方法を設定できます。
  • Service client : 単独の AWS サービスを利用するための設定です。作成時にリクエストの送信先エンドポイント (詳細は後述) の設定なども内部で行います。なお、必要に応じて Session で設定した内容を上書きすることもできます。
  • Request : AWS サービスに対して具体的な操作を指示する API リクエストです。Service client に紐付いたメソッドを実行することで (上の例では GetItem())、API リクエスト送信・レスポンス受信を行うことができます。

(各概念の説明は、こちらのドキュメント にも記載されています。)

前編 では、AWS SDK for Go V1 における、(A) Session や (B) Service client の裏側を見てきました。

後編(本記事)では、(C) Request について、AWS リソースを操作するためのリクエストの生成や送信の流れに焦点を当てて見ていきたいと思います。

その中でも、

  • API への指示を HTTP リクエストへ埋め込む仕組み
  • リクエスト送信元を認証するためのリクエストへの署名
  • エクスポネンシャルバックオフを用いたリクエストのリトライ

は興味深い仕組みとなっているかと思いますので、見出しに (★) マークを付けるとともに詳しめに説明しております。


2. ソースコードリーディング : (C) Request 送信の実行

それでは、(C) Request 送信の実行 の中身を見ていきましょう。

    // (C) Request: 具体的なAWSサービス利用(今回はAmazon DynamoDBのitem取得)
    result, err := svc.GetItem(&dynamodb.GetItemInput{
        TableName: aws.String(tableName),
        Key: map[string]*dynamodb.AttributeValue{
            "Year": {
                N: aws.String(movieYear),
            },
            "Title": {
                S: aws.String(movieName),
            },
        },
    })

2-1. 本章で見ていく関数・メソッドの実行順序

以下の★をつけたパートは興味深そうな仕組みだと思われるので、深堀りして説明していきます。

DynamoDB.GetItem()    # GetItem リクエストの作成・送信・結果取得を行うメソッド
├── DynamoDB.GetItemRequest()      # GetItem 用の Request 作成を行うメソッド
│   └── DynamoDB.newRequest()      # Client.NewRequest のラッパーメソッド
│       └── Client.NewRequest()    # request.New のラッパーメソッド
│           └── request.New()      # 新規 Requset を作成する関数
└── Request.Send()    # HTTP リクエスト作成・リクエスト送信を行うメソッド
    ├── Request.Sign()      # HTTP リクエスト作成・署名を行うメソッド
    │   ├── (★) Request.Build()    # HTTP リクエストの検証・作成を行うメソッド 
    │   │   └── Request.Handlers.Build.Run()    # HTTP リクエスト作成を行う HandlerList
    │   │       └── jsonrpc.Build()       # HTTP リクエストボディ・ヘッダの作成を関数
    │   └── (★) Request.Handlers.Sign.Run()     # HTTP リクエストへの署名付与を行う HandlerList
    │       └── Signer.signWithBody()     # 署名に利用する AWS 認証情報を取得するメソッド
    │           └── signingCtx.build()    # SigV4 署名を生成・付与するメソッド
    ├── Request.sendRequest()      # HTTP リクエストの送信・レスポンスの受信w行うメソッド                   
    └── (★) Request.Handlers.AfterRetry.Run(r)  # リトライ判定、リトライ間隔設定を行う HandlerList
        └── corehandlers.AfterRetryHandler      # リトライ判定、リトライ間隔設定を行う Handler

2-2. DynamoDB.GetItem() メソッドの内部構成

Amazon DynamoDB 型 Service client の GetItem() メソッドは、以下のように定義されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/service/dynamodb/api.go#L3548:L3551

func (c *DynamoDB) GetItem(input *GetItemInput) (*GetItemOutput, error) {
    req, out := c.GetItemRequest(input)
    return out, req.Send()
}

まず、1 行目で、引数として GetItem リクエストのパラメータを受け取っています。
サンプルコードでは、以下の部分です。

例えば、TableName や Key を指定することで、取得対象 item の DynamoDB テーブルおよび Primary Key を指定することができます。

&dynamodb.GetItemInput{
    TableName: aws.String(tableName),
    Key: map[string]*dynamodb.AttributeValue{
        "Year": {
            N: aws.String(movieYear),
        },
        "Title": {
            S: aws.String(movieName),
        },
    },
}

2 行目 req, out := c.GetItemRequest(input) では、引数として受け取ったパラメータを利用して Request を作成しています。

実は、この Request  (正確に言うと Request 型の変数) は GetItemInput() メソッドを通して作成された段階では、API を呼ぶための HTTP リクエスト本体を保持しているわけではなく、HTTP リクエスト生成に必要な各種情報を集約して保持しているだけです。

実際の HTTP リクエストの生成は、3 行目の req.Send() 内でリクエスト送信とあわせて行われます。

そして、3 行目の return で、リクエストの結果を格納した outreq.Send() を実行した際のエラーを返しています。


次の節では、まずは、 GetItemInput() メソッド内で HTTP リクエスト生成に必要な情報がどのように Request 型変数に格納されるのか見ていきたいと思います。


3. DynamoDB.GetItemRequest() メソッドによるリクエスト生成に必要な情報の集約

DynamoDB.GetItemRequest() メソッドは下記の通り定義されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/service/dynamodb/api.go#L3467:L3506

func (c *DynamoDB) GetItemRequest(input *GetItemInput) (req *request.Request, output *GetItemOutput) {
    op := &request.Operation{
        Name:       opGetItem,
        HTTPMethod: "POST",
        HTTPPath:   "/",
    }

    if input == nil {
        input = &GetItemInput{}
    }

    output = &GetItemOutput{}
    req = c.newRequest(op, input, output)
    // if custom endpoint for the request is set to a non empty string,
    // we skip the endpoint discovery workflow.
    if req.Config.Endpoint == nil || *req.Config.Endpoint == "" {
        if aws.BoolValue(req.Config.EnableEndpointDiscovery) {
            de := discovererDescribeEndpoints{
                Required:      false,
                EndpointCache: c.endpointCache,
                Params: map[string]*string{
                    "op": aws.String(req.Operation.Name),
                },
                Client: c,
            }

            for k, v := range de.Params {
                if v == nil {
                    delete(de.Params, k)
                }
            }

            req.Handlers.Build.PushFrontNamed(request.NamedHandler{
                Name: "crr.endpointdiscovery",
                Fn:   de.Handler,
            })
        }
    }
    return
}

このメソッドの重要な処理は、以下の通りです。

  • 1 行目 : 返り値として、リクエストを表す Request 型の変数とリクエスト結果を格納する変数のポインタを返すよう指定
  • 2~6 行目 : op という変数に API リクエストの種類を指定
  • 13 行目 : リクエスト生成に必要な情報を集約した Request 型の変数 req を作成

まず、返り値は 1 行目で定義されており、req という Request 型の変数 (のポインタ) と output という GetItem リクエストの実行結果を格納する変数 (のポインタ) を返しています。

この reqoutput がどのように生成されるのか意識しながらメソッドの中身を見ていきましょう。

2〜6 行目では、op という変数に API リクエストの種類を指定しています。具体的には、 3 行目 Name: opGetItem, (中身は “GetItem” という文字列) から、GetItem という API リクエストであることが指定されています。

その後、12 行目で output という GetItem リクエストの実行結果を格納するための変数を作成します。

そして、13 行目で、リクエストの種類を表す op 、本メソッドの引数であり API のパラメータを格納した input 、リクエストの実行結果を格納する output から、DynamoDB.newRequest() メソッドを用いて Request 型の変数 req を作成しています。

なお、リクエストの実行結果を格納する output は、このメソッドからは空のまま返されます。それでも、13 行目で output は Request (req) に渡されているので、本メソッドの呼び出し元である DynamoDB.GetItem() メソッド内で req.send() が実行されて API が呼ばれると、 output に結果が格納されます。

また、16 行目以降は Endpoint Discovery という機能を有効にした場合に追加で行われる処理です。Endpoint Discovery とは AWS SDK の持つ機能であり、自動的にAPIリクエストを送る先のエンドポイントURLを探してくれるというものです。この機能は Amazon DynamoDB ではデフォルトでは無効であることと本題から大きく外れるためここではスルーしますが、興味のある方はより深く見てみても良いかもしれません。

それでは、小ネタを挟んだ後、13 行目に出てきた DynamoDB.newRequest() メソッドをもっと深堀りしていきましょう。


小ネタ:GetItem リクエストなのに POST メソッド ?

ちなみに、GetItem リクエストなのに POST メソッドなのはどうして ? と思った方もいらっしゃるかもしれません。

その理由は、 Get 系のリクエストでも、取得する対象を示す条件などの指定が複雑かつ長大になりやすくURLのクエリ文字列に全てのパラメータを埋め込むことが難しいため、パラメータを HTTP リクエストのメッセージボディに記述しているためです (参考ドキュメント) 。逆に、ドキュメント にも記載があるようにリソースの状態変更を伴う API リクエストが GET メソッドを用いることも可能であり、AWS API は本質的には RESTful ではありません。なお、GET メソッドにメッセージボディを追加する手法に関しては、HTTP/1.1 の現在の仕様 (RFC 7231) ではメッセージボディ (ペイロード) の扱いが未定義であり、AWS API では採用されていません。

3-1. DynamoDB.newRequest() メソッド

DynamoDB.newRequest() メソッドは、下記のように定義されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/service/dynamodb/service.go#L100:L111

func (c *DynamoDB) newRequest(op *request.Operation, params, data interface{}) *request.Request {
    req := c.NewRequest(op, params, data)

    // Run custom request initialization if present
    if initRequest != nil {
        initRequest(req)
    }

    return req
}

まず、1 行目のメソッドの引数を見てみると、 inputparamsoutputdata として渡されていることが分かります。

このメソッドは、2 行目にある Client.NewRequest() メソッドのラッパーメソッドです。(DynamoDB 型 には Client 型が埋め込まれているので、Client 型のメソッドもそのまま利用できます。)

なお、5 行目以降の initRequest では、AWS サービスごとの追加処理を行うことができます。Amazon DynamoDB の場合は追加処理がないため何も処理は行われません。(この追加処理は、各 AWS サービス用の “customizations.go” ファイルに記述されています。Amazon DynamoDB の場合は aws-sdk-for-go/service/dynamodb/customizations.go ファイルにいろいろなサービス独自処理が記述されていますが、initRequest() 関数は無いことが確認できます。)

それでは、 Client.NewRequest() メソッドの中はどうなっているでしょうか ?

Client.NewRequest() メソッドは下記のように定義されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/aws/client/client.go#L85:L87

func (c *Client) NewRequest(operation *request.Operation, params interface{}, data interface{}) *request.Request {
    return request.New(c.Config, c.ClientInfo, c.Handlers, c.Retryer, operation, params, data)
}

このメソッドも、 request.New() 関数のラッパーとなっています。

request.New() に渡している引数に着目すると、DynamoDB.GetItemRequest() メソッドから渡されてきた operation (op)、params (input)、 data (output) に加えて、DynamoDB Service client の各種設定も渡されていることがわかります。

その request.New() 関数は下記のように定義されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/aws/request/request.go#L117:L171

func New(cfg aws.Config, clientInfo metadata.ClientInfo, handlers Handlers,
    retryer Retryer, operation *Operation, params interface{}, data interface{}) *Request {

    if retryer == nil {
        retryer = noOpRetryer{}
    }

    method := operation.HTTPMethod
    if method == "" {
        method = "POST"
    }

    httpReq, _ := http.NewRequest(method, "", nil)

    var err error
    httpReq.URL, err = url.Parse(clientInfo.Endpoint)
    if err != nil {
        httpReq.URL = &url.URL{}
        err = awserr.New("InvalidEndpointURL", "invalid endpoint uri", err)
    }

    if len(operation.HTTPPath) != 0 {
        opHTTPPath := operation.HTTPPath
        var opQueryString string
        if idx := strings.Index(opHTTPPath, "?"); idx >= 0 {
            opQueryString = opHTTPPath[idx+1:]
            opHTTPPath = opHTTPPath[:idx]
        }

        if strings.HasSuffix(httpReq.URL.Path, "/") && strings.HasPrefix(opHTTPPath, "/") {
            opHTTPPath = opHTTPPath[1:]
        }
        httpReq.URL.Path += opHTTPPath
        httpReq.URL.RawQuery = opQueryString
    }

    r := &Request{
        Config:     cfg,
        ClientInfo: clientInfo,
        Handlers:   handlers.Copy(),

        Retryer:     retryer,
        Time:        time.Now(),
        ExpireTime:  0,
        Operation:   operation,
        HTTPRequest: httpReq,
        Body:        nil,
        Params:      params,
        Error:       err,
        Data:        data,
    }
    r.SetBufferBody([]byte{})

    return r
}

この関数内の重要な処理として、以下の 3 つがあります。

  • 13 行目 http.NewRequest(method, "", nil)  :  HTTP メソッドを指定した HTTP リクエスト作成 (今回は POST メソッド)
  • 16〜36 行目 : HTTP リクエスト送信先のエンドポイント URL などの設定
  • 38〜50 行目 : その他リクエストに必要な情報を格納


HTTPリクエストのメッセージボディはこの関数内では生成されていません。

その代わり、37 行目以降にある通り、Request 型の変数 r の中に下記の内容なども格納し、HTTPリクエスト (HTTPRequest) と一緒に返しています。

  • 各種設定 (Config, ClientInfo
  • 送信までに行う処理 (Handlers)
  • リトライ方法 (Retryer)
  • リクエストの中身(Operation, Params)
  • リクエスト結果の格納場所 (Data)

ここまでで、API リクエスト生成・送信に必要な情報を格納した Request 型変数の作成は完了です。

ここからは、いよいよ実際に API リクエスト送信までにどのような処理が行われているのか見てみましょう。


4. Request.Send() メソッドによる HTTP リクエスト作成・リクエスト送信

4-1. リクエスト送信前後の処理 (Handlers) の一覧

実際にリクエストを送信する req.Send() の内部を見る前に、送信までに行われる処理 (Handlers) の一覧を見てみましょう。

まず、request.Handlers 型は以下のように定義されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/aws/request/handlers.go#L10:L25

type Handlers struct {
    Validate         HandlerList
    Build            HandlerList
    BuildStream      HandlerList
    Sign             HandlerList
    Send             HandlerList
    ValidateResponse HandlerList
    Unmarshal        HandlerList
    UnmarshalStream  HandlerList
    UnmarshalMeta    HandlerList
    UnmarshalError   HandlerList
    Retry            HandlerList
    AfterRetry       HandlerList
    CompleteAttempt  HandlerList
    Complete         HandlerList
}

リクエストを送信してからレスポンスを受信するまでの間に、上記の役割ごとに分かれた HandlerList を順番に実行していきます。

そして、今回 Amazon DynamoDB の GetItem リクエストを送るにあたって以下の Handler の処理が実行されます。

Handler 名だけを見て機能が想像できそうなものもあると思います。重要な役割を果たしている Handler は下の Requeest.Send() の解説において詳しく説明していきますが、ざっくりこんな処理をしていくんだと雰囲気をつかんでいただければと思います。

4-2. Request.Send() メソッドの実装

Request.Send() メソッドは下記のように定義されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/aws/request/request.go#L526:L561

func (r *Request) Send() error {
    defer func() {
        // Regardless of success or failure of the request trigger the Complete
        // request handlers.
        r.Handlers.Complete.Run(r)
    }()

    if err := r.Error; err != nil {
        return err
    }

    for {
        r.Error = nil
        r.AttemptTime = time.Now()

        if err := r.Sign(); err != nil {
            debugLogReqError(r, "Sign Request", notRetrying, err)
            return err
        }

        if err := r.sendRequest(); err == nil {
            return nil
        }
        r.Handlers.Retry.Run(r)
        r.Handlers.AfterRetry.Run(r)

        if r.Error != nil || !aws.BoolValue(r.Retryable) {
            return r.Error
        }

        if err := r.prepareRetry(); err != nil {
            r.Error = err
            return err
        }
    }
}

このメソッドでは、以下の処理を for 文による繰り返しによってリトライしながら行っています。

  • 16 行目 : r.Sign() による HTTP リクエスト作成・署名付与
  • 21 行目 : r.sendRequest() によるリクエスト送信
  • 24 行目 : r.Handlers.Retry.Run(r) によるリトライ専用処理 (Amazon DynamoDB のリクエストでは特にリトライ専用処理はなく、 r.Handlers.Retry は空)
  • 25 行目 : r.Handlers.AfterRetry.Run(r) によるリトライ判定・リトライ動作設定
  • 31 行目 : r.prepareRetry() による、レスポンスの除去などリトライ用の HTTP リクエストの再構築
  • 5 行目 : r.Handlers.Complete.Run(r) によるリクエスト完了後処理 (今回 r.Handlers.Complete は空)

そのうち重要な処理の中身を以下の章で順番に見ていきましょう。


5. Request.Sign() による HTTP リクエスト作成・リクエスト署名付与

Request.Sign() メソッドは以下のように定義されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/aws/request/request.go#L431:L446

func (r *Request) Sign() error {
    r.Build()
    if r.Error != nil {
        debugLogReqError(r, "Build Request", notRetrying, r.Error)
        return r.Error
    }

    SanitizeHostForHeader(r.HTTPRequest)

    r.Handlers.Sign.Run(r)
    return r.Error
}

このメソッド内で重要な処理は、以下の 2 つです。

  • 2 行目 : r.Build() による API 内容の HTTP リクエストへの埋め込み
  • 10 行目 : r.Handlers.Sign.Run(r) により HTTP リクエストへ署名を追加

この 2 つの処理を以下の節で順番に見ていきましょう。

5-1. (★) Request.Build() による API 内容の HTTP リクエストへの埋め込み

まず、2 行目 r.Build() (Request.Build() メソッド) は、以下のように定義されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/aws/request/request.go#L403:L429

func (r *Request) Build() error {
    if !r.built {
        r.Handlers.Validate.Run(r)
        if r.Error != nil {
            debugLogReqError(r, "Validate Request", notRetrying, r.Error)
            return r.Error
        }
        r.Handlers.Build.Run(r)
        if r.Error != nil {
            debugLogReqError(r, "Build Request", notRetrying, r.Error)
            return r.Error
        }
        r.built = true
    }

    return r.Error
}

この処理では、以下の 2 つの処理が行われています。

  • 3 行目 r.Handlers.Validate.Run(r) : リクエストの設定項目の検証
  • 8 行目 r.Handlers.Build.Run(r) : API 内容の HTTP リクエストへの埋め込み

なお、13 行目に r.built = true を設定することで、リトライ時にリクエストの検証・再生成を回避しています。

HandlerList.Run() メソッドによって、Validate や Build 内に含まれる Handler が順に実行されます。

まず、Validate には、以下の 2 つの Handler が含まれています。

そして、Build には、以下の Handler が含まれています。

このうち、HTTP リクエスト作成に重要な役割を果たしているのが jsonrpc.BuildHandler です。

ここで、HTTP リクエストを作成するために行いたいことは、 リクエストの種類を表す情報 (operation) とリクエストのパラメータ (params) をHTTPリクエストに埋め込むことです。

operation (op)

op := &request.Operation{
    Name:       opGetItem,    // 中身は “GetItem” という文字列
    HTTPMethod: "POST",
    HTTPPath:   "/",
}

params (input)

&dynamodb.GetItemInput{
    TableName: aws.String(tableName),
    Key: map[string]*dynamodb.AttributeValue{
        "Year": {
            N: aws.String(movieYear),
        },
        "Title": {
            S: aws.String(movieName),
        },
    },
}

これらの情報も組み込んで、以下のような HTTP リクエストを作成することが目標です。

GetItem HTTP リクエスト (ドキュメント、一部改変)

POST / HTTP/1.1
Host: dynamodb.<region>.<domain>;
Accept-Encoding: identity
Content-Length: <PayloadSizeBytes>
User-Agent: <UserAgentString>
Content-Type: application/x-amz-json-1.0
Authorization: AWS4-HMAC-SHA256 Credential=<Credential>, SignedHeaders=<Headers>, Signature=<Signature>
X-Amz-Date: <Date>
X-Amz-Target: DynamoDB_20120810.GetItem

{
    "TableName": "Movies",
    "Key": {
        "Year": {
            "N": "2015"
        },
        "Title": {
            "S": "The Big New Movie"
        }
    }
}

jsonrpc.BuildHandler でリクエストの情報がどのように HTTP リクエストに埋め込まれているのか見てみましょう。

jsonrpc.BuildHandler の処理部分は、下記のように定義されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/private/protocol/jsonrpc/jsonrpc.go#L38:L66

func Build(req *request.Request) {
    var buf []byte
    var err error
    if req.ParamsFilled() {
        buf, err = jsonutil.BuildJSON(req.Params)
        if err != nil {
            req.Error = awserr.New(request.ErrCodeSerialization, "failed encoding JSON RPC request", err)
            return
        }
    } else {
        buf = emptyJSON
    }

    // Always serialize the body, don't suppress it.
    req.SetBufferBody(buf)

    if req.ClientInfo.TargetPrefix != "" {
        target := req.ClientInfo.TargetPrefix + "." + req.Operation.Name
        req.HTTPRequest.Header.Add("X-Amz-Target", target)
    }

    // Only set the content type if one is not already specified and an
    // JSONVersion is specified.
    if ct, v := req.HTTPRequest.Header.Get("Content-Type"), req.ClientInfo.JSONVersion; len(ct) == 0 && len(v) != 0 {
        jsonVersion := req.ClientInfo.JSONVersion
        req.HTTPRequest.Header.Set("Content-Type", "application/x-amz-json-"+jsonVersion)
    }
}

主な処理は以下の通りになります。

  • 5 行目 : リクエストのパラメータ (req.Params) を JSON に変換し、バイト列として保持
  • 15 行目 : SetBufferBody() メソッドにより、5 行目で JSON 化したリクエストのパラメータを req.HTTPRequest.Body へ代入 (詳細省略)
  • 19 行目 : HTTP リクエストヘッダーの X-Amz-Target フィールドに、DynamoDB の GetItem リクエストであることを記載 (X-Amz-Target: DynamoDB_20120810.GetItem)

5 行目で、いよいよリクエストパラメータ (params) が JSON 化され、15 行目でリクエストボディに格納されました。

また、19 行目にて、DynamoDB の GetItem リクエストであるという情報 (operation) が HTTP リクエストヘッダーの X-Amz-Target フィールドに記載されています

以上のプロセスで、API に指示する内容を HTTP リクエストに埋め込む作業は完了です。

続いては、HTTP リクエストへ署名を追加するプロセスを見ていきましょう。

5-2. (★) r.Handlers.Sign.Run(r) によるリクエストへの署名追加

API リクエストの署名を含む認証関連情報は、HTTP リクエストヘッダに以下のように付与されます。(なお、認証情報はクエリ文字列に含めることも可能です。)

Authorization: AWS4-HMAC-SHA256 Credential=<Credential>, SignedHeaders=<Headers>, Signature=<Signature>

本署名の役割は、API 側がリクエスト送信元を認証することです。

この認証関連情報付与は、Signature V4 (SigV4) という仕組みを利用して行われています。

SigV4 を一言で説明すると、送信元を認証するために、シークレットアクセスキーやその他メタ情報を利用して署名を生成する仕組みです。

API リクエストに付与された署名と AWS の API 側でリクエスト内容から計算した署名が一致すれば、送信元が正しいシークレットアクセスキーを持っていると認証され、API リクエストが処理されます。

5-2-1. SigV4 の署名プロセス

SigV4 の具体的な署名プロセスは、SDK を使っている間は特に意識する必要はありません。

ただ、もし SDK を使わずに AWS へリクエストを送信しなければいけない場合でも、署名プロセスが ドキュメント で公開されているため、独自に SigV4 の署名プロセスを実装することもできます。

SigV4 は、具体的には、以下のようなプロセスで認証情報の生成・HTTP リクエストへの付与を行っています。ご興味のある方は、各プロセスの詳細を確認してみてもいいかもしれません。(詳細は ドキュメント をご参照ください。)

  1. HTTP リクエストの内容 (リクエスト先、ヘッダー、ボディのハッシュ値など) を整形し、さらに SHA256 などのハッシュアルゴリズムでハッシュ値を計算します。(タスク 1)
  2. タスク 1 で計算したハッシュ値とハッシュアルゴリズム名などのメタ情報を合わせて署名生成の基となる文字列 (署名文字列) を生成します。(タスク 2)
  3. HMAC アルゴリズムを利用して、シークレットアクセスキーやリージョン名などの情報をハッシュキーとした HMAC ハッシュ操作を行い、署名文字列から署名を生成します。(タスク 3)
  4. タスク 3 で生成した署名を、HTTP リクエストヘッダもしくはクエリ文字列として付与します。また、AWS Security Token Service (STS)AWS EC2 Instance Metadata Service (IMDS) などから取得した一時認証情報を使用する際は、さらにセッショントークンの値を X-Amz-Security-Token として付与します。(タスク 4)

5-2-2. SigV4 によって署名付与を行う v4.SignRequestHandler 内の処理

Sign には、以下の2つの Handler が含まれています。

このうち、署名付与は v4.SignRequestHandler で行われています。

v4.SignRequestHandlerv4.SignSDKRequestWithCurrentTime() から呼び出される Signer.signWithBody() は以下のように定義されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/aws/signer/v4/v4.go#L315:L374

func (v4 Signer) signWithBody(r *http.Request, body io.ReadSeeker, service, region string, exp time.Duration, isPresign bool, signTime time.Time) (http.Header, error) {
    currentTimeFn := v4.currentTimeFn
    if currentTimeFn == nil {
        currentTimeFn = time.Now
    }

    ctx := &signingCtx{
        Request:                r,
        Body:                   body,
        Query:                  r.URL.Query(),
        Time:                   signTime,
        ExpireTime:             exp,
        isPresign:              isPresign,
        ServiceName:            service,
        Region:                 region,
        DisableURIPathEscaping: v4.DisableURIPathEscaping,
        unsignedPayload:        v4.UnsignedPayload,
    }

    for key := range ctx.Query {
        sort.Strings(ctx.Query[key])
    }

    if ctx.isRequestSigned() {
        ctx.Time = currentTimeFn()
        ctx.handlePresignRemoval()
    }

    var err error
    ctx.credValues, err = v4.Credentials.GetWithContext(requestContext(r))
    if err != nil {
        return http.Header{}, err
    }

    ctx.sanitizeHostForHeader()
    ctx.assignAmzQueryValues()
    if err := ctx.build(v4.DisableHeaderHoisting); err != nil {
        return nil, err
    }

    // (〜省略〜)
    
    return ctx.SignedHeaderVals, nil
}

ここで行われている処理として、重要なものは主に 2 つです。

  • 30 行目 : ctx.credValues, err = v4.Credentials.GetWithContext(requestContext(r)):AWS 認証情報の取得
  • 37 行目 : ctx.build(v4.DisableHeaderHoisting)署名生成

30 行目で AWS 認証情報 (シークレットアクセスキーなどの値) を取得した後、37 行目の ctx.build() で署名を生成しています。

この ctx.build() は以下のように定義されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/aws/signer/v4/v4.go#L514:L550

func (ctx *signingCtx) build(disableHeaderHoisting bool) error {
    ctx.buildTime()             // no depends
    ctx.buildCredentialString() // no depends

    if err := ctx.buildBodyDigest(); err != nil {
        return err
    }

    unsignedHeaders := ctx.Request.Header
    if ctx.isPresign {
        if !disableHeaderHoisting {
            urlValues := url.Values{}
            urlValues, unsignedHeaders = buildQuery(allowedQueryHoisting, unsignedHeaders) // no depends
            for k := range urlValues {
                ctx.Query[k] = urlValues[k]
            }
        }
    }

    ctx.buildCanonicalHeaders(ignoredHeaders, unsignedHeaders)
    ctx.buildCanonicalString() // depends on canon headers / signed headers
    ctx.buildStringToSign()    // depends on canon string
    ctx.buildSignature()       // depends on string to sign

    if ctx.isPresign {
        ctx.Request.URL.RawQuery += "&" + signatureQueryKey + "=" + ctx.signature
    } else {
        parts := []string{
            authHeaderPrefix + " Credential=" + ctx.credValues.AccessKeyID + "/" + ctx.credentialString,
            "SignedHeaders=" + ctx.signedHeaders,
            authHeaderSignatureElem + ctx.signature,
        }
        ctx.Request.Header.Set(authorizationHeader, strings.Join(parts, ", "))
    }

    return nil
}

小ネタ 2 でご紹介した SigV4 の署名プロセスと照らし合わせると、各プロセスは下記部分で実行されています。

  • 21 行目 ctx.buildCanonicalString() : HTTP リクエストの内容を整形(タスク 1)
  • 22 行目 ctx.buildStringToSign() : 署名生成の基となる文字列(署名文字列)を生成 (タスク 2)
  • 23 行目 ctx.buildSignature() : HMAC ハッシュ操作により署名を生成 (タスク 3)
  • 33 行目 ctx.Request.Header.Set(authorizationHeader,...) : 署名を HTTP リクエストヘッダーに付与 (タスク 4)

5-2-3. Tips:AWS 一時認証情報は自動で更新される

Assume Role を行ったときなどに AWS Security Token Service (STS) から発行される認証情報や、EC2 インスタンスから他の AWS リソースを操作するために AWS EC2 Instance Metadata Service (IMDS) から取得される認証情報は、一時認証情報ということで有効期限が設定されています。

そのため、常時動作するアプリケーションを SDK 経由で操作する際、認証情報がどのように管理されるか (最悪アプリケーションが途中で停止しないか) 気になる方もいらっしゃるかと思います。

結論から言うと、STS や IMDS から取得される AWS 一時認証情報は自動的に更新されます。
そのため、ユーザーが AWS 一時認証情報の有効期限を意識する必要はありません。
(Web Identity Federation を設定していて元の Web Identity のトークンが失効している場合など、AWS 一時認証情報の更新に失敗する場合はあります。)

先述の通り、AWS SDK 内では APIリクエストの署名のため AWS 認証情報が利用され、Credentials.GetWithContext() メソッドを通して認証情報の値が取得されています。

Credentials.GetWithContext() メソッドは、下記のように実装されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/aws/credentials/credentials.go#L225:L264

func (c *Credentials) GetWithContext(ctx Context) (Value, error) {
    // Check if credentials are cached, and not expired.
    select {
    case curCreds, ok := <-c.asyncIsExpired():
        // ok will only be true, of the credentials were not expired. ok will
        // be false and have no value if the credentials are expired.
        if ok {
            return curCreds, nil
        }
    case <-ctx.Done():
        return Value{}, awserr.New("RequestCanceled",
            "request context canceled", ctx.Err())
    }

    // Cannot pass context down to the actual retrieve, because the first
    // context would cancel the whole group when there is not direct
    // association of items in the group.
    resCh := c.sf.DoChan("", func() (interface{}, error) {
        return c.singleRetrieve(&suppressedContext{ctx})
    })
    select {
    case res := <-resCh:
        return res.Val.(Value), res.Err
    case <-ctx.Done():
        return Value{}, awserr.New("RequestCanceled",
            "request context canceled", ctx.Err())
    }
}

主な処理は以下の通りになります。

  • 4行目 case curCreds, ok := <-c.asyncIsExpired():有効な認証情報があるかどうか確認。もしあれば 8 行目で有効な認証情報をそのまま返す。
  • 19行目 return c.singleRetrieve(...) : (有効な認証情報がなければ) 新たに認証情報を取得して返す。

まず、4 行目の c.asyncIsExpired() で有効な認証情報があるか判定し、もしあれば 8 行目でその有効な認証情報をそのまま返します。

環境変数や ~/.aws/config ファイルなどに IAM ユーザーの静的な認証情報を登録している場合は、失効しないので常にその静的な認証情報が返されます。

一方、もし認証情報が失効していた場合などは、19 行目の c.singleRetrieve() で新たに認証情報が取得され、その認証情報を返します。また、新たに取得された Service client に登録され、有効期限内に新たな API リクエストを発行する際に再利用されます。


6. Request.sendRequest() によるリクエスト送信

署名が終わったHTTPリクエストは、r.sendRequest()  (Request.sendRequest() メソッド) により実際に送信されます。

Request.sendRequest() メソッドは以下のように定義されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/aws/request/request.go#L589:L620

func (r *Request) sendRequest() (sendErr error) {
    defer r.Handlers.CompleteAttempt.Run(r)

    r.Retryable = nil
    r.Handlers.Send.Run(r)
    if r.Error != nil {
        debugLogReqError(r, "Send Request",
            fmtAttemptCount(r.RetryCount, r.MaxRetries()),
            r.Error)
        return r.Error
    }

    r.Handlers.UnmarshalMeta.Run(r)
    r.Handlers.ValidateResponse.Run(r)
    if r.Error != nil {
        r.Handlers.UnmarshalError.Run(r)
        debugLogReqError(r, "Validate Response",
            fmtAttemptCount(r.RetryCount, r.MaxRetries()),
            r.Error)
        return r.Error
    }

    r.Handlers.Unmarshal.Run(r)
    if r.Error != nil {
        debugLogReqError(r, "Unmarshal Response",
            fmtAttemptCount(r.RetryCount, r.MaxRetries()),
            r.Error)
        return r.Error
    }

    return nil
}

このメソッドでは、リクエスト送信からレスポンスの受信・分解まで、下記の各 HandlerList の処理実行を行います。

  • 5 行目 r.Handlers.Send.Run(r)HTTP リクエストの送信・レスポンスの受信
  • 13 行目 r.Handlers.UnmarshalMeta.Run(r) : レスポンスよりステータスコードやヘッダー等のメタ情報を抽出
  • 14 行目 r.Handlers.ValidateResponse.Run(r) : レスポンスが正常に完了しているか、どんなエラーが発生したのか検証
  • 16 行目 r.Handlers.UnmarshalError.Run(r)レスポンスのエラー情報を抽出・分解
  • 23 行目 r.Handlers.Unmarshal.Run(r) : レスポンスの内容を抽出・分解
  • 2 行目 r.Handlers.CompleteAttempt.Run(r)リクエスト送信完了後の処理 (今回 r.Handlers.CompleteAttempt は空)

順番にHandlerList に含まれる Handler の役割を見てみます。

まず、実際にリクエスト送信を行う Send には、以下 2 つの Handler が含まれています。

次に API のレスポンスのメタ情報を JSON から取り出す UnmarshalMeta には、以下の Handler が含まれています。

レスポンスの検証を行う ValidateResponse には以下の Handler が含まれています。

もしレスポンスのステータスコードが 3xx, 4xx, 5xx などの非成功を表すものであった場合、エラー情報を抽出するために、UnmarshalError 内の Handler が実行されます。UnmarshalError には以下の Handler が含まれます。

一方、レスポンスが成功していた場合は、Unmarshal 内の Handler が実行されます。Unmarshal には以下の Handler が含まれます。

  • validateCRC32 : レスポンスの HTTP ヘッダーの "X-Amz-Crc32" の値とレスポンスから計算した CRC32チェックサムの値を比較し、データの整合性をチェック
  • jsonrpc.UnmarshalHandler : レスポンスの情報を JSON から変換し、r.Data (output) に格納

リクエストが成功した場合、jsonrpc.UnmarshalHandler によってリクエスト結果が Request 型変数 rData フィールドに格納され、最初に DynamoDB.GetItem() を呼んだユーザーコードから GetItem リクエストの結果にアクセスできるようになります。


7. (★) r.Handlers.AfterRetry.Run(r) によるリトライ判定・リトライ動作設定

Request.sendRequest() 実行後、エラーの種類によってリトライを行うかどうか、r.Handlers.AfterRetry に含まれる Handler の処理によって判定します。

例えば、Amazon DynamoDB のプロビジョニング済み WCU、RCU を超過してスロットリングされてしまった場合は、時間をあけて自動でリトライを行います。一方、Primary Key エラーなど、何度リクエストを実行しても結果が変わらなそうなエラーの場合は、リトライをしません。

このような処理の判定を、r.Handlers.AfterRetry で行います。また、同時にリトライ待機時間などのリトライの設定も行います。

AfterRetry には以下の Handler が含まれています。

まず、前提として、DynamoDBの場合デフォルトでは以下のようなリトライ設定が適用されています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/service/dynamodb/customizations.go#L30:L40

func setCustomRetryer(c *client.Client) {
    maxRetries := aws.IntValue(c.Config.MaxRetries)
    if c.Config.MaxRetries == nil || maxRetries == aws.UseServiceDefaultRetries {
        maxRetries = 10    // 最大リトライ回数は10回
    }

    c.Retryer = client.DefaultRetryer{
        NumMaxRetries: maxRetries,
        MinRetryDelay: 50 * time.Millisecond,    // 最小リトライ間隔は50ミリ秒
    }
}

リトライをする際には、エクスポネンシャルバックオフという再試行ロジックが用いられています。簡単に述べると、リトライ回数が増えるにつれてリトライ間隔を倍々に増やしていく仕組みです。(参考ドキュメント)

各 AWS SDK はエクスポネンシャルバックオフアルゴリズムを実装し、フロー制御を改善します。エクスポネンシャルバックオフは、再試行間の待機時間を累進的に長くして、連続的なエラー応答を受信するという概念に基づいています。たとえば、1 回目の再試行の前に最大 50 ミリ秒、2 回目の前に最大 100 ミリ秒、3 回目の前に最大 200 ミリ秒のようになります。

デフォルトではジッターと呼ばれるランダム化された遅延も待ち時間に加えられます。

それでは、corehandlers.AfterRetryHandler の実装を見てみましょう。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/aws/corehandlers/handlers.go#L185:L219

var AfterRetryHandler = request.NamedHandler{
    Name: "core.AfterRetryHandler",
    Fn: func(r *request.Request) {
        // If one of the other handlers already set the retry state
        // we don't want to override it based on the service's state
        if r.Retryable == nil || aws.BoolValue(r.Config.EnforceShouldRetryCheck) {
            r.Retryable = aws.Bool(r.ShouldRetry(r))
        }

        if r.WillRetry() {
            r.RetryDelay = r.RetryRules(r)

            if sleepFn := r.Config.SleepDelay; sleepFn != nil {
                // Support SleepDelay for backwards compatibility and testing
                sleepFn(r.RetryDelay)
            } else if err := aws.SleepWithContext(r.Context(), r.RetryDelay); err != nil {
                r.Error = awserr.New(request.CanceledErrorCode,
                    "request context canceled", err)
                r.Retryable = aws.Bool(false)
                return
            }

            // when the expired token exception occurs the credentials
            // need to be expired locally so that the next request to
            // get credentials will trigger a credentials refresh.
            if r.IsErrorExpired() {
                r.Config.Credentials.Expire()
            }

            r.RetryCount++
            r.Error = nil
        }
    }}

主な処理は、以下の5つとなります。

  • 7 行目 r.ShouldRetry(r) : レスポンスのステータスコードを見て、リトライを行うべきかどうか判定
  • 10 行目 if r.WillRetry() :最大リトライ回数などと比べて、リトライを行うべきか判定
  • 11 行目 r.RetryDelay = r.RetryRules(r) : リトライ間隔を設定
  • 16 行目 aws.SleepWithContext(r.Context(), r.RetryDelay) : 指定時間待機
  • 30 行目 r.RetryCount++ : リトライ回数をインクリメント

このうち、11行目 r.RetryDelay = r.RetryRules(r) のリトライ間隔設定部分で、スロットリングされたリクエストに対してはエクスポネンシャルバックオフを行うようにリトライ間隔を計算しています。

その実装部分は下記のようになっています。

https://github.com/aws/aws-sdk-go/blob/v1.42.35/aws/client/default_retryer.go#L112:L122

    // Logic to cap the retry count based on the minDelay provided
    actualRetryCount := int(math.Log2(float64(minDelay))) + 1
    if actualRetryCount < 63-retryCount {
        delay = time.Duration(1<<uint64(retryCount)) * getJitterDelay(minDelay)    // エクスポネンシャルバックオフ待機時間の計算
        if delay > maxDelay {
            delay = getJitterDelay(maxDelay / 2)
        }
    } else {
        delay = getJitterDelay(maxDelay / 2)
    }
    return delay + initialDelay

細かい条件分岐などは置いておいて、4 行目で

2^(リトライ回数) * (ランダム化された遅延+最小待ち時間)

というエクスポネンシャルバックオフの待ち時間の計算を行っていることが分かると思います。


8. まとめ

本記事では、AWS リソースを操作するためのリクエスト作成、送信に焦点を当てて、AWS SDK の裏側の動作をご紹介しました。

特に、

  • API への指示を HTTP リクエストへ埋め込む仕組み
  • リクエスト送信元を認証するための SigV4 を用いた署名
  • エクスポネンシャルバックオフを用いたリクエストのリトライ

は興味深い仕組みとなっているのではないかと思います。

今回題材としては AWS SDK for Go (v1) を用いていますが、内部で行われている処理は Go 言語用の SDK に限った話ではなく、SDK 全体や AWS API の仕組みに関わる内容となります。

本記事を通して、AWS SDK の動作への理解、ひいては AWS API の仕組みについて理解を深めていただけましたら幸いです。


9. 参考情報


builders.flash メールメンバーへ登録することで
AWS のベストプラクティスを毎月無料でお試しいただけます

筆者プロフィール

王 力捷 (Lijie Wang) @elecho1_t
アマゾン ウェブ サービス ジャパン合同会社
ソリューションアーキテクト

学生時代に機械学習の研究をしていたこともあり、MLOps に関心を持つ。好きな AWS サービスは AWS App Runner
休日の楽しみは、鉄道動画やバラエティ番組をのんびりと見ること。

さらに最新記事・デベロッパー向けイベントを検索

下記の項目で絞り込む
1

AWS を無料でお試しいただけます

AWS 無料利用枠の詳細はこちら ≫
5 ステップでアカウント作成できます
無料サインアップ ≫
ご不明な点がおありですか?
日本担当チームへ相談する