トレタ開発者ブログ

飲食店向け予約/顧客台帳サービス「トレタ」、モバイルオーダー「トレタO/X」などを運営するトレタの開発メンバーによるブログです。

もうすぐ標準化されるらしいのでHTTP/3について調べてみた

*この記事は トレタ Advent Calendar 2020 の7日目の記事です。

こんにちは。トレタでサーバーサイドエンジニアをしている川村です。 この記事では自分の学習も兼ねてHTTP/3についてまとめたいと思います。

このテーマにしたきっかけと記事の目標

先月「QUICとHTTP/3がIETFのラストコール。RFCによる標準化が間近に」という記事を見て、「規格策定してることはなんとなく知ってたけど全然気にしてなかったな」と思っていたものの、全く調べてなかったのでこれを機に調べてみようとこのテーマにしました。

という入りなので、データ構造やシーケンスのような細かいレベルの話はでてきません。※細かい所は参考のリンク先を辿っていただけると見つかるかと思います
この記事ではHTTP/3で何がどう変わって何が良くなるのかを把握することを目標にします。

HTTP/3の背景

HTTP/3はQUIC上で動作するHTTPの規格として新たに策定されました。
HTTP/2をそのまま使えればよかったのですが、トランスポート層変更の影響により不都合があったので、HTTP/2をベースにQUICの上でも問題なく動作するよう作成されたのがこの規格です。

f:id:yosinani4472:20201207170907p:plain
プロトコルスタックの変化

QUIC?

まずは、HTTP/3策定のきっかけとなったQUICとは何かから見ていきたいと思います。

QUICは、元をたどれば以下を目標にGoogleが作成したUDP上で動作するトランスポートプロトコルです。

  • TCP上でHTTP/2を使用する場合の不都合を解消する
  • 通信開始時の往復回数をへらす

HTTP/3とともに使われる予定のQUICは、Google版にあった独自仕様を一般的な実装に置き換えて策定が進められたIETF版のプロトコルとなっています。

〜 QUICの特徴 〜

- ストリームの導入

QUICではHTTP/2にあったストリームの概念を取り入れており、以下のように動作します。

  • 1コネクションに複数ストリームを流せる
  • ひとつのストリーム内でのパケット到着順序は保証される。複数ストリーム間での順序は保証されない。

- UDPで動作する

TCPはプロトコルの仕様として、送られてきたパケットにロスが無いか検証し、ロスがあった場合再送信してもらうことができるようになっています。この仕様のおかげで、TCPを使っていれば確実に完全なデータを受け取ることができるわけです。

ところで、HTTP/2は1コネクションで複数のストリームを扱うことで、複数のファイルを並列に取得できるようにし、HTTP/1に比べて効率よくファイルを取得できるようになっているのでした。

HTTP/2はTCPで動作するため、コネクションでパケットのロスが発生するとパケットの再送待ちが発生します。 その結果、このコネクションで扱われている全てのストリームの処理がパケット再送までのあいだ止まることになってしまいます。(Head of Line Blocking)

UDPのプロトコル自体にはパケットロスを修復する仕様は含まれていないため、UDPを使うことでこのブロックを回避することができます。UDPで保証されない代わりにQUIC側にストリーム単位でパケットロスを修復する仕組みが用意されているので時々パケットがロスすることを受け入れるわけではありません。

- コネクションをID管理するためIPが変化してもセッションが継続できる

TCPではコネクションの識別に「送信元/先のIPアドレス+送信元/先のTCPポート番号」を使用しますが、QUICでは各コネクションにIDを割り振るため、クライアントのIPアドレスが変わった場合でもセッションを継続することができます。

- 0-RTT/1-RTTハンドシェイクの提供

ハンドシェイクはサーバー/クライアント間でコネクションを確立するために行う一連の処理のことです。

TCPでは接続が確立するまでに1往復半のやり取りが必要でした。(3-Way ハンドシェイク) QUICでは1往復のやり取りでコネクションを確立できる(1-RTT ハンドシェイク)ようになっており、同じコネクションを再開する場合は、再開メッセージと共にデータを送信することもできる(0-RTT ハンドシェイク)ようになっています。

これにより、HTTPリクエストの内容などアプリケーションとして送信したいデータをより早く送ることができるようになります。

