お知らせ フロントエンド バックエンド インフラ 品質保証 セキュリティ 製品 興味・関心 その他

2023.06.01

インフラ

API GatewayでAPIとホスティングを両立し、SAM管理

AWS SAM(サーバーレスアプリケーションモデル)を使うとAPIを簡単に作成できます。 APIを利用する側(HTMLやJavaScriptなどのフロントエンド)も必要となりますが、それらをどうホスティングするかは、状況に応じて色々な選択肢があります。

今回、APIと同一のAPI Gatewayでホスティングまでできたら良いかと思い、方法を調べましたがあまり有用な情報はネット上にありませんでした。 ましてや、SAMで管理する方法は私が調べた範囲では皆無でした。

SAMを使ってAPIを作成し、さらにHTMLなどの静的ファイルも同一API Gatewayでホスティングする方法を探りました。

構成

API Gateway + LambdaでAPIを提供します。 同一API Gatewayでホスティングを実施しますが、コンテンツはS3に保存されているファイルを配信します。

構成図

この構成のメリットは、以下が考えられます。

  • フロントエンド、バックエンドをセットで管理できる
  • AWSリソースが少ないため、AWSに払う使用料が安い
  • ホスティングと同一ドメインでAPIが提供されるため、CORSを考えなくて良い

まずはAPIのみから始める

AWS SAMのサンプルテンプレートの「Hello World Example」をそのまま使い、APIを作成します。 名称はデフォルトの「sam-app」とします。 このAPIにホスティング機能を付加していきます。

最終的に、以下のパス構成となります。

  • https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/hello (API)
  • https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/static/… (ホスティング)

sam initを実行すると、次のようなAWS SAMテンプレートが作成されます(重要な部分のみ抜粋)。

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

  Sample SAM Template for sam-app

Globals:
  Function:
    Timeout: 3

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.10
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api
          Properties:
            Path: /hello
            Method: get

これをDeployすると、以下のような6つのAWSリソースが作成されます。

API Gatewayリソース切り出し

テンプレート内の

Events:
  HelloWorld:
    Type: Api

の記述により、自動的にAPI Gatewayが生成されています。 このままだとAPI Gatewayに追加の機能を設定できませんので、API Gatewayの記述を独立させます。 SAMにはAWS::Serverless::Apiというタイプがありますので、とりあえずこれを使ってみます。 公式ページによると、このタイプを指定すると、以下の3つのリソースを自動生成してくれます。

  • AWS::ApiGateway::RestApi
  • AWS::ApiGateway::Stage
  • AWS::ApiGateway::Deployment

テンプレートの変更部分は以下の通りです。

           Properties:
             Path: /hello
             Method: get
+            RestApiId:
+              Ref: ServerlessRestApi
+
+  ServerlessRestApi:
+    Type: AWS::Serverless::Api
+    Properties:
+      StageName: Prod

これをDeployすると以下のようなリソースとなります。

テンプレートの記述を変更しましたが、リソースには物理ID以外の変化はありません。API Gatewayの切り出しに成功しました。

ホスティング関連のリソースを追加

S3 Bucketや権限、API Gatewayリソース(ややこしいですが、「API Gatewayリソース」というタイプのAWSリソース: ホスティングのパス情報)を追加します。以下の記述をテンプレートに追加します。

+  HostingBucket:
+    Type: AWS::S3::Bucket
+
+  HostingIAMRole:
+    Type: AWS::IAM::Role
+    Properties:
+      Path: "/"
+      AssumeRolePolicyDocument:
+        Version: "2012-10-17"
+        Statement:
+          - Effect: Allow
+            Principal:
+              Service: apigateway.amazonaws.com
+            Action: sts:AssumeRole
+      MaxSessionDuration: 3600
+      ManagedPolicyArns:
+        - arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs
+      Description: "S3 hosting role."
+
+  HostingIAMPolicy:
+    Type: AWS::IAM::Policy
+    Properties:
+      PolicyName: S3GetObject
+      PolicyDocument:
+        Version: "2012-10-17"
+        Statement:
+          - Effect: Allow
+            Action:
+              - s3:GetObject
+            Resource: !Sub "arn:aws:s3:::${HostingBucket}/*"
+      Roles:
+        - !Ref HostingIAMRole
+
+  HostingResource:
+    Type: AWS::ApiGateway::Resource
+    Properties:
+      RestApiId: !Ref ServerlessRestApi
+      PathPart: static
+      ParentId: !GetAtt ServerlessRestApi.RootResourceId
+
+  HostingProxyResource:
+    Type: AWS::ApiGateway::Resource
+    Properties:
+      RestApiId: !Ref ServerlessRestApi
+      PathPart: "{proxy+}"
+      ParentId: !Ref HostingResource

