AWS CDKを使った公開APIのバージョン管理方法〜実装からGitHub Actionsを使ったデプロイまで〜

AWS CDK を使って Amazon API Gateway と AWS Lambda 等を構成し、API バージョン管理を行う方法を解説します!
2023.03.25

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

1. はじめに

お疲れさまです。とーちです。
API を公開する際は色々と気を使う点があるかと思いますが、その中でも API バージョンをどのように切り替えるかは重要な点かと思います。本記事では、AWS CDK を使って Amazon API Gateway(以下 API Gateway) と AWS Lambda(以下 Lambda) 等を構成し、API バージョン管理を行う方法を解説します。また、デプロイ自動化に GitHub Actions を使った例も紹介します。

2. API バージョン管理の一般的なアプローチ

ここでのバージョン管理とは API の変更・アップデートをどのように管理し、API を利用するエンドユーザーにどのような形でバージョンを提供するかを指します。バージョン管理方法を後から変更するのは大変なので設計の段階で決めておくことを推奨します。一般的なバージョン管理のアプローチには、以下のような方法があります。

  • URI にバージョンを記載:エンドユーザ側で URI 切り替えによりバージョンを更新させる。
    • 例:https://example.com/products/v1
  • クエリ文字列でバージョンを指定:エンドユーザ側でクエリ文字列の切り替えによりバージョンを更新させる。
    • 例:https://example.com/products?api-version=v1
  • HTTP ヘッダでバージョン指定:エンドユーザ側で Accept ヘッダ等の HTTP ヘッダに指定する文字列の切り替えによりバージョンを更新させる。Vary ヘッダをレスポンスとしてつけるのが通例
    • 例(リクエストヘッダ):Accept: application/vnd.appv1+json

上記の中で比較的一般的に使用される方法は、URI にバージョンを記載する方法です。この方法はエンドユーザーがどのバージョンの API を使用しているかが明確でわかりやすいというメリットがあります。本記事でも「URI にバージョンを記載する方法」にフォーカスしてご紹介していきます。

3. システム構成の説明

本記事で扱うシステム構成は、以下のようなアーキテクチャです。

API Gateway は本記事では HTTP API での使用を想定しています。API Gateway では複数のバージョンに対応するために、バージョンごとに ステージ を作成します。 また Lambda はバージョン発行をすることで複数バージョンを保持します。それぞれの ステージ に Lambda のバージョンを紐付けることで公開 API のバージョン管理を実現します。
なお、Amazon CloudFront(以下 CloudFront)は本構成では必須ではないですが、特に API Gateway の HTTP API の場合は CloudFront を挟むことで AWS WAF を通すことも出来るようになるので、必要に応じて使用するようにして頂ければと思います。

4. CDK を用いた API バージョン切り替えの実装

4.1 サンプルコードと実装手順の説明

CDK で実装する場合のコードは以下の通りとなります。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as cforigins from "aws-cdk-lib/aws-cloudfront-origins";
import * as apigw from "aws-cdk-lib/aws-apigatewayv2";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";