- 暗号化が必須

QUICではTLS1.3による暗号化が必須となっています。 このため、HTTP/3による通信では暗号化されていない通信は存在しないことになります。

HTTP/3

はじめに書いた通り、HTTP/3はQUIC上でHTTP/2を使えるようにしたものです。 そのため、機能的にはHTTP/2とほぼ同じになっており、優先度制御以外の機能的な差分はQUICの仕様に対応させるために発生しています。

〜 HTTP/3と2の違い 〜

- HTTP/3ではストリームを扱わない

HTTPのレイヤーでストリームの管理をしないという意味です。
上述の通りQUICにストリームの実装があるので、HTTP/3ではQUICのストリームを使用して、HTTPのデータを送受信します。

- ヘッダーの圧縮にQPACKを用いる

HTTP/2ではHPACKという方式でヘッダーを圧縮していましたが、この方式はパケットロスが発生した場合、そのパケットが再送されてくるまで待つ必要があり、ブロッキングが発生してしまいます。 そこで、パケットロスの発生によりパケットの到着順序が入れ替わっても問題なく動作するようHPACKを改良したQPACKが利用されます。

- 優先度付けが無い

HTTP/2にあった優先度設定が複雑すぎ、サポートしているサーバーが現時点でもほとんどないことから、レスポンスの優先度設定はHTTP/3の仕様から外され、別途検討されることになりました。(HTTPヘッダーベースのものが検討されているようです。)

f:id:yosinani4472:20201207174400p:plain
HTTP/3の接続イメージ

まとめと感想

ここまで見てきたことを元に、HTTP/3になるとどう変わるのかざっくりまとめるとこんな感じになります。

  • 全体的に通信の効率が良くなる(これがおそらく一番の目玉)
  • 確実に暗号化されていてセキュア
  • うまくいかなかった優先度付けの仕組みが外部化されて単純になる

HTTP/2になったときもそうでしたが、アプリケーションを作成する上ではHTTP/3であるか否かを殊更気にすることはあまり無さそうという印象をもちました。 (優先度付けの仕様策定と実装が進めばパフォーマンスチューニングの観点で気にすることはあるかも。HTTP/3の仕様ではないのでこの記事のスコープからちょっと外れますが)
ただ、インフラ周りの設定をする際は、いままであまり使わなかったであろうUDPが来ることを意識する必要があるので少し注意が必要そうです。

調査した内容は以上です!何がどう変わったのか大体把握できたかなと思うので目標は達成しました。多分。
今回調査する機会を作ってくれた @hiroki_tanaka に感謝してこの記事を締めたいと思います🙇

ありがとうございました!

最後に定番のやつを...

もしトレタに少しでも興味を持っていただいた方がいれば、ぜひ遊びに来てください🙋‍♂️
仲間も募集しています!

参考

今回調べるにあたって参考にしたページです。
本文では簡単のため大分省略しているので詳細を知りたい場合はぜひ。

terraformでAPI Gatewayを構築してAPI Key認証を設定してみた

本記事はトレタアドベントカレンダーの6日目の記事です。

始めに

皆さま、こんにちは!
『劇場版ヴァイオレット・エヴァーガーデン』で映画館にいる誰よりも号泣していたトレタのエンジニア兼北条加蓮ちゃんのプロデューサーの@hiroki_tanakaです。
先日、API Gateway~Lambda構成のサーバレスシステムをterraform+Lambrollで構築しました。
本記事ではterraformを用いてのAPI Gatewayの構築とAPI Keyでの認証設定に関してお話します。
※Lambrollに関しては昨日投稿された下記の記事を参考にしてください(o*。_。)oペコッ tech.toreta.in

システム構成

今回は下記のサーバレスアーキテクチャの基本型のようなシステム構成です。
アプリケーションからAPI Gatewayをキックして、キックしたパスに応じて対応するLambda関数を呼び出します。
アプリケーションからAPI Gatewayの認証方法はAPI Keyを用います。

f:id:hiroki_tanaka:20201130141152j:plain

API Gatewayをterraformで構築

1. Lambda関数の取得

data "aws_lambda_function" "hello_world" {
  function_name = "hello_world"
}

