🙆‍♂️

【SPA】AWS SAMで構築したAPI GatewayのリソースをTerraformで参照する

2025/01/05に公開

はじめに

自己学習のため、以下構成で簡単なSPA(シングルページアプリケーション)のTodoアプリを作成しました。フロントエンドはReact(TypeScript)、バックエンドはPythonです。

構成図

インフラ部分は全てTerraformを使って構築することも可能ですが、学習のため今回はあえてバックエンド部分のみSAMを利用して構築を行いました。

SAMを使うとサーバーレスなAWSサービス(API Gateway,Lambda,DynamoDB)をより少ないコードで構築できるなど様々なメリットがあります。

ただし部分的にSAMを利用するとTerraformで管理しているリソースと連携させたいケースが出てきます。

今回は例として、SAMで構築したAPI GatewayのリソースをTerraformで参照する方法を紹介します。

TL;DR

ポイントは以下2点です。CloudFormationのクロススタック参照先をTerraformにするイメージです。

  • SAMテンプレート側のOutputsセクションにValueとExportフィールドを定義する
  • Terraform側でaws_cloudformation_exportのdataリソースを利用して参照する

今回はAPI Gatewayのみを参照していますが、SAM側でOutputの記述を行うことで他リソースを参照させることも可能です。

ディレクトリ構成

ディレクトリ構成は以下です。(一部省略)
terraformはAWSサービス単位でmoduleを分割しています。

├── backend
│   ├── src           
│   ├── tests
│   ├── __init__.py
│   ├── samconfig.toml
│   └── template.yaml <- SAMテンプレートはここ
├── frontend
│   └── (省略)
└── terraform
    ├── main.tf
    ├── modules
    │   ├── route53
    │   │   ├── variables.tf
    │   │   ├── output.tf
    │   │   └── route53.tf <- dataリソースは今回はここに書く
    │   ├── acm
    │   │   ├── variables.tf
    │   │   ├── output.tf
    │   │   └── acm.tf
    │   └── (省略)
    └── variables.tf

SAM側でAPI Gatewayの構築と出力値の定義を行う

SAMテンプレートではAPI Gatewayのエンドポイント名を出力値として定義します。

SAMはCloudFormationがベースとなっているので、テンプレートのOutputsセクションに記述することでCloudFormationのスタックの出力として値が出力されます。

Exportフィールドには任意の名前を付けます。エクスポート名が一意になっていれば任意で問題ありません。

SAMテンプレートの例

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  backend

Globals:
  Function:
    Timeout: 3
  Api:
    OpenApiVersion: 3.0.2

Parameters:
  StageName:
    Type: String
    Default: Prod

Resources:
(LambdaとDynamoDBの記述は省略)
  ### API Gateway ###
  ToDoApi:
    Type: AWS::Serverless::Api
    Properties:
      Name: ToDoApi
      StageName: !Ref StageName
      EndpointConfiguration: REGIONAL
      Cors:
        AllowOrigin: "'*'"
        AllowHeaders: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'"
        AllowMethods: "'GET,POST,DELETE,OPTIONS'"

Outputs:
  ApiGatewayRestApiId:
    Description: "ID of the API Gateway"
    Value: !Sub ${ToDoApi}
    Export:
      Name: ApiGatewayRestApiId <- 任意の名前でOK

  ApiGatewayStageName:
    Description: "API Gateway Stage Name"
    Value: !Ref StageName
    Export:
      Name: ApiGatewayStageName <- 任意の名前でOK

SAMのデプロイ

以下手順でSAMのデプロイを行います。なお、構文が正しいかどうかはsam validateコマンドを利用すると素早くトラブルシュートできるので活用しましょう。

ビルド
$ sam build
Build Succeededと出力されればOK

デプロイ
$ sam deploy
Successfully created/updated stack - {stack_name} in ap-northeast-1と出力されればOK

コンソール上で見る出力例

Terraform側で値を参照する

SAMのCloudFormationスタックの出力値を、Terraformのdataリソースで参照します。

例えば、API Gatewayのカスタムドメインを設定する場合を記載します。
route53.tfに書いていますが、apigatewayの部分なので別moduleに分けて記述しても問題ないです。

※ドメインはTerraform側で管理する前提です。TerraformとSAMを併用する場合、どちらで何を管理するかをうまく設計する必要があります。

参照例