export interface ApigwStageProps {
  stageName: string;
  stageVariables: { [key: string]: any };
}
export class CdkApigwStack extends cdk.Stack {
  private stagev1: ApigwStageProps = {
    stageName: "v1",
    stageVariables: {
     ["lambdaVersion"]: "1",
    },
  };
  private stagev2: ApigwStageProps = {
    stageName: "v2",
    stageVariables: {
     ["lambdaVersion"]: "2",
    },
  };
// 使用する github リポジトリに応じて以下書き換え
private gitHubOwner: string = "ice1203";
private repositoryName: string = "cdk-apigwV2-retainVersion";

constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);

    // IAM Role for githubActions
    // IAM OIDC Providerがない場合は以下のコメントを外して作成
    /*const oidcprovider = new iam.OpenIdConnectProvider(this, "OIDCProvider", {
      url: "https://token.actions.githubusercontent.com",
      thumbprints: ["6938fd4d98bab03faadb97b34396831e3780aea1"],
      clientIds: ["sts.amazonaws.com"],
    });*/
    // IAM Role
    const roleForGithubAction = new iam.Role(this, "RoleForGithubAction", {
      roleName: "my-githubactions-role",
      assumedBy: new iam.WebIdentityPrincipal(
        `arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
        {
          StringEquals: {
            ["token.actions.githubusercontent.com:aud"]: "sts.amazonaws.com",
          },
          StringLike: {
            ["token.actions.githubusercontent.com:sub"]: `repo:${this.gitHubOwner}/${this.repositoryName}:*`,
          },
        }
      ),
    });
    // 検証目的のためAdmin権限、実際の運用では最小権限を推奨
    roleForGithubAction.addManagedPolicy(
      iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess")
    );
    // Lambda function
    const myLambda = new lambda.Function(this, "MyLambda", {
      runtime: lambda.Runtime.PYTHON_3_9,
      handler: "index.handler",
      code: lambda.Code.fromAsset("lambda_src"),
      currentVersionOptions: {
        removalPolicy: cdk.RemovalPolicy.RETAIN,
      },
    });
    // Lambdaにバージョン発行させるためにcurrentVersionプロパティを使用する必要がある
    myLambda.currentVersion;

    // create an HTTP API
    const httpApi = new apigw.CfnApi(this, "HttpApi", {
      name: "HttpApi",
      protocolType: "HTTP",
    });

    // add routes and integrations
    const apigwRole = new iam.Role(this, "apigwRole", {
      assumedBy: new iam.ServicePrincipal("apigateway.amazonaws.com"),
      inlinePolicies: {
        ["InvokeFunction"]: new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              actions: ["lambda:InvokeFunction"],
              effect: iam.Effect.ALLOW,
              resources: [`${myLambda.functionArn}:*`],
            }),
          ],
        }),
      },
    });
    const usersIntegration = new apigw.CfnIntegration(
      this,
      "usersIntegration",
      {
        apiId: httpApi.ref,
        integrationType: "AWS_PROXY",
        integrationUri:
          myLambda.functionArn + ":${stageVariables.lambdaVersion}",
        payloadFormatVersion: "2.0",
        credentialsArn: apigwRole.roleArn,
      }
    );

    const usersRoute = new apigw.CfnRoute(this, "usersRoute", {
      apiId: httpApi.ref,
      routeKey: "GET /users",
      target: `integrations/${usersIntegration.ref}`,
    });

    // add stage
    const apigwStageV1 = new apigw.CfnStage(this, "apigwStageV1", {
      apiId: httpApi.ref,
      stageName: this.stagev1.stageName,
      autoDeploy: true,
      stageVariables: this.stagev1.stageVariables,
    });
    const apigwStageV2 = new apigw.CfnStage(this, "apigwStageV2", {
      apiId: httpApi.ref,
      stageName: this.stagev2.stageName,
      autoDeploy: true,
      stageVariables: this.stagev2.stageVariables,
    });

    // CloudFront distribution
    const myDistribution = new cloudfront.Distribution(this, "MyDistribution", {
      defaultRootObject: "index.html",
      defaultBehavior: {
        origin: new cforigins.HttpOrigin(
          `${httpApi.attrApiId}.execute-api.${this.region}.${this.urlSuffix}`
        ),
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
        // AllViewerExceptHostHeaderのポリシーを設定
        // HostヘッダをOriginにわたすとそのHost名の証明書を要求しようとするので「SignatureDoesNotMatch」となるため
        originRequestPolicy:
          cloudfront.OriginRequestPolicy.fromOriginRequestPolicyId(
            this,
            "orpExceptHost",
            "b689b0a8-53d0-40ab-baf2-68738e2966ac"
          ),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
    });

    // Output
    new cdk.CfnOutput(this, "ApiUrl", {
      value: httpApi.attrApiEndpoint ?? "Something went wrong with the deploy",
    });
    new cdk.CfnOutput(this, "roleForGithubActionArn", {
      value: roleForGithubAction.roleArn,
    });

}
}

なおコード全文は以下のリポジトリに格納しています。

以下ポイントとなる部分を説明します。

Lambda のバージョン発行

以下の部分で Lambda 作成 及び Lambda のバージョン発行をしています。 currentVersionOptions で過去のバージョンを残すかどうかを指定できます。また、myLambda.currentVersion プロパティを使用することで Lambda のコードに変更がある場合にのみ Lambda のバージョンが発行されます。

Lambda のバージョン発行はVersionというコンストラクトを使用することでも可能なのですが、リファレンスにも記載のある通り、このコンストラクトは直接使用せず、上記のcurrentVersion を使うようにしましょう。私は最初この記載に気づかず、Version コンストラクトを使っていたのですが、Version コンストラクトでバージョンを発行しようとすると、デプロイごとにバージョンの CloudFormation リソースの論理 ID を変更する必要があったり、Lambda のコード変更を検知する必要があるなど、非常に手間がかかります。

    // Lambda function
    const myLambda = new lambda.Function(this, "MyLambda", {
      runtime: lambda.Runtime.PYTHON_3_9,
      handler: "index.handler",
      code: lambda.Code.fromAsset("lambda_src"),
      currentVersionOptions: {
        removalPolicy: cdk.RemovalPolicy.RETAIN,
      },
    });
    // Lambdaにバージョン発行させるためにcurrentVersionプロパティを使用する必要がある
    myLambda.currentVersion;

API Gateway での Lambda の指定方法

以下の部分で API Gateway で呼び出す Lambda の指定(Lambda 統合の作成)を行っています。
integrationUri の部分で Lambda の Arn を指定するのですが、Lambda のバージョンまで指定する場合は通常の Lambda の Arn の末尾に :<バージョン番号>の記載が必要になります。
今回のコードではこのバージョン番号の部分に API Gateway のステージ変数を使用しています。このようにすることでステージ変数の変更のみで異なるバージョンの Lambda を呼び出すことができます。

またこの Lambda 統合では Lambda 呼び出しに使用する IAM ロールを指定することができます。通常 API Gateway と Lambda の統合では Lambda 側のリソースベースポリシーにより API Gateway からの Lambda 呼び出しを許可することが多いかと思いますが、今回の構成では Lambda のバージョンを発行するのでリソースベースポリシーで実現するとなると Lambda バージョン毎にポリシーを設定する必要が出てきます。それを避けるため、API Gateway 側に 指定した 一つの IAM ロールに対し全てのバージョンの呼び出し許可を与えています。

    // add routes and integrations
    const apigwRole = new iam.Role(this, "apigwRole", {
      assumedBy: new iam.ServicePrincipal("apigateway.amazonaws.com"),
      inlinePolicies: {
        ["InvokeFunction"]: new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              actions: ["lambda:InvokeFunction"],
              effect: iam.Effect.ALLOW,
              resources: [`${myLambda.functionArn}:*`],
            }),
          ],
        }),
      },
    });
    const usersIntegration = new apigw.CfnIntegration(
      this,
      "usersIntegration",
      {
        apiId: httpApi.ref,
        integrationType: "AWS_PROXY",
        integrationUri:
          myLambda.functionArn + ":${stageVariables.lambdaVersion}",
        payloadFormatVersion: "2.0",
        credentialsArn: apigwRole.roleArn,
      }
    );

4.2 CDK での API バージョン切り替え手順

API のメジャーバージョンを新たに発行する際は上記コードの以下の内容を修正してデプロイするだけです。
新しいメジャーバージョンを発行しても古いバージョンはそのまま残っているので API を使用するエンドユーザーに対して API バージョン移行のための期間を設けた上で移行してもらうことができます。

  • Lambda のコード内容を変更(変更することで自動でバージョン発行)
  • API Gateway の新しいステージを作成しステージ変数に最新の Lambda バージョン番号を指定

5. GitHub Actions を利用したデプロイ

5.1 GitHub Actions の紹介

GitHub Actions は、GitHub リポジトリ内で CI/CD(継続インテグレーション/継続デリバリー)ワークフローを簡単に自動化できる機能です。これにより、コードの変更やプルリクエストのマージなどのイベントに応じて、テストやデプロイなどのタスクを自動実行することができます。

5.2 GitHub Actions を使ったデプロイの設定

GitHub Actions 用 IAM ロール

GitHub Actions を使って上記の AWS 環境にリソースを作成するわけですが、AWS 環境にリソースを作成するには当然 IAM 権限が必要になります。

GitHub Actions では IAM ロールを指定して AWS 環境へのデプロイを実施することが出来るので、CDK コード の以下の部分で GitHub Actions にてデプロイする際に使用する IAM ロールを作成しています。
具体的な仕組みとしては OpenID Connect(OIDC) を使って IAM ロールへのフェデレーションを行うことで IAM ロールを引き受けています。そのため IAM ロールには GitHub(https://token.actions.githubusercontent.com) を OIDC の ID プロバイダ(IdP)として信頼するよう設定する必要があります。IAM OIDC Provider は信頼する IdP を登録するためのリソースです。作成した IAM OIDC Provider を IAM ロール内で指定することで、上記の信頼設定が実現できます。
また StringEquals 等で条件を指定していますが、こちらは GitHub から受け取った ID token を検査するための条件となっており、ID token 内の aud 及び sub クレームが指定の値になっていることを確認しています。
sub と aud の値にどういったものが入るかについては以下を参考にしてください。

aws-actions/configure-aws-credentials
OpenID Connect によるセキュリティ強化について - GitHub Docs

    // IAM Role for githubActions
    // IAM OIDC Providerがない場合は以下のコメントを外して作成
    /*const oidcprovider = new iam.OpenIdConnectProvider(this, "OIDCProvider", {
      url: "https://token.actions.githubusercontent.com",
      thumbprints: ["6938fd4d98bab03faadb97b34396831e3780aea1"],
      clientIds: ["sts.amazonaws.com"],
    });*/
    // IAM Role
    const roleForGithubAction = new iam.Role(this, "RoleForGithubAction", {
      roleName: "my-githubactions-role",
      assumedBy: new iam.WebIdentityPrincipal(
        `arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
        {
          StringEquals: {
            ["token.actions.githubusercontent.com:aud"]: "sts.amazonaws.com",
          },
          StringLike: {
            ["token.actions.githubusercontent.com:sub"]: `repo:${this.gitHubOwner}/${this.repositoryName}:*`,
          },
        }
      ),
    });