追加後のテンプレートをDeployすると以下のようなリソースとなります。

追加した記述通りにホスティング関連のリソースが追加されました。

API Gatewayの様子を見ると次のようになりました。

/static/{proxy}というパスは追加されましたが、メソッドがありません。

{proxy}というのは、実際に{proxy}というパスを表すのではなく、/static/index.htmlのようなアクセスが発生するとAPI Gateway内部で{proxy} = index.htmlとして参照できるという意味となります。

メソッド追加

静的コンテンツを取得するGETメソッドを追加します。 /static/{proxy}にGETアクセスがあった場合、S3 Bucketにあるコンテンツを返す定義となります。

+  HostingMethod:
+    Type: AWS::ApiGateway::Method
+    Properties:
+      RestApiId: !Ref ServerlessRestApi
+      ResourceId: !Ref HostingProxyResource
+      HttpMethod: "GET"
+      AuthorizationType: "NONE"
+      ApiKeyRequired: false
+      RequestParameters:
+        "method.request.header.Accept": false
+        "method.request.header.Content-Type": false
+        "method.request.path.proxy": true
+      MethodResponses:
+        -
+          ResponseParameters:
+            "method.response.header.Content-Type": false
+          StatusCode: "200"
+        -
+          ResponseParameters:
+            "method.response.header.Content-Type": false
+          StatusCode: "404"
+      Integration:
+        CacheKeyParameters:
+          - "method.request.path.proxy"
+        CacheNamespace: !Ref HostingProxyResource
+        Credentials: !GetAtt HostingIAMRole.Arn
+        IntegrationHttpMethod: "GET"
+        IntegrationResponses:
+          -
+            ResponseParameters:
+              "method.response.header.Content-Type": "integration.response.header.Content-Type"
+            StatusCode: "200"
+          -
+            ResponseParameters:
+              "method.response.header.Content-Type": "integration.response.header.Content-Type"
+            SelectionPattern: "4\\d{2}"
+            StatusCode: "404"
+        PassthroughBehavior: "WHEN_NO_TEMPLATES"
+        RequestParameters:
+          "integration.request.path.proxy": "method.request.path.proxy"
+        TimeoutInMillis: 29000
+        Type: "AWS"
+        Uri: !Sub "arn:aws:apigateway:${AWS::Region}:s3:path/${HostingBucket}/{proxy}"

追加後のテンプレートをDeployすると以下のようなリソースとなります。

追加した記述通りにホスティング関連のリソースが追加されました。

API Gatewayを見ると次のようになりました。

/static/{proxy}にGETメソッドが追加され、S3からコンテンツを返すようになりました。

これで完成かと思いきや、下の図のようにAPI Gatewayのステージを見るとなぜかProdステージもStageステージにも/static/{proxy}がありません。

この状態でAWSコンソールから手動で「APIのデプロイ」を実行すると、ステージの画面に/static/{proxy}が現れます。 この結果から、おそらく/static/{proxy}が作成される前に、AWS::ApiGateway:Deployment(AWS::Serverless::Apiによる自動生成)が作成されたため、デプロイに/static/{proxy}の生成が反映されていないからだと考えられます。

そのためリソース生成順を制御するため、テンプレートでDependsOnを定義するなどいろいろ試してみましたが、うまくいきませんでした。

自動で生成されるリソースの生成順を制御することは不可能と判断しましたので、AWS::Serverless::Apiの使用をあきらめ、

  • AWS::ApiGateway::RestApi
  • AWS::ApiGateway::Stage
  • AWS::ApiGateway::Deployment

を個別に記述することにします。

AWS::Serverless::Apiを置き換え

AWS::Serverless::Apiを

  • AWS::ApiGateway::RestApi
  • AWS::ApiGateway::Stage
  • AWS::ApiGateway::Deployment

の記述に置き換えます。

変更部分

   ServerlessRestApi:
-    Type: AWS::Serverless::Api
+    Type: AWS::ApiGateway::RestApi
     Properties:
-      StageName: Prod
+      Name: sam-app
+      EndpointConfiguration:
+        Types:
+        - "EDGE"

追加部分