route53.tf
resource "aws_api_gateway_domain_name" "api_name" {
  domain_name = "api.${var.common.domain}" <- 取得したドメインを変数で宣言しています
  regional_certificate_arn = var.acm.cert_tokyo.arn
  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

data "aws_cloudformation_export" "api_id" {
  name = "ApiGatewayRestApiId" # SAMテンプレートで記載したExport名に合わせる
}

data "aws_cloudformation_export" "api_stage_name" {
  name = "ApiGatewayStageName" # SAMテンプレートで記載したExport名に合わせる
}

resource "aws_api_gateway_base_path_mapping" "api" {
  domain_name = aws_api_gateway_domain_name.api_name.domain_name
  ### ここで参照する ###
  api_id      = data.aws_cloudformation_export.api_id.value
  ### ここで参照する ###
  stage_name  = data.aws_cloudformation_export.api_stage_name.value
}

resource "aws_route53_record" "api" {
  zone_id = aws_route53_zone.public.zone_id
  name    = aws_api_gateway_domain_name.api_name.domain_name
  type    = "A"

  alias {
    name                   = aws_api_gateway_domain_name.api_name.regional_domain_name
    zone_id                = aws_api_gateway_domain_name.api_name.regional_zone_id
    evaluate_target_health = false
  }
}

なお証明書作成は以下リソースで実施しています。(module間の変数渡しの記述は省略)

acm.tf
resource "aws_acm_certificate" "main_tokyo" {
  domain_name = "*.${var.common.domain}"
  subject_alternative_names = [
    "*.${var.common.domain}"
  ]
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "cert_validation_tokyo" {
  depends_on      = [ aws_acm_certificate.main_tokyo ]
  allow_overwrite = true

  zone_id = var.route53.public_zone.zone_id
  name    = tolist(aws_acm_certificate.main_tokyo.domain_validation_options)[0].resource_record_name
  type    = tolist(aws_acm_certificate.main_tokyo.domain_validation_options)[0].resource_record_type
  records = [tolist(aws_acm_certificate.main_tokyo.domain_validation_options)[0].resource_record_value]
  ttl     = 60
}

resource "aws_acm_certificate_validation" "main_tokyo" {
  certificate_arn = aws_acm_certificate.main_tokyo.arn
  validation_record_fqdns = [aws_route53_record.cert_validation.fqdn]
}

terraform applyの実施

terraform applyを実行しリソースを作成します。

以下はplan結果の抜粋です。

$ terraform plan
(省略)
  # module.route53.aws_api_gateway_base_path_mapping.api will be created
  + resource "aws_api_gateway_base_path_mapping" "api" {
      + api_id      = "a1b2c3d4e5"
      + domain_name = "api.example.com"
      + id          = (known after apply)
      + stage_name  = "Prod"
    }

  # module.route53.aws_api_gateway_domain_name.api_name will be created
  + resource "aws_api_gateway_domain_name" "api_name" {
      + arn                                    = (known after apply)
      + certificate_upload_date                = (known after apply)
      + cloudfront_domain_name                 = (known after apply)
      + cloudfront_zone_id                     = (known after apply)
      + domain_name                            = "api.example.com"
      + domain_name_id                         = (known after apply)
      + id                                     = (known after apply)
      + ownership_verification_certificate_arn = (known after apply)
      + regional_certificate_arn               = "arn:aws:acm:ap-northeast-1:123456789012:certificate/12345678-1234-5678-9012-123456789012"
      + regional_domain_name                   = (known after apply)
      + regional_zone_id                       = (known after apply)
      + security_policy                        = (known after apply)
      + tags_all                               = (known after apply)

      + endpoint_configuration (known after apply)
    }

  # module.route53.aws_route53_record.api will be created
  + resource "aws_route53_record" "api" {
      + allow_overwrite = (known after apply)
      + fqdn            = (known after apply)
      + id              = (known after apply)
      + name            = "api.example.com"
      + type            = "A"
      + zone_id         = "Z01234567890123456789"

      + alias {
          + evaluate_target_health = false
          + name                   = "api.example.com"
          + zone_id                = "Z01234567890123456789"
        }
    }

apply後、カスタムドメインができていればOKです。

まとめ

今回は、SAMで構築したリソースをTerraformで参照する方法を解説しました。

注意点として、SAMのExport名を変更した場合、Terraform側も変更しないといけない点です。
また、連携するリソースが増えるたびにdataリソースが増えるため、ファイルを分割するなど管理を工夫も必要です。

さらに、この構成ではTerraformがSAMに依存しているため、デプロイの順番にも注意が必要です。
実運用では、CI/CDパイプラインを構築し、SAMのビルドからTerraformの適用までを自動化するのが望ましいでしょう。

AWS SAMは冒頭言及した通り、非常に少ないコードでサーバーレスアーキテクチャに必要なリソースを簡単に作成することができます。
特にIAM権限周りの設定が抽象化されており、例えばLambdaで必要なIAMロールやアクセス権限を詳細に記述しなくても、自動的に適切な設定を作成してくれる点は嬉しいですね。

一方で、APIの構築に専念したい、またはLambdaコードの管理を簡潔にしたい場合はSAMを併用するのも良いですが、Webアプリや規模の大きいアプリでは、すべてをTerraformで一元管理するのもアリです。

繰り返しですがSAMとTerraformを併用する場合は、どちらのツールで何を管理するかを明確に設計することが重要です。要件や方針に合わせて設計することを推奨します。

Discussion