CDK 内で GitHub Actions 用の IAM ロールを作成しているので初回のデプロイは端末等から手動で行う必要がある点にご注意ください。

GitHub Actions ワークフローファイルの書き方

GitHub Actions を使って AWS CDK をデプロイするためには、デプロイの手順を記したワークフローファイルが必要になります。 ワークフローファイルは以下の流れで作成します。

  1. GitHub リポジトリのルートに.github/workflowsディレクトリを作成します。
  2. そのディレクトリ内に、デプロイ用のワークフローファイル(例:action.yml)を作成します。
  3. secrets に AWS_ACCOUNT_ID という名前のシークレットを作成し値としてデプロイ対象の AWS アカウント ID を入れます(詳細は暗号化されたシークレット - GitHub Docsを参照)
  4. ワークフローファイルに、以下のような設定を記述します。
name: GitHub Actions Demo

on:
  pull_request:
    branches:
      - main
  push:
    branches:
      - main

jobs:
  deploy:
    name: cdk deploy
    runs-on: ubuntu-latest
    # These permissions are needed to interact with GitHub's OIDC Token endpoint.
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/my-githubactions-role
          aws-region: ap-northeast-1
      - name: Setup Node
        uses: actions/setup-node@v1
        with:
          node-version: "16"

      - name: CDK package install
        run: npm ci

      - name: CDK Diff Check
        if: contains(github.event_name, 'pull_request')
        run: |
          npm run cdk diff
      - name: CDK Deploy
        if: contains(github.event_name, 'push')
        run: |
          npm run cdk deploy

