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"