LCL Engineers' Blog

バス比較なび・格安移動・バスとりっぷを運営する LCLの開発者ブログ

ECSをAPI Gatewayと組み合わせる

はじめに

この記事はLCL Advent Calendar 2020 - 24日目です。

qiita.com

リモートワークと外出自粛の組み合わせにより年の瀬をあまり感じていないバックエンドエンジニアの星野です。

LCLではAmazon ECSを活用しています。 その中でAmazon API GatewayのHTTP APIと組み合わせて使う機会があったので紹介したいと思います。

はじめにHTTP APIとREST APIの違い、それによるVPCリンクの挙動違いについてはクラスメソッドさんの記事によくまとまっていましたので参考にしてください。 dev.classmethod.jp dev.classmethod.jp

システム構成図

システム構成図は次のようになります。

f:id:hosht:20201223172050j:plain
クラスメソッドさんの図とほぼ同じです本当にありがとうございました

外側からAPI Gateway、ECSサービスディスカバリ(Cloud Map)、ECSです。

ALBと比較した際のメリット

ECSを使って外部にサービスを公開したい場合はALBがよく使われていると思いますが、今回ALBと比較してなぜAPI Gateway HTTP APIを採用したのかを解説します。

  • リクエスト数課金のため、リクエストが数が少ない場合作成した時点で時間単位の課金も発生するALBと比べて安価になる
  • どちらもOpenID Connectによる認証が使えるが、API GatewayはAmplify Libraryを使うことでフロントエンドの実装工数を削減できる
  • IAM認証を使うことでCognito User PoolsのグループでAPIエンドポイントアクセスを制限できる
  • Cross-Origin Resource Sharingの仕組みがビルトインされているのでECS側でハンドリングする必要がない

ECSからは離れますがAWSサービスとの統合も使い勝手が良いですね。

デメリットとしてはセキュリティグループやWAFを紐づけることができないことによるIP制限やエンドポイントの保護使えないことが挙げられます*1

Terraformで実装してみる

LCLではTerraformによるインフラのコード化を推進していますので、上記構成をTerraformで作成する際のポイントを解説して行きます。

techblog.lclco.com

サンプルコード全体

※このコードは説明の簡略化ために多くのリソースを省略しているのでそのままterraform applyできません。 また、チーム開発においてresourceにつける名前を全てthisにするのは止めましょう。

data "aws_vpc" "this" {
  tags = {
    Name = "awesome-vpc"
  }
}

resource "aws_service_discovery_private_dns_namespace" "this" {
  name = "private.example.com"
  vpc  = data.aws_vpc.this.id
}

resource "aws_service_discovery_service" "this" {
  name = "great-service-discovery"

  dns_config {
    namespace_id = aws_service_discovery_private_dns_namespace.this.id

    dns_records {
      ttl  = 60
      type = "SRV"
    }

    routing_policy = "MULTIVALUE"
  }

  health_check_custom_config {
    failure_threshold = 1
  }
}

resource "aws_ecs_service" "this" {
  中略
  service_registries {
    registry_arn = aws_service_discovery_service.this.arn
    port         = 80
  }
}

resource "aws_apigatewayv2_api" "this" {
  name          = "crazy-api"
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_deployment" "this" {
  api_id = aws_apigatewayv2_api.this.id

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_apigatewayv2_stage" "this" {
  api_id        = aws_apigatewayv2_api.this.id
  name          = "$default"
  auto_deploy   = true
  deployment_id = aws_apigatewayv2_deployment.this.id

  lifecycle {
    ignore_changes = [deployment_id, default_route_settings]
  }
}

resource "aws_apigatewayv2_route" "this" {
  api_id    = aws_apigatewayv2_api.this.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.this.id}"
}

resource "aws_apigatewayv2_vpc_link" "this" {
  name               = "fine-vpc-link"
  security_group_ids = ["security_group_ids"]
  subnet_ids         = ["subnet_ids"]
}

resource "aws_apigatewayv2_integration" "this" {
  api_id             = aws_apigatewayv2_api.this.id
  integration_type   = "HTTP_PROXY"
  connection_type    = "VPC_LINK"
  connection_id      = aws_apigatewayv2_vpc_link.this.id
  integration_method = "ANY"
  integration_uri    = aws_service_discovery_service.this.arn
}

ECSサービスディスカバリ

resource "aws_service_discovery_private_dns_namespace" "this" {
  name = "private.example.com"
  vpc  = data.aws_vpc.this.id
}

resource "aws_service_discovery_service" "this" {
  name = "great-service-discovery"

  dns_config {
    namespace_id = aws_service_discovery_private_dns_namespace.this.id

    dns_records {
      ttl  = 60
      type = "SRV"
    }

    routing_policy = "MULTIVALUE"
  }

  health_check_custom_config {
    failure_threshold = 1
  }
}

ECSサービスディスカバリを使う場合はdns_recordstype = "SRV"にしてhealth_check_custom_configを設定します。

ECSサービス

resource "aws_ecs_service" "this" {
  中略
  service_registries {
    registry_arn = aws_service_discovery_service.this.arn
    port         = 80
  }
}

service_registriesのブロックで設定します。 タスク定義やサブネットなどは本題ではないので省略しています。

API Gatewayのデフォルトルート

resource "aws_apigatewayv2_deployment" "this" {
  api_id = aws_apigatewayv2_api.this.id

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_apigatewayv2_stage" "this" {
  api_id        = aws_apigatewayv2_api.this.id
  name          = "$default"
  auto_deploy   = true
  deployment_id = aws_apigatewayv2_deployment.this.id

  lifecycle {
    ignore_changes = [deployment_id, default_route_settings]
  }
}

resource "aws_apigatewayv2_route" "this" {
  api_id    = aws_apigatewayv2_api.this.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.this.id}"
}

HTTP APIは$defaultのルートを設定することでリクエストのフルパスをそのまま統合先に送信できます。 ECSと組み合わせる際はデフォルトルートを使うことでシンプルに実装できました。*2

docs.aws.amazon.com

VPCリンク

resource "aws_apigatewayv2_vpc_link" "this" {
  name               = "fine-vpc-link"
  security_group_ids = ["security_group_ids"]
  subnet_ids         = ["subnet_ids"]
}

resource "aws_apigatewayv2_integration" "this" {
  api_id             = aws_apigatewayv2_api.http_api.id
  integration_type   = "HTTP_PROXY"
  connection_type    = "VPC_LINK"
  connection_id      = aws_apigatewayv2_vpc_link.this.id
  integration_method = "ANY"
  integration_uri    = aws_service_discovery_service.this.arn
}

VPCリンクを設定する際はaws_apigatewayv2_integrationintegration_type = "HTTP_PROXY"integration_method = "ANY"にするのが重要です。

まとめ

API Gatewayと聞くとLambdaの印象が強いですが、ECSとの組み合わせでも強力な機能を活用できました。

上記開発中はAWSとTerraformのドキュメントを何度も読み返しながら試行錯誤の連続でしたので、この記事が誰かのお役に立てると幸いです。

*1:IAM認証を使えばIP制限は実現できますが、JWTによる認証と併用できないためアプリケーションの実装量が増えてしまいます。

*2:aws_apigatewayv2_stageでignore_changesを設定しているのはplanするたびに差分が表示されるのを防ぐためです。