OpenAPIでAPI Gatewayを構築する運用スタイルを試してみた話

記事タイトルとURLをコピーする

記事を書いた背景

API Gatewayの構築をもっとラクにしたい

私はAWSインフラの構築・運用を担当しています。API Gatewayはアプリケーション側の担当者が使用することが多く、仕様の変更も基本的にはアプリ担当者から発生します。

API Gatewayを定義しようとすると、「エンドポイントの構成は?メソッドは?統合先はどのLambda?」といった情報をアプリ担当から逐一ヒアリングして、リソースに反映する必要があります。 これが非常に手間です。

本来であれば、アプリ担当者が自分でAPI Gatewayを構築してくれるのが理想です。 実際、アプリ担当者もAWSにログインできる権限は持っているのですが、現実はそう単純ではありません。

なぜアプリ担当に好きにAPI Gatewayを構築させてあげないのか

現在運用中のAWSアカウントには、 複数の他プロジェクトのリソースが同居 しており、リソースを誤って作成・変更されると、アカウント全体に思わぬ影響を及ぼしかねません。

アプリ担当者用に構築専用のIAM Roleを作成して操作範囲を制限する方法もありますが、 アプリ担当者やプロジェクトが増えるたびにロール設計やポリシー調整を繰り返す のは、あまりスマートな運用とは言えません。

さらに言えば、アプリ担当者全員がAWS CDKやAWS SAMのようなIaCツールを扱えるとは限りません。 特に、フロントエンド中心の開発者やAPIの設計に注力しているチームでは、IaCの知識を求めること自体が負担になるケースもあります。

そのため、アプリ担当者とインフラ担当者間のAPIの仕様をより簡単に受け渡す手法がないか検討しました。 そこで私たちは、「API定義はOpenAPIファイルにしてもらい、それをCDK経由でAWSに読み込ませる」という方針にたどり着きました。

OpenAPIとは?

OpenAPIは、REST APIのエンドポイントやリクエスト・レスポンスの構造、HTTPメソッドなどを定義する標準的な仕様フォーマットで、もともとはSwaggerとして知られていました。

JSONまたはYAML形式で記述でき、開発・設計・テスト・ドキュメント生成など、さまざまなフェーズで活用できます。

API GatewayはOpenAPI + 拡張に対応している

AWSのAPI GatewayはこのOpenAPI仕様をベースにAPIを構築できるようになっており、さらに x-amazon-apigateway-* というAWS独自の拡張フィールドを通じて、Lambdaとの統合やステージ設定、バイナリメディアタイプ、Gatewayレスポンスなどの詳細な制御も可能です。

そのため、OpenAPIファイルにAPI Gatewayの構成・リソースパス・統合先Lambda・レスポンス仕様などをすべて記述しておけば、仕様書でありながらそのまま構築定義にもなる、いわば「手順書付きのAPI仕様」として活用できます

API定義はアプリ担当者がOpenAPI形式で記述し、インフラ担当者がCDKで読み込むという責任分離の体制が整いました。 では、実際の環境(DEV / STG / PRD)において、この運用方針がどのように展開されていくのかを、フローとして見てみましょう。

運用フローのイメージ

環境 担当者 作業内容
DEV アプリ担当 マネジメントコンソールでAPI Gatewayを構築
OpenAPIとしてエクスポート
- インフラ担当 エクスポートされたOpenAPIファイルをGitなどで受け取る
OpenAPIファイル内のARNなどの固有値を実環境の値に置換するプレースホルダに修正
STG インフラ担当
アプリ担当
STG環境にCDKデプロイを行い、アプリの動作確認を行う
PRD インフラ担当
アプリ担当
PRD環境にCDKデプロイを行い、アプリの動作確認を行う

イメージ図

IaCへの取り込み:CDK + SpecRestApi の構成

アプリ担当が作成したOpenAPIファイルをSTGやPRD環境に展開する際は、私たちインフラ担当者がCDKを使ってAPI GatewayをIaCとして定義・デプロイします。

ここで活躍するのが、CDKに用意されている SpecRestApi クラスです。

SpecRestApiとは?

aws-apigateway.SpecRestApi は、OpenAPI仕様のファイルをもとにCDKでAPI Gatewayを構築できるクラスです。