+  ApiGatewayDeployment:
+    Type: AWS::ApiGateway::Deployment
+    DependsOn:
+      - HostingMethod
+      - ApiMethod
+    Properties:
+      RestApiId: !Ref ServerlessRestApi
+
+  ApiGatewayStage:
+    Type: AWS::ApiGateway::Stage
+    Properties:
+      StageName: Prod
+      DeploymentId: !Ref ApiGatewayDeployment
+      RestApiId: !Ref ServerlessRestApi
+      CacheClusterEnabled: false
+      TracingEnabled: false

AWS::Serverless::ApiをAWS::ApiGateway::RestApiに変更し、AWS::ApiGateway::DeploymentとAWS::ApiGateway::Stageを明示的に記述しました。しかしこれだけでは変更は不足していて、AWS::Serverless::FunctionのEvents以下もAPI Gatewayのステージに関連しているようで、ここも置き換えが必要でした。

削除部分

       Runtime: python3.10
       Architectures:
         - x86_64
-      Events:
-        HelloWorld:
-          Type: Api
-          Properties:
-            Path: /hello
-            Method: get
-            RestApiId:
-              Ref: ServerlessRestApi

追加部分

+  ApiResource:
+    Type: AWS::ApiGateway::Resource
+    Properties:
+      RestApiId: !Ref ServerlessRestApi
+      PathPart: hello
+      ParentId: !GetAtt ServerlessRestApi.RootResourceId
+
+  ApiMethod:
+    Type: AWS::ApiGateway::Method
+    Properties:
+      RestApiId: !Ref ServerlessRestApi
+      ResourceId: !Ref ApiResource
+      HttpMethod: "GET"
+      AuthorizationType: "NONE"
+      ApiKeyRequired: false
+      Integration:
+        CacheNamespace: !Ref ApiResource
+        IntegrationHttpMethod: "POST"
+        PassthroughBehavior: "WHEN_NO_MATCH"
+        TimeoutInMillis: 29000
+        Type: "AWS_PROXY"
+        Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${HelloWorldFunction}/invocations"
+
+  LambdaPermission:
+    Type: AWS::Lambda::Permission
+    Properties:
+      Action: "lambda:InvokeFunction"
+      FunctionName: !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${HelloWorldFunction}"
+      Principal: "apigateway.amazonaws.com"
+      SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ServerlessRestApi}/*/GET/hello"

AWS::Serverless::FunctionのEventsで簡単に書けていたAPI Gatewayリソース、メソッド、Lambda実行権限の記述が一気に増えました。最終的なテンプレートの全体は、この記事の最後に記述します。

修正後のテンプレートをDeployするとAPI Gatewayのリソース画面は以下のようになりました。

また、ステージの画面は以下のようになり、/helloと/static/{proxy}にGETメソッドが存在します。

試しに、APIのURL

  • https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/hello

へアクセスすると、

{"message": "hello world"}

のJSONデータが返ってきますし、S3 Bucketにtest.html等のファイルを置いて、

  • https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/static/test.html

へアクセスすると、ファイルの内容が返ってきます。

テスト

S3 Bucketに以下のようなファイルをtest.htmlとして保存します。

<html>
<head>
<script>
function test() {
  var xhr = new XMLHttpRequest();
  xhr.open("GET", "../hello");
  xhr.responseType = 'json';
  xhr.onload = function() {
    alert(xhr.response.message);
  };
  xhr.send();
}
</script>
</head>
<body>
<button onclick="test();">Test</button>
</body>
</html>

https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/static/test.html にアクセスしてボタンをおすと、「hello world」のメッセージが書かれたalertウィンドウが開きます。

まとめ

ホスティングとAPIを同一API Gatewayで実現することに成功しました。 しかし、ホスティング機能を追加しただけなのに、AWS SAMで使えるAWS::Serverless::xxxのタイプはテンプレートでほぼ使えなくなり、テンプレートが肥大化してしまいました。 ほとんどCloudFormationのテンプレートで書いたのと変わりません。

  • もっと良い方法があるのではないか?
  • 無理に一緒にせず、APIとホスティングは別に管理した方が良いのではないか?

といった疑問は残りましたが、AWS Amplifyなどを使うほとではない小規模なWebアプリではこの方法は有用なのではないかと考えます。 AWSの構成がシンプルになりますので費用が抑えられ、必要であれば独自ドメイン運用、Certificate ManagerによるSSL運用も可能です。 ホスティングの静的ファイルも合わせてGit管理し、CodePipelineでCI/CDパイプラインを作ることも可能です。パイプラインではstaticの下の静的ファイルもS3へ自動コピーします。

最終テンプレート

最終的には次のようなテンプレートになりました。最初のテンプレートと比較すると、だいぶ記述が増えました。

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

  Sample SAM Template for sam-app

Globals:
  Function:
    Timeout: 3

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.10
      Architectures:
        - x86_64

  ServerlessRestApi:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: sam-app
      EndpointConfiguration:
        Types:
        - "EDGE"

  HostingBucket:
    Type: AWS::S3::Bucket

  HostingIAMRole:
    Type: AWS::IAM::Role
    Properties:
      Path: "/"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: apigateway.amazonaws.com
            Action: sts:AssumeRole
      MaxSessionDuration: 3600
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs
      Description: "S3 hosting role."

  HostingIAMPolicy:
    Type: AWS::IAM::Policy
    Properties:
      PolicyName: S3GetObject
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - s3:GetObject
            Resource: !Sub "arn:aws:s3:::${HostingBucket}/*"
      Roles:
        - !Ref HostingIAMRole

  HostingResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref ServerlessRestApi
      PathPart: static
      ParentId: !GetAtt ServerlessRestApi.RootResourceId

  HostingProxyResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref ServerlessRestApi
      PathPart: "{proxy+}"
      ParentId: !Ref HostingResource

  HostingMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref ServerlessRestApi
      ResourceId: !Ref HostingProxyResource
      HttpMethod: "GET"
      AuthorizationType: "NONE"
      ApiKeyRequired: false
      RequestParameters:
        "method.request.header.Accept": false
        "method.request.header.Content-Type": false
        "method.request.path.proxy": true
      MethodResponses:
        -
          ResponseParameters:
            "method.response.header.Content-Type": false
          StatusCode: "200"
        -
          ResponseParameters:
            "method.response.header.Content-Type": false
          StatusCode: "404"
      Integration:
        CacheKeyParameters:
          - "method.request.path.proxy"
        CacheNamespace: !Ref HostingProxyResource
        Credentials: !GetAtt HostingIAMRole.Arn
        IntegrationHttpMethod: "GET"
        IntegrationResponses:
          -
            ResponseParameters:
              "method.response.header.Content-Type": "integration.response.header.Content-Type"
            StatusCode: "200"
          -
            ResponseParameters:
              "method.response.header.Content-Type": "integration.response.header.Content-Type"
            SelectionPattern: "4\\d{2}"
            StatusCode: "404"
        PassthroughBehavior: "WHEN_NO_TEMPLATES"
        RequestParameters:
          "integration.request.path.proxy": "method.request.path.proxy"
        TimeoutInMillis: 29000
        Type: "AWS"
        Uri: !Sub "arn:aws:apigateway:${AWS::Region}:s3:path/${HostingBucket}/{proxy}"

  ApiResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref ServerlessRestApi
      PathPart: hello
      ParentId: !GetAtt ServerlessRestApi.RootResourceId

  ApiMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref ServerlessRestApi
      ResourceId: !Ref ApiResource
      HttpMethod: "GET"
      AuthorizationType: "NONE"
      ApiKeyRequired: false
      Integration:
        CacheNamespace: !Ref ApiResource
        IntegrationHttpMethod: "POST"
        PassthroughBehavior: "WHEN_NO_MATCH"
        TimeoutInMillis: 29000
        Type: "AWS_PROXY"
        Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${HelloWorldFunction}/invocations"

  ApiGatewayDeployment:
    Type: AWS::ApiGateway::Deployment
    DependsOn:
      - HostingMethod
      - ApiMethod
    Properties:
      RestApiId: !Ref ServerlessRestApi

  ApiGatewayStage:
    Type: AWS::ApiGateway::Stage
    Properties:
      StageName: Prod
      DeploymentId: !Ref ApiGatewayDeployment
      RestApiId: !Ref ServerlessRestApi
      CacheClusterEnabled: false
      TracingEnabled: false

  LambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: "lambda:InvokeFunction"
      FunctionName: !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${HelloWorldFunction}"
      Principal: "apigateway.amazonaws.com"
      SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ServerlessRestApi}/*/GET/hello"
工作クラブ

工作クラブ

記事一覧

「マーケライズ工作クラブ」で役に立つものを作り、その過程で新しい技術を習得してスキルアップしていきましょう。