dataのaws_lambda_functionを用いて定義されたLambda関数を取得します。
今回はリクエストにHello worldと書くと、レスポンスでもHello worldと返してくれるLambda関数を定義しました。

2. API Gateway REST APIの定義

resource "aws_api_gateway_rest_api" "example" {
  name        = "example"
  description = "example API Gateway"
}

aws_api_gateway_rest_apiを用いてAPI Gateway REST APIを定義します。
これがAPI Gatewayの大元になります。

3. API Gatewayに紐づくリソース・メソッドの定義

resource "aws_api_gateway_resource" "hello_world" {
  rest_api_id = aws_api_gateway_rest_api.example.id
  parent_id   = aws_api_gateway_rest_api.example.root_resource_id
  path_part   = "hello_world"
}

resource "aws_api_gateway_method" "hello_world" {
  rest_api_id      = aws_api_gateway_rest_api.example.id
  resource_id      = aws_api_gateway_resource.hello_world.id
  http_method      = "POST"
  authorization    = "NONE"
  api_key_required = true
}

resource "aws_api_gateway_method_response" "hello_world" {
  rest_api_id = aws_api_gateway_rest_api.example.id
  resource_id = aws_api_gateway_resource.hello_world.id
  http_method = aws_api_gateway_method.hello_world.http_method
  status_code = "200"
  response_models = {
    "application/json" = "Empty"
  }
  depends_on = [aws_api_gateway_method.hello_world]
}

resource "aws_api_gateway_integration" "hello_world" {
  rest_api_id             = aws_api_gateway_rest_api.example.id
  resource_id             = aws_api_gateway_resource.hello_world.id
  http_method             = aws_api_gateway_method.hello_world.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = data.aws_lambda_function.hello_world.invoke_arn
}

API Gatewayに紐づくリソースやメソッド・統合リクエストを定義します。
まずaws_api_gateway_resourceでAPI Gatewayのリソース情報を定義し、大元のAPI Gateway REST APIと紐付けます。
path_partがこのAPIリソースの最後のセグメントになります。

次にaws_api_gateway_methodでAPI GatewayのHTTPメソッド情報を定義し、API Gateway REST API・リソースと紐付けます。
http_methodANYでも良いのですが、ここでは明示的にLambda側にPOSTリクエストを送るのでPOSTを選択します。 そして、API Keyを用いた認証を行うため、api_key_requiredtrueとします。

そして、aws_api_gateway_method_responseでAPI GatewayリソースのHTTPメソッドのレスポンスを定義し、API Gateway REST API・リソース・HTTPメソッドと紐付けます。 また、HTTPメソッドのレスポンスのためHTTPメソッドの後に作る必要があるため、depends_onを用いて明示的な依存関係を宣言しています。

最後にaws_api_gateway_integrationで統合リクエストを定義し、API Gateway REST API・リソース・HTTPメソッドと紐付けます。
integration_http_methodはHTTPメソッドに合わせてPOSTを選択します。 typeは今回、API Gatewayの構築が簡単なLambda プロキシ統合を使用するAWS_PROXYを設定します。
uriは実行したいLambda関数のinvoke_arnを設定します。

※補足:Lambda統合とLambdaプロキシ統合の違い aws_api_gateway_integrationtypeAWSを指定するとLambda統合・AWS_PROXYを指定するとLambdaプロキシ統合になるが、両者の違いは下記です。

  • Lambdaプロキシ統合はクライアントから連携されたAPI GatewayへのリクエストをLambda関数にraw形式でそのまま渡すことができる。
  • Lambdaプロキシ統合を使用する場合、Lambda関数は決められた形式でレスポンスを返却する必要がある。

つまり、簡単に言うとLambdaプロキシ統合の場合は出来るだけLambda側で開発が完結するようにAPI Gateway側の処理を最小限にしている形になります。

4. API Gatewayのステージ及びデプロイを定義

resource "aws_api_gateway_deployment" "example" {
  rest_api_id       = aws_api_gateway_rest_api.example.id
  stage_name        = "example"
  stage_description = "timestamp = ${timestamp()}"

  depends_on = [
    aws_api_gateway_integration.hello_world
  ]

  lifecycle {
    create_before_destroy = true
  }
}