通常の RestApi ではCDK上でパスや統合先をコードで書く必要がありますが、SpecRestApi を使えば OpenAPIファイルをインラインで読み込むだけで済みます。

CDKで SpecRestApiを使ってOpenAPIを読み込み、API Gatewayを構成します。 また、SpecRestApiのオプションの「RestApiMode.MERGE」 を指定することで、OpenAPIの仕様に加えて、CDKで定義したAPI仕様の記載とマージも可能です。 

※本記事ではOpenAPIファイルの読み込みだけで作成しますが、インフラの都合でAPIGatewayに手を加えるといったシーンで使えるかな?と思います。

実装例:hello1 / hello2 Lambdaを使ったOpenAPIインポート

今回のサンプルでは、2つのLambda関数 hello1 と hello2 を用意し、それぞれ /hello1 /hello2 のエンドポイントに対応させています。

OpenAPIファイル(openapi.json)では、統合先のLambda関数を {hello1Lambda} {hello2Lambda} というプレースホルダで記述しておき、CDK側で実際のARNに置き換えます。

openapi.json

{
  "openapi": "3.0.1",
  "servers": [
    {
      "variables": {
        "basePath": {
          "default": "prod"
        }
      }
    }
  ],
  "paths": {
    "/hello1": {
      "get": {
        "x-amazon-apigateway-integration": {
          "httpMethod": "POST",
          "uri": "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/{hello1Lambda}/invocations",
          "passthroughBehavior": "when_no_match",
          "type": "aws_proxy"
        }
      }
    },
    "/hello2": {
      "get": {
        "x-amazon-apigateway-integration": {
          "httpMethod": "POST",
          "uri": "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/{hello2Lambda}/invocations",
          "passthroughBehavior": "when_no_match",
          "type": "aws_proxy"
        }
      }
    }
  },
  "components": {},
  "x-amazon-apigateway-gateway-responses": {
    "DEFAULT_4XX": {
      "responseParameters": {
        "gatewayresponse.header.Content-Type": "'application/json'"
      },
      "responseTemplates": {
        "application/json": "{\"message\":\"GWレスポンスのテスト - 4XXエラー\"}"
      }
    },
    "DEFAULT_5XX": {
      "responseParameters": {
        "gatewayresponse.header.Content-Type": "'application/json'"
      },
      "responseTemplates": {
        "application/json": "{\"message\":\"GWレスポンスのテスト - 5XXエラー\"}"
      }
    }
  },
  "x-amazon-apigateway-binary-media-types": [
    "text/csv"
  ]
}

oapigw-stack.ts

import * as fs from 'fs';
import * as path from 'path';
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigw from 'aws-cdk-lib/aws-apigateway';
import * as iam from 'aws-cdk-lib/aws-iam';

export class OapigwStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // hello1 Lambda
    const hello1Lambda = new lambda.Function(this, 'hello1', {
      runtime: lambda.Runtime.NODEJS_LATEST,
      handler: 'index.handler',
      code: lambda.Code.fromInline(`
        exports.handler = async () => {
          return {
            statusCode: 200,
            body: JSON.stringify({ message: "HELLO1" })
          };
        };
      `),
    });

    // hello2 Lambda
    const hello2Lambda = new lambda.Function(this, 'hello2', {
      runtime: lambda.Runtime.NODEJS_LATEST,
      handler: 'index.handler',
      code: lambda.Code.fromInline(`
        exports.handler = async () => {
          return {
            statusCode: 200,
            body: JSON.stringify({ message: "HELLO2" })
          };
        };
      `),
    });

    // OpenAPI ファイルの読み込み
    const openApiPath = path.join(__dirname, 'openapi.json');
    let openApiSpec = JSON.parse(fs.readFileSync(openApiPath, 'utf8'));

    // ARN置換
    const replacements: Record<string, string> = {
      '{hello1Lambda}': hello1Lambda.functionArn,
      '{hello2Lambda}': hello2Lambda.functionArn,
    };

    let openApiSpecString = JSON.stringify(openApiSpec);
    for (const [placeholder, arn] of Object.entries(replacements)) {
      openApiSpecString = openApiSpecString.replace(new RegExp(placeholder, 'g'), arn);
    }
    openApiSpec = JSON.parse(openApiSpecString);

    // API Gateway を構築
    const api = new apigw.SpecRestApi(this, 'Api', {
      restApiName: 'Test API',
      deploy: true,
      mode: apigw.RestApiMode.MERGE,
      apiDefinition: apigw.ApiDefinition.fromInline(openApiSpec),
    });

    // Lambda 実行権限を追加
    [hello1Lambda, hello2Lambda].forEach((fn) => {
      fn.addPermission(`ApiGatewayInvoke-${fn.node.id}-${api.restApiName}`, {
        principal: new iam.ServicePrincipal("apigateway.amazonaws.com"),
        sourceArn: api.arnForExecuteApi(),
      });
    });
  }
}