簡単に上記の処理内容について説明したいと思います。

  1. ワークフロー名 (name: GitHub Actions Demo): ワークフローの名前を設定しています。
  2. トリガー (on): プルリクエストが main ブランチに作成された場合や、main ブランチへのプッシュが行われた場合にワークフローが実行されます。
  3. ジョブ (jobs): ワークフロー内で実行されるジョブを定義しており、名前はcdk deployに設定されています。
  4. 実行環境 (runs-on: ubuntu-latest): ジョブの実行環境を指定します。ubuntu-latest なので、最新の Ubuntu を指定しています。
  5. 権限 (permissions): 必要な権限を設定して、GitHub の OIDC トークンエンドポイントとやり取りできるようにしています。
  6. ステップ (steps): リポジトリのチェックアウト、AWS 認証情報の設定、Node.js 環境のセットアップなど、ジョブ内で実行されるステップを定義しています。
  7. パッケージのインストール (CDK package install): CDK パッケージをインストールするためにnpm ciを実行しています。
  8. 差分チェック (CDK Diff Check): プルリクエストがトリガーとなった場合、CDK の差分チェックを実行します。
  9. デプロイ (CDK Deploy): プッシュがトリガーとなった場合、CDK デプロイを実行します。

GitHub Actions を使ったデプロイ方法

デプロイは非常に簡単で以下のステップを踏むだけとなっています。

  1. コードをプッシュ: ローカルリポジトリから GitHub リポジトリの<適当なブランチ名>にコードをプッシュします。
  2. プルリクエストを作成: GitHub リポジトリ上で、<適当なブランチ名>からmainブランチへのプルリクエストを作成します。ワークフローによりcdk diff実行されます。
  3. プルリクエストのレビューとマージ: プルリクエストがレビューされ、問題がなければmainブランチにマージされます。ワークフローによりcdk deploy実行されます。
  4. デプロイの確認: デプロイが正常に完了したかどうかを AWS Management Console やアプリケーションの URL で確認します。

6. まとめ

以上となります。 本記事では、AWS CDK を使って API Gateway と Lambda を構成し、API バージョン管理を行う方法を解説しました。また、GitHub Actions を使ってデプロイプロセスを自動化する方法も紹介しました。 GitHub Actions はあまり触ったことがなかったのですが、手軽につかえて素晴らしいですね。これからもどんどん活用していこうと思いました。

この記事が誰かのお役に立てばなによりです。
以上、とーちでした。