aws_api_gateway_deploymentでAPI Gatewayのステージを定義し、大元のAPI Gateway REST APIと紐付けます。
stage_nameで指定した名前がAPI GatewayのURIのセグメントに入ってきます。

aws_api_gateway_deploymentは紐づくREST APIのリソースやメソッドが変更になった場合に再デプロイされず、自身に変更が加わった時のみデプロイされます。
そこでリソースやメソッドに変更が入った場合にもステージを最新状態に保つために、stage_description${timestamp()}を指定することで自身に必ず変更が加わるようにしています。
しかし、ステージは毎回replaceされるため後述するAPI Key・使用量プランとステージの紐づきがapplyの度に切れてしまう可能性があり、この紐づきが切れてしまうと正しいAPI Keyで正しいURIにアクセスしているのに404が返ってくるという事態になります。
そのため、lifecyclecreate_before_destroytrueにすることでそれを防止しています。

5. API Gatewayのログ出力を定義

resource "aws_api_gateway_method_settings" "example" {
  rest_api_id = aws_api_gateway_rest_api.example.id
  stage_name  = aws_api_gateway_deployment.example.stage_name
  method_path = "*/*"

  settings {
    data_trace_enabled = true
    logging_level      = "INFO"
  }
}

aws_api_gateway_method_settingsではログやモニタリングなどの設定を定義します。
settingsブロックで詳細な設定を行います。
今回はデータトレースロギングを有効にするかのdata_trace_enabledをtrueとして、logging_levelINFOとします。
(INFOは全てのログが出力されますので、エラー時のみで良い場合はERRORと設定すれば大丈夫です。)
また、ログの出力先ですがAPI GatewayはデプロイされるとCloudWatch Logsに自動でAPI-Gateway-Execution-Logs_{rest-api-id}/{stage_name}というロググループが作られ、そこにログが出力されます。

6. API GatewayにLambda関数へのアクセスを許可