このように、テンプレート化されたOpenAPIファイルに対して、CDKでプレースホルダ置換を行うことで、環境に応じたLambda統合が実現できます。

リソースベースのポリシーステートメントの付与に注意

SpecRestApi を使って Lambda を統合した場合、API Gateway から Lambda を呼び出すための権限 は自動では付きません。

具体的にはLambdaが呼び出し元を制御するリソースベースポリシーの定義です。GUIの画面だと以下の赤枠の部分が該当します。

通常ApiGatewayの画面から手動でLambdaを紐づけると、リソースベースポリシーはAWSが自動で追加してくれますが、OpenAPIの読み込みだと付与されません。

そのため、CDK 側で明示的に addPermission() を使って Lambda に実行許可を付与する必要があります。

    // Lambda 実行権限を追加のコード例
    [hello1Lambda, hello2Lambda].forEach((fn) => {
      fn.addPermission(`ApiGatewayInvoke-${fn.node.id}-${api.restApiName}`, {
        principal: new iam.ServicePrincipal("apigateway.amazonaws.com"),
        sourceArn: api.arnForExecuteApi(),
      });
    });

しかし上記コードの api.arnForExecuteApi() を使用すると、/prod/get/hello1(2)Lambdaに対する権限としてAPI Gateway 全体( /*/*/* ) の「全ステージ、全メソッド、全パスから実行を許可する」、という意味合いのポリシーがLambdaに付与されます。これは実行権限としては広すぎな設定となります。

仮に広すぎな権限設定を許容したとしても...

許可対象のARNにapi.arnForExecuteApi(),と書くと「全ステージ、全メソッド、全パスからも実行を許す」というポリシーが付与されるため、全てのLambdaが実行できるように思えますが、Lambdaオーソライザーの場合は別途設定が必要です。

