S3にあるHTMLや画像などの静的ファイルを、API Gateway+Lambdaを使ってHTTPSで公開するコードを書きました。
LambdaでS3オブジェクトを読み込んで、そのまま返すだけですが、バイナリファイルの扱いが面倒でしたので、記事にしておきます。
ポイントは、API Gatewayのバイナリメディアタイプを */*
にすることです。
S3のファイルをHTTP(S)で公開するには、S3のバケットにStatic website hostingの設定をするか、CloudFrontを使うことが多いと思いますが、なんらかの事情でこれらの機能を使えない場合の対処です。(あんまりないかもしれませんが)
バイナリファイルの扱い
LambdaとAPI Gatewayとのあいだでは画像などのバイナリを直接受け渡すことができません。API Gatewayでバイナリメディアタイプという設定をし、BASE64で受け渡します。
API GatewayからLambdaへのバイナリ受け渡し
以下の条件を満たすとき、API GatewayはPOSTメソッドなどのリクエストボディをBASE64エンコードしてLambdaに渡してくれます。Lambda側で必要に応じてBASE64デコードする必要があります。
条件
- ブラウザからのリクエストヘッダのContent-TypeがAPI Gatewayのバイナリメディアタイプに合致
※この記事の目的である静的ホスティングであれば、リクエストボディの考慮は不要です。
LambdaからAPI Gatewayへのバイナリ受け渡し
以下の条件をすべて満たすとき、API GatewayはLambdaから渡されたレスポンスボディをBASE64デコードしてからレスポンスします。バイナリをレスポンスすべきときは以下の条件をすべて満たすようにしたうえで、Lambda側はレスポンス時にBASE64エンコードする必要があります。
条件
- ブラウザからの
Accept
リクエストヘッダがバイナリメディアタイプと合致 - レスポンスヘッダのContent-Typeがバイナリメディアタイプと合致
- LambdaからAPI Gatewayに返す値の
isBase64Encoded
がtrue
静的ホスティングにおけるバイナリファイルの扱いに関する実装
レスポンスすべきファイルが画像などのバイナリのときにだけBASE64エンコードするという処理を思いつきます。そこで、バイナリメディアタイプに image/*, font/*
などと列挙しようとしました。しかし、これはうまくいきません。バイナリメディアタイプを */*
にする必要がありました。
理由その1
バイナリのファイルタイプは画像だけでなく無数にあり列挙しきれないのが理由です。ちょっと試しただけでフォントファイルとかPDFとかいろいろ出てきます。テキストファイルをバイナリとして扱うのは問題ありませんので、すべてをバイナリとして扱うのが早いです。
理由その2
バイナリをレスポンスするにはブラウザからの Accept
リクエストヘッダがバイナリメディアタイプと合致する必要があるのですが、合致しない場合があるのです。Chromeの場合、HTMLのimg要素でブラウザが画像をサーバにリクエストするときには Accept
の先頭が image/*
になるのですが、画像ファイルを新しいタブで直接読み込んだ場合には Accept
が text/html, image/*
の順になります(他のブラウザは試してません)。Chromeの挙動は間違ってないとは思います。しかし、API Gatewayはバイナリ判定するときに Accept
の先頭しか見ない仕様だとドキュメントに書かれています。したがって、Chromeで新しいタブで開いたときはAPI Gatewayは text/html
だとみなしてしまいます。バイナリメディアタイプにバイナリのタイプを列挙する方法ですと、画像やPDFをタブで開こうとしてもバイナリファイルにならずに開けません。text/html
がバイナリメディアタイプに合致する必要があります。
テキストファイルなのにLambdaでBASE64エンコードして、API Gatewayがデコードするのはもったいない気がします。そこで、バイナリメディアタイプは */*
にしたうえで、明らかにテキスト形式であるHTML/CSS/JSなどの場合はLambda側で isBase64Encoded
を false
とすれば、無駄なBASE64エンコード・デコードを省略できます。
ハマりポイント
上記の理由その2に書いた通り、新しいタブで画像が表示されないという問題が十分ハマりポイントだったのですが、それに関連してもうひとつ。
バイナリメディアタイプをCloudFormationのテンプレートに書いても、なぜか反映されない場合がありました。CloudFormationのStackをいったん削除してから作り直すと反映されますが、その場合API GatewayのURLが変わってしまいます。なので、せっかくCloudFormationに書いてるのに、改めて手動でバイナリメディアタイプだけ設定しなおすことが多々ありました。
ぼやき
API Gatewayにいろんな機能がありますが、Lambdaと組み合わせるときには、へんにAPI Gatewayの機能に期待するよりも、Lambda側に任せ、コードで表現しちゃったほうが早い気がします。。。バイナリの扱いに限らず。
バイナリメディアタイプを */*
にするのも、Lambdaで判定を実装するからAPI Gatewayは余計な判断をするな、という意思でもあります。
ソースコード
LambdaのJavaScriptコード
ここまで書いたことを実装したLambdaのコードです。JavaScriptです。
S3のオブジェクトに設定されているContent-Typeが信用できない想定で、拡張子でContent-Typeを判断してレスポンスするようにしています。この場合、使われうる拡張子をリストアップする必要があります。ついでにバイナリファイルかどうか(BASE64するかどうか)の判定もしています。
api/app.js
const AWS = require('aws-sdk');
function basename(key) {
const p = key.lastIndexOf("/");
if (p >= 0) {
return key.substring(p + 1);
} else {
return key;
}
}
function extname(key) {
const p = key.lastIndexOf(".");
if (p > 0) {
return key.substring(p + 1);
} else {
return "";
}
}
exports.lambda_handler = async (event, context) => {
const s3_bucket = process.env.S3_BUCKET;
const s3_key = event.path.substring(1);
// TODO "/" で終わっていたら "/index.html" にするとかしたほうがいい
const ext = extname(s3_key);
const s3 = new AWS.S3();
console.log(event);
return new Promise(function(resolve, reject) {
s3.getObject({
Bucket: s3_bucket,
Key: s3_key,
}, function (err, data) {
if (err) {
console.log(err);
resolve({
"statusCode": 400,
"headers": {
"Content-Type": "application/json",
},
"body": JSON.stringify(err),
});
return;
}
const body = data.Body;
let text = "";
for (let i = 0, len = body.byteLength; i < len; i++) {
text += String.fromCharCode(body[i]);
}
let contentType;
let isBase64Encoded = true;
if (ext == "jpg") {
contentType = "image/jpeg";
} else if (ext == "gif") {
contentType = "image/gif";
} else if (ext == "html") {
contentType = "text/html";
isBase64Encoded = false;
} else if (ext == "css") {
contentType = "text/css";
isBase64Encoded = false;
} else if (ext == "js") {
contentType = "text/javascript";
isBase64Encoded = false;
} else if (ext == "json") {
contentType = "application/json";
isBase64Encoded = false;
} else if (ext == "woff") {
contentType = "font/woff";
} else if (ext == "ttf") {
contentType = "font/ttf";
} else if (ext == "ico") {
contentType = "image/vnd.microsoft.icon";
} else {
contentType = "text/plain";
}
let responseBody;
if (isBase64Encoded) {
responseBody = data.Body.toString("base64");
} else {
responseBody = data.Body.toString();
}
resolve({
"statusCode": 200,
"headers": {
"Content-Type": contentType,
"Last-Modified": data.LastModified,
},
"body": responseBody,
"isBase64Encoded": isBase64Encoded,
});
});
});
};
CloudFormationのテンプレート
CloudFormationのテンプレートです。ホスティングしているS3のバケット名はテンプレートのパラメータで渡し、Lambdaには環境変数で渡しています。
template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Parameters:
S3Bucket:
Type: String
Resources:
StaticWebsiteFileFunction:
Type: AWS::Lambda::Function
Properties:
Code: api
Handler: app.lambda_handler
FunctionName: staticwebsite-file
Role: !GetAtt StaticWebsiteFileFunctionRole.Arn
Runtime: nodejs10.x
Timeout: 30
Environment:
Variables:
S3_BUCKET: !Ref S3Bucket
StaticWebsiteFileFunctionPermissionProd:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
Principal: apigateway.amazonaws.com
FunctionName: !Ref StaticWebsiteFileFunction
SourceArn: !Sub
- arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/${__Method__}/${__Path__}
- __Stage__: "*"
__ApiId__: !Ref StaticWebsiteFileApiGateway
__Method__: "*"
__Path__: "*"
StaticWebsiteFileFunctionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Action:
- "sts:AssumeRole"
Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole # ログ書き込み用
- arn:aws:iam::aws:policy/AmazonS3FullAccess # S3閲覧用
StaticWebsiteFileApiGateway:
Type: AWS::ApiGateway::RestApi
Properties:
Name: staticwebsite-file
BinaryMediaTypes:
# ここがポイント
- "*/*"
Body:
info:
version: 1.0
title: !Ref AWS::StackName
paths:
"/":
get:
x-amazon-apigateway-integration:
httpMethod: POST
type: aws_proxy
uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${StaticWebsiteFileFunction.Arn}/invocations
"/{proxy+}": # すべてのパスを同じLambdaが受け取る設定
get:
x-amazon-apigateway-integration:
httpMethod: POST
type: aws_proxy
uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${StaticWebsiteFileFunction.Arn}/invocations
swagger: 2.0
Policy:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal: "*"
Action: execute-api:Invoke
Resource: "execute-api:/*"
StaticWebsiteFileApiGatewayProdStage:
Type: AWS::ApiGateway::Stage
Properties:
DeploymentId: !Ref StaticWebsiteFileApiGatewayDeployment
RestApiId: !Ref StaticWebsiteFileApiGateway
StageName: Prod
StaticWebsiteFileApiGatewayDeployment:
Type: AWS::ApiGateway::Deployment
Properties:
RestApiId: !Ref StaticWebsiteFileApiGateway
StageName: Stage
Outputs:
Api:
Value: !Sub "https://${StaticWebsiteFileApiGateway}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
デプロイスクリプト
CloudFormationのデプロイをするbashスクリプトの例です。デプロイ後にAPI GatewayのURLを表示します。
deploy.sh
set -Ceu
# 環境に合わせてパラメータを設定
profile=default
cf_s3_bucket=... # CloudFormationで使うS3バケット名
stack_name=staticwebsite
s3_bucket=... # HTMLや画像などを置くS3バケット名
aws --profile $profile cloudformation package \
--template-file template.yaml \
--s3-bucket $cf_s3_bucket \
--s3-prefix staticwebsite \
--output-template-file var/packaged-$profile.yaml
aws --profile $profile cloudformation deploy \
--template-file var/packaged-$profile.yaml \
--stack-name $stack_name \
--capabilities CAPABILITY_IAM \
--no-fail-on-empty-changeset \
--parameter-overrides \
S3Bucket=$s3_bucket
aws --profile $profile cloudformation describe-stacks \
--stack-name $stack_name |
jq -r '.Stacks[0].Outputs[0].OutputValue'
関連記事
最近API Gatewayの記事ばかり書いています。今年後半はAPI Gatewayに悩まされ続けました。
- IAM認証のAWS API GatewayにEC2インスタンスからSigV4署名してアクセスするには (12/23)
- API GatewayをVPC内のプライベートに変更するとHTTP2非対応になる (12/22)
- API GatewayをVPC内のプライベートアクセスのみに限定 (12/21)
- CloudFormationでAPI Gatewayを変更しても反映されない (12/17)
- AWS::ApiGateway::Stage と AWS::ApiGateway::Deployment の違い (12/16)
- IAM認証のAWS API GatewayにC#からIAMロールでSigV4署名してアクセスするには (2020/10/19)
- IAM認証のAWS API GatewayにC#からIAMユーザでSigV4署名してアクセスするには (2020/10/14)
- IAM認証のAWS API GatewayにPythonからSigV4署名してアクセスするには (2020/07/22)