resource "aws_lambda_permission" "hello_world" {
  action        = "lambda:InvokeFunction"
  function_name = data.aws_lambda_function.hello_world.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_api_gateway_rest_api.example.execution_arn}/*/${aws_api_gateway_method.hello_world.http_method}/${aws_api_gateway_resource.hello_world.path_part}"
}

最後にaws_lambda_permissionでLambda関数へのアクセス許可をAPI Gatewayに対して定義します。
principalで許可を与えるAWSサービスを指定しますが、今回はAPI Gatewayなのでapigateway.amazonaws.comとします。
(S3に与える場合はs3.amazonaws.com・SNSに与える場合はsns.amazonaws.comといった形で付与します。)

API Keyの設定

resource "aws_api_gateway_api_key" "example" {
  name    = "example_api_key"
  enabled = true
}

resource "aws_api_gateway_usage_plan" "example" {
  name       = "example_usage_plan"
  depends_on = [aws_api_gateway_deployment.example]

  api_stages {
    api_id = aws_api_gateway_rest_api.example.id
    stage  = aws_api_gateway_deployment.example.stage_name
  }
}

resource "aws_api_gateway_usage_plan_key" "example" {
  key_id        = aws_api_gateway_api_key.example.id
  key_type      = "API_KEY"
  usage_plan_id = aws_api_gateway_usage_plan.example.id
}

まずaws_api_gateway_api_keyでAPI Keyを定義します。

次にAPI Keyは使用量プラン経由で使用するため、aws_api_gateway_usage_planで使用量プランを定義します。
api_stagesで使用量プランとAPI Gatewayのステージの紐付けを行います。
そのため、使用量プランの定義前にステージが出来上がっている必要があるので、depends_onaws_api_gateway_deploymentを指定しています。

最後にaws_api_gateway_usage_plan_keyでAPI Keyと使用量プランの関連を定義します。
key_typeは現在、API_KEYのみ使用できます。

構築結果

これまでで必要なterraformのコードで準備は出来ました。
なので、いざapply!

  • API Gatewayリソースのメソッド
    API Keyでの認証が必須となっています。

f:id:hiroki_tanaka:20201202185254p:plain

  • API Gatewayリソースのメソッドの詳細
    Lambda関数:hello_worldと正常に接続しています。

f:id:hiroki_tanaka:20201202185827p:plain

  • API Gatewayステージ
    ログ出力設定及びURIのセグメント設定がterraformで定義した通りとなっています。

f:id:hiroki_tanaka:20201202185639p:plain

  • 使用量プラン
    使用量プランを介してAPI Gatewayステージ及びAPI Keyと紐付いています。

f:id:hiroki_tanaka:20201202190119p:plain f:id:hiroki_tanaka:20201202190156p:plain

ここまでマネジメントコンソール上で確認すると正常に出来ているように思えるので、最終確認としてコンソールからcurlコマンドを実行してみます。

$curl -X POST "https://{api-id}.execute-api.ap-northeast-1.amazonaws.com/example/hello_world" -d "{"hello_world"}" --header 'x-api-key: {api_key}'

{"body":"{hello_world}","time":"2020-12-02T10:07:37.703201622Z"}

正常にAPI Gateway経由でLambda関数が実行され、期待通りのレスポンスが戻ってきました。
無事にAPI Key認証を用いたAPI Gateway+Lambda構成、上手に出来ました!

終わりに

terraformを用いてのAPI Gatewayの構築は公式サイトにそれぞれのresourceのリファレンスが充実していることもあって、比較的簡単に構築することが出来ました。
今後はより応用的なAPI Gatewayの構築も行っていき、API Gatewayの様々な機能を使ってみたいです。
例えば「認証でCognito認証やBasic認証を使用してみたい」や「今回ドメインをAWSデフォルトのものをそのままで運用したので独自ドメインでの運用したい」などです。
そして、今回の構築を踏まえて今更ながらサーバレスアーキテクチャの世界に入門できたと思っているので、これからは色々なサーバレスシステムに携わって運用負荷の低減に貢献していきたいです!

終わりの終わりに

トレタに少しでも興味を持っていただいた方がいれば、ぜひ遊びに来てくださいヽ(*´∀`)/
仲間も募集しています!

lambrollでシンプルなLambdaデプロイの仕組みを作った話

トレタ Advent Calendar 2020 5日目の記事です。


はじめに

はじめまして。2020年4月からSREチームにJOINしたbutadoraと申します。

入社日から即フルリモートでなれない部分もありましたが、気づけばもう年末であっという間の1年だったなと感じるこの頃です。

さて、今回はAPI Gateway+LambdaでREST APIを構築するにあたり、 lambrollというデプロイツールを利用したデプロイフローを採用したため、紹介させていただきます。

lambrollとは

lambrollはfujiwaraさんによって作成されたLambdaのデプロイツールです。

github.com

一方lambrollはLambdaリソースに特化しており、 1コマンドでコードのアップロード準備〜デプロイをやってくれます。 大まかなデプロイの流れは以下のとおりです。

  1. デプロイ対象ディレクトリのzipアーカイブ
  2. zipファイルのアップロード(to S3)
  3. Lambda functionの(作成&)更新
  4. currentタグの(作成&)エイリアス更新

lambroll開発の経緯やクイックスタートはfujiwaraさんのブログにまとまっているためそちらをご参照ください。

sfujiwara.hatenablog.com

ディレクトリ構成

最終的な構成としては以下のようになりました。

.
├── bin/  # go biuldのバイナリデータ出力先ディレクトリ
├── .gitignore
├── .lambdaignore  # lambdaアーカイブ時に除外するディレクトリ
├── function.production.json  # production環境のLambda(lambroll)設定ファイル
├── function.staging.json  # staging環境のLambda(lambroll)設定ファイル
├── go.mod  # goプログラムの依存モジュール管理ファイル
├── go.sum  # goプログラムの依存モジュールのチェックサム管理ファイル
└── main.go  # goプログラム本体のファイル
  • 今回はgoで書かれているため、ビルド後のファイル置き場(空ディレクトリ)を用意
  • function.jsonはfunction.環境名.jsonそれぞれに書き分けて--functionオプションで切り替え

CI/CD

デプロイフローを実現するために追加した設定を書いていきます。

必要な権限(IAM Policy)

lambrollはAWS Lambda APIを利用している。 lambroll deployを実行するためには以下のようなIAM Policyが必要となります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iam:PassRole"
            ],
            "Resource": "arn:aws:iam::123456789012:role/lambda-role"
        },
        {
            "Effect": "Allow",
            "Action": [
                "lambda:CreateAlias",
                "lambda:CreateFunction",
                "lambda:GetFunction",
                "lambda:GetFunctionConfiguration",
                "lambda:ListTags",
                "lambda:UpdateAlias",
                "lambda:UpdateFunctionCode",
                "lambda:UpdateFunctionConfiguration"
            ],
            "Resource": "arn:aws:lambda:ap-northeast-1:123456789012:function:*"
        }
    ]
}
  • IAMのResourceはLambdaFunctionに割り当てるIAM Role

.circleci/confg.yml

今回はCircleCIで実現しました。configの一部を抜粋して紹介します。

version: 2.1
orbs:
  lambroll: fujiwara/lambroll@0.0.7

executors:
  golang:
    docker:
      - image: cimg/go:1.14
        auth:
          username: $DOCKERHUB_USER
          password: $DOCKERHUB_PASSWORD

commands:
  function_build:
    parameters:
      function_dir:
        type: string
    steps:
      - run:
          name: create persistent dir
          command: mkdir -p /tmp/lambroll-api/<<parameters.function_dir>>/bin
      - run:
          name: go build
          command: "GOOS=linux GOARCH=amd64 go build -ldflags='-s -w' -o /tmp/lambroll-api/<<parameters.function_dir>>/bin/main ./main.go"
          working_directory: "functions/<<parameters.function_dir>>"

  lambroll_init:
    steps:
      - lambroll/install:
          version: v0.9.1

jobs:
  build:
    executor: golang
    steps:
      - checkout
      - function_build:
          function_dir: hoge-function
      - function_build:
          function_dir: fuga-function
      - persist_to_workspace:
          root: /tmp/lambroll-api
          paths:
            - hoge-function/bin/*
            - fuga-function/bin/*

  lambroll_deploy:
    executor: golang
    steps:
      - checkout
      - attach_workspace:
          at: functions/
      - lambroll_init
      - run:
          command: lambroll deploy --src="./bin" --function="function.$ENV.json"
          working_directory: "functions/hoge-function"
      - run:
          command: lambroll deploy --src="./bin" --function="function.$ENV.json"
          working_directory: "functions/fuga-function"

# workflowsは省略
  • lambrollはgithubのreleaseから取ってくるだけですぐ利用できるが、Orbが用意されているためそちらを利用してインストール
    • 今回はbuildに使っているcimg/goをそのまま利用しているが、他のコンビニエンスイメージでもインストールできるはず
  • 環境変数はCircleCIのcontextsから呼び出すようにしている。定義している環境変数は以下。
    • ENV: 環境名
    • AWS_ACCESS_KEY_ID: lambrollデプロイ用ユーザのAWSアクセスキー
    • AWS_SECRET_ACCESS_KEY: lambrollデプロイ用ユーザのAWSシークレットアクセスキー
    • AWS_REGION: Lambdaをデプロイするリージョン

前日譚

トレタでは基本的にAWSリソースの管理にはTerraformを採用しています。 しかしながら、Lambdaに関してはzipアーカイブの用意などの事前作業が別途必要など、Terraformだけでデプロイを完結させることは難しいです。

そこで、真っ先に浮かんだのはServerless Frameworkでしたが、 Lambdaとその他リソースではデプロイフローが異なるのではないかという考えから、 シンプルにLambdaリソースだけを管理して、その他リソースはTerraformに任せる構成を模索しました。

その結果、lambrollが候補に上がり、lambrollの思想がマッチしていることやメンテナンスが継続されていることからPoCも兼ねて採用しました。

最後に

今回はサーバレスAPIの新規構築にlambrollを利用しましたが、 既存のLambda関数をlambroll管理下に置くことも容易にできるため、Serverless Frameworkからの置き換えも検討していきたいと考えています。

先日発表されたLambdaのcontainer imageにも即日対応されていたため、近々試して見ようと思います。

エンジニア募集中

トレタに興味を持っていただけましたら、是非一度お話しましょう!

https://corp.toreta.in/recruit/midcareer/

© Toreta, Inc.

Powered by Hatena Blog