APIGatewayから Lambdaオーソライザー を実行する場合、リソースベースポリシーに記載する許可設定は「/authorizers/パス名 」となります。 そのため、arnForExecuteApi()だけではカバーすることができず、許可範囲を /*/* の2階層目で留まるポリシーが必要となります。

そのため、オーソライザーを使用する際は以下は個別の設定が必要となります。

// オーソライザー用のARN指定の例:
sourceArn = `arn:aws:execute-api:${this.region}:${this.account}:${apiGw.restApiId}/authorizers/*`;

理想的な設定

セキュリティを最優先する場合、API Gateway から Lambda を呼び出す際の実行権限(lambda:InvokeFunction)は、対象となる API のメソッド・パスを明示的に指定して絞り込むべきです。

たとえば以下のように、ステージ名・HTTPメソッド・パスを指定して sourceArn を構成します:

    hello1Lambda.addPermission('InvokeSpecificPath', {
      principal: new iam.ServicePrincipal("apigateway.amazonaws.com"),
      sourceArn: cdk.Arn.format({
        service: 'execute-api',
        resource: api.restApiId,
        resourceName: `prod/GET/hello1`,
        arnFormat: cdk.ArnFormat.SLASH_RESOURCE_NAME,
        region: this.region,
        account: this.account,
      }, this),
    });

    hello2Lambda.addPermission('InvokeSpecificPath', {
      principal: new iam.ServicePrincipal("apigateway.amazonaws.com"),
      sourceArn: cdk.Arn.format({
        service: 'execute-api',
        resource: api.restApiId,
        resourceName: `prod/GET/hello2`,
        arnFormat: cdk.ArnFormat.SLASH_RESOURCE_NAME,
        region: this.region,
        account: this.account,
      }, this),
    });

このように設定すれば、API Gateway から Lambda へのアクセスを最小限の範囲に限定できるため、IAMのベストプラクティスに則ったセキュアな構成になります。

ただし、現実的には…

この方式には大きな弱点があります。それは、APIのエンドポイントが追加・変更されるたびに、CDKのコードにも手を加えなければならないという点です。

SpecRestApi を使いAPI仕様をOpenAPIファイルに外出しして、CDKにAPIの仕様を記載しない という方針でしたが、APIの仕様が変わるたびにCDKで許可設定を修正する必要が出てきてきて運用の負荷が高くなってしまいます。

改善策があるとすれば?

以下のようなアプローチを取ることで、手動設定の煩雑さを軽減できるかと思いました。

OpenAPIファイルから paths と methods をCDKでパースし、addPermission() を自動生成する

JSONの構造を読み取り、GET /hello1 → lambda:InvokeFunction を許可する処理をCDK内で行う

メリット:完全に自動化でき、OpenAPIの変更にも柔軟に対応可能

デメリット:CDK側の処理がやや複雑になる。OpenAPI構造に強く依存

OpenAPIに独自拡張(例: x-lambda-name)を追加して、CDKでその情報を使う

各エンドポイントに x-lambda-name を付与し、対応するLambda関数をCDK側でMap参照

メリット:OpenAPIファイルに明示的な意図が残る。CDK側での処理が比較的シンプル

デメリット:アプリ担当がOpenAPIを編集する際にルールの理解が必要

LambdaとAPIの紐付きを定義する構成ファイルを用意する

OpenAPIとは別に、以下のようなYAML/JSONのマッピングファイルを使って制御

{
  "/hello1": { "method": "GET", "lambda": "hello1" },
  "/hello2": { "method": "GET", "lambda": "hello2" }
}

メリット:責任分離が明確。OpenAPIファイルに余計な情報を入れなくて済む

デメリット:別ファイルの管理・同期コストが発生する、RestApiの書き方でも同じような書き方になる

どのアプローチも一長一短ありますが、「OpenAPIファイルを外部仕様として使う」という前提を活かすには、何らかの自動処理ロジック(動的 addPermission())との組み合わせが現実的な落としどころです。

この構成のメリットとデメリット

✅ メリット:OpenAPI読み込み方式が優れている点

責務分離がしやすい

API定義(OpenAPIファイル)とインフラ構築(CDK)を分けることで、アプリ担当とインフラ担当の役割を明確に分離できます。 アプリ担当者は仕様の設計・変更に集中でき、インフラ担当者チームは構築と管理に専念できます

OpenAPIを“仕様書”兼“構築定義”として使える

OpenAPIはもともとAPI仕様を文書化するための標準フォーマットですが、API Gatewayの x-amazon-apigateway-* 拡張を使うことで、そのまま構築定義にも転用できるのが大きな強みです。

開発フローにフィットしやすい

たとえば以下のような開発フローにフィットしやすいかと思います。

  • DEV環境でアプリ担当がマネコンからAPI Gatewayを自由に調整
  • 設定をOpenAPIとしてエクスポート
  • Git経由で共有
  • インフラ担当がCDKでSTG/PRDに展開

上記のようにDev環境ではマネコンを使用して手動試行 → IaCで本番展開という流れを自然に実現できます。

❌ デメリット:OpenAPI読み込み方式の限界と注意点

CDKコードからAPI構造が見えなくなる

OpenAPIファイルをCDKから読み込む方式では、API Gatewayの構成がCDKコードの外にあるため、 コードベースだけでAPIの全体像を把握することが難しくなります。

たとえば「このエンドポイントはどのLambdaと統合されているのか?」という情報がCDKコード上にはなく、 構成の可読性や保守性が下がるという課題があります。

API Gatewayの定義がOpenAPIにすべて入るわけではない

OpenAPIで定義できるのは、主にエンドポイントや統合先などのAPIの仕様面です。 一方で、以下のようなインフラ構成に関わる設定はOpenAPIだけでは対応できません:

  • VPC Link や Private Integration の設定
  • WAFのアタッチやログ出力のフォーマットのカスタマイズ設定(CloudWatch Logs)
  • UserPlanなど、ApiGatewayに対する設定

これらはAWSのAPI Gatewayの設定につきCDK側で個別に記述する必要があり、 OpenAPIとCDKの定義が分散するため、全体構成がわかりにくくなる恐れがあります CDKやインフラに不慣れなアプリ担当から見ると、「OpenAPIに全部書かれているはずなのに、なぜCDKにも定義が?」という混乱が起きやすい構成でもあります。

対してRestAPIを使えば、API仕様も一緒に記載することになりますが CDKのプロジェクト内にapi-gateway.tsといったファイルに全てまとめて記載することも可能かと思います

プレースホルダ置換という地味で危険な手作業が発生する

OpenAPIファイル内では、統合先のLambda関数のARNが環境ごとに異なるため、 {hello1Lambda} や {lambdaARN} のようなプレースホルダを使った一時的な記述が必要になります。

本番やステージング環境では、CDK側でこれを実際のARNに置き換える処理を行いますが、 この「文字列置換作業」は一見単純に見えて、置換漏れや間違ったARNへの紐付けといったヒューマンエラーの温床になりがちです。

特に以下のような問題が発生しやすいです:

  1. フォーマットミス → CDKがOpenAPIの読み込み時にエラーになるため検知可能
  2. 紐付けミス(例:/hello1 に hello2Lambda を指定) → CDKでは検知されず、デプロイ後に気づく

特に2個目のミスはRestApiを使ったCDKでも起こり得るミスですが、プレースホルダ運用では毎回修正作業が発生するため、ミスの機会はRestApiで書くよりも多いです。 OpenAPIファイルのGit差分チェックで気づけることもありますが、それに頼るしかない構成は正直ちょっと怖いです。

SpecRestApiとRestApiを比較すると

改めてSpecRestApiとRestApiを比較してみると、以下のような表になると思います。

比較項目 SpecRestApi(OpenAPI読み込み) RestApi(CDKで直接記述)
定義の責任 アプリ担当者に分離可能 インフラ担当者主導
可読性 OpenAPIファイルに集約(CDKコードからは見えにくい) CDKコード内で完結、構造が追いやすい
保守性 ファイル更新中心、Git差分で変化を追えるがjson編集となるためヒューマンエラーが懸念 型補完あり。リファクタや差分確認も容易
Lambda統合の手間 プレースホルダ置換が必要(手動 or CDK側で処理) Lambda定義と統合が同じコード内にありシンプル
連携コスト OpenAPIファイルを直接受け取れるため、やりとりが最小限で済む エンドポイント・メソッド・統合先などの細かい仕様をヒアリングしてCDKに記載が必要
向いているユースケース アプリ担当にAPI定義を任せたい
開発と動作確認でよくパスやメソッドが変わる場合
インフラ側でAPI設計・管理を一元化したいケースに有効
アプリ担当者がCDKをかけるならRestAPIの方が合理的

結論:柔軟性と分担のバランスで選ぶ

アプリ担当者がCDK(TypeScript)を問題なく記述できる場合は、RestApi を使ってAPI Gatewayを直接コードで管理する方が、構成の見通しや保守性の面で優れています。
型補完や差分の明確化、CDK内での一元管理といったメリットが享受できます。

たとえば、アプリ担当者が api-gateway.ts のようなファイルにAPI定義を記述し、インフラ担当者がその内容に不適切な設定がないかをレビューする、といった分担が可能です。

一方で、アプリ担当者がCDKやIaCに不慣れな場合や、APIの設計・変更が頻繁に発生するプロジェクトでは、OpenAPIファイルで仕様を定義し、それをCDKの SpecRestApi で読み込む構成が効果的です。
この方法なら、責任分離がしやすく、インフラ担当者とのやりとりも最小限に抑えられます。

ただしその場合、Lambda統合などのプレースホルダ置換処理における ヒューマンエラーのリスク には注意が必要です。

最終的な選択のポイントは、「IaCのコードを誰が管理・記述するのか?」に尽きます