別チームでホスティングしていたWebサイトがあったのですが、いろいろと都合がありWebサイトのソースとドメインだけをもらい、自分のとこのチームでホスティングする機会があり、「いまさらサーバーとか持ちたくない! サーバーレスでやりたい! それにIaCしたい!」と四苦八苦したメモです。
受領したソース
受領したWebサイトですが、販売終了したけれど残しておかないといけない商品サイトとかをイメージしていただけるといいかと思います。アクセス頻度・更新頻度ともに多くはありません。
ファイル種別としては、以下のようなものが含まれます。古き良きCGI的な感じの動的サイトです。
- HTMLファイル
- PHPファイル、incファイル
- アセットファイル
- jsファイル
- スタイルシート
- 画像
- etc...
容量的には全体で400MBくらいですが、そのほとんどはアセットファイルで、HTML,PHPは数MBです。PHPはヘッダーフッターを共通化したり、ニュースやブログ記事のXMLをCMSサーバーから取得・整形したりする用途で使われています。CakePHP, Laravel等のフレームワークは使われていません。
タスクランナーにgulpが使われており、 gulp build
するとjavascriptのminifyだのなんだのをした成果物が dist/
に出力されます。
元々のサーバー構成
元々は大まかに以下のような構成になっていました。Apache+PHPの一般的なWebサーバー構成かと思います。
サーバーレスな構成
そこから四苦八苦して導き出したサーバーレスな構成がこちら。
ホスティング対象のWebサイトのソースコードには極力手を加えず、それでいて保守やら負荷やらを考えないでいいというのがコンセプトです。CloudFront+S3による静的サイトホスティングをベースに動的生成が必要な部分だけAPI Gateway + AWS Lambdaを使用します。
API Gateway + AWS Lambdaの部分はLambda Web Adapter でウェブアプリを (ほぼ) そのままサーバーレス化するを参考にしています。
実装方針
今回の作業では、なるべく元のソースコードには修正を加えません。IaC用のサーバーレスなデプロイ用の設定ファイルやデプロイスクリプトを追加してやります。
デプロイには、使い慣れたフレームワークということで、Serverless Framework (v3)を使用します。また、PHPのLambda公式ランタイムはないためDocker on Lambdaで頑張って動かします。
Dockerfile
以下のような感じになります。
FROM php:x.x-apache
# https://aws.amazon.com/jp/builders-flash/202301/lambda-web-adapter/?awsf.filter-name=*all
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.7.0 /lambda-adapter /opt/extensions/lambda-adapter
# ポート変更
RUN sed -i "s|Listen 80|Listen 8080|" /etc/apache2/ports.conf
RUN sed -i "s|VirtualHost \*:80|VirtualHost *:8080|" /etc/apache2/sites-enabled/000-default.conf
# 初期設定だとlambdaで動かした時にbad user name www-dataと言われる
ENV APACHE_RUN_USER=daemon
# pidを置くデフォルトの場所がRead-onlyなので変更
ENV APACHE_PID_FILE=/tmp/apache2.pid
RUN chown -R root /var/www/html
COPY dist_apigw /var/www/html
ベースのイメージには、Docker Officialな php:バージョン-apache
を使用しました。PHPとかapacheとかのセットアップは面倒なので、公式イメージがあるのはありがたいですね。ただ、公式のイメージに -apache
はあるのですが、 -apache
かつ -alpine
なイメージは提供されていません。コンテナサイズが少し大きいので、Lambdaのコールドスタートのスピードが気になる場合は公式イメージを使わずに php -alpine
をベースに自前でapache httpdを入れたほうがいいかもしれません。
AWS Lambda Web Adapterにより、API GatewayからのLambda関数の実行をHTTPアクセスに戻すようにします。これが今回のキモです。AWS Lambda Web Adapterはポート8080にアクセスするので、ApacheのListen Portをそれに合わせて変更しました。
それだけだとApacheがうまく動かなかったので、権限周りを少し調整しました。このあたりは実際にLambdaを実行してはエラーメッセージと睨めっこを繰り返してます。
最後に、/var/www/html
に動的ページ部分をコピーします。画像ファイルなどの動的ではないファイルはCloudFront+S3の静的サイトホスティングで事足りるので、コンテナには含めないようにします(後述)。
serverless.yml
後はserverless.ymlで、上の「サーバーレスな構成」を実装していきます。CloudFront Distributionの定義もserverless.ymlのresources
に書いて、sls deploy
コマンド一発で構築できるようにします。以下に、serverless.ymlの記述を抜粋します。
説明の都合上、省略している箇所、順番を入れ替えている箇所がありますので注意してください。
Webサイト部分のビルド
最初に、Webサイトのビルドの設定です。gulp build
するとdist/
に成果物ができるので、それをDockerコンテナ内に入れるファイル(dist_apigw/
)とS3に置くファイル(dist_assets/
)に分けるスクリプト作成しました。sls deploy
コマンド以外の余計な手間をかけたくないので、パッケージ作成前に自動実行されるようにserverless-hooks-pluginでそれをフックに仕込みます。
plugins:
- serverless-hooks-plugin
# ...
custom:
hooks:
package:initialize:
- gulp build # distにファイルが出力される
# API Gateway+Lambda用に.phpを抽出
- rm -rf dist_apigw
- cp -r dist dist_apigw
- find dist_apigw -type f | grep -v -E '.*\.(php|html|inc)' | xargs rm -f
- find dist_apigw -type d -empty -delete
# CloudFront+S3用に.php以外を抽出
- rm -rf dist_assets
- cp -r dist dist_assets
- find dist_assets -type f | grep -E '.*\.(php|html|inc)' | xargs rm -f
- find dist_assets -type d -empty -delete
# ...
拡張子で選り分けるだけの処理なのですが、単純にワイルドカードでやるとファイル数多すぎでコマンドライン長の制限に引っかかりfind
とxargs
を使うように書き換えたりと少し手間取りました。
DockerビルドとLambda関数
先ほどのDockerfileでイメージをビルド&ECRにプッシュしてLambda関数にする設定です。Lambda関数は一個だけでAPI Gatewayへの全アクセスをこれで受けます。
provider:
ecr:
images:
latest:
path: ./
functions:
php:
image:
name: latest
events:
- httpApi:
path: /{proxy+}
method: any
# ここに実行時環境変数とかを追加する
CloudFrontディストリビューション
CloudFront Distributionおよびアセットファイルを置くS3バケットを作る設定です。加えて、CloudFrontからS3にアクセスするためのOriginAccessControlとBucketPolicyも作ります。
resources:
Resources:
BucketWebsiteAssets:
Type: AWS::S3::Bucket
Properties:
BucketName: ${env:S3BUCKET}
BucketPolicyWebsiteAssets:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref BucketWebsiteAssets
PolicyDocument: !Sub
- |
{
"Version": "2008-10-17",
"Id": "PolicyForCloudFrontPrivateContent",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "${BucketArn}/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribution}"
}
}
}
]
}
- BucketArn: !GetAtt BucketWebsiteAssets.Arn
Distribution: !Ref CfDistributionWebsite
CfDistributionWebsite:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
Aliases:
- ${env:DOMAIN}
ViewerCertificate:
AcmCertificateArn: !Sub arn:aws:acm:us-east-1:${AWS::AccountId}:certificate/${env:ACM_IDENTIFIER}
HttpVersion: http2
Origins:
- Id: !Sub
- ${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}
- HttpApi: !Ref HttpApi
DomainName: !Sub
- ${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}
- HttpApi: !Ref HttpApi
CustomOriginConfig:
OriginProtocolPolicy: https-only
OriginSSLProtocols: [TLSv1.2]
- Id: !GetAtt BucketWebsiteAssets.RegionalDomainName
DomainName: !GetAtt BucketWebsiteAssets.RegionalDomainName
OriginAccessControlId: !Ref CfOacWebsite
S3OriginConfig: {}
# Behavior要約 (PathPatternにorが使えないので記述がややこしくなっている)
# 1. *.php,*.html は API Gateway
# 2. それ以外のファイル(*.*) は S3
# 3. さらにそれ以外のパス(ex. news/) は API Gateway
CacheBehaviors:
- TargetOriginId: !Sub
- ${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}
- HttpApi: !Ref HttpApi
PathPattern: "*.php"
Compress: true
ViewerProtocolPolicy: redirect-to-https
AllowedMethods: [GET, HEAD, OPTIONS]
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled
OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac # AllViewerExceptHostHeader
- TargetOriginId: !Sub
- ${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}
- HttpApi: !Ref HttpApi
PathPattern: "*.html"
Compress: true
ViewerProtocolPolicy: redirect-to-https
AllowedMethods: [GET, HEAD, OPTIONS]
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled
OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac # AllViewerExceptHostHeader
- TargetOriginId: !GetAtt BucketWebsiteAssets.RegionalDomainName
PathPattern: "*.*"
Compress: true
ViewerProtocolPolicy: redirect-to-https
AllowedMethods: [GET, HEAD, OPTIONS]
CachedMethods: [GET, HEAD, OPTIONS]
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized
OriginRequestPolicyId: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf # CORS-S3Origin
DefaultCacheBehavior:
TargetOriginId: !Sub
- ${HttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}
- HttpApi: !Ref HttpApi
Compress: true
ViewerProtocolPolicy: redirect-to-https
AllowedMethods: [GET, HEAD, OPTIONS]
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled
OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac # AllViewerExceptHostHeader
CfOacWebsite:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: !GetAtt BucketWebsiteAssets.DomainName
SigningBehavior: always
SigningProtocol: sigv4
OriginAccessControlOriginType: s3
S3BUCKET
は作成するS3バケット名、DOMAIN
はホスティングするドメイン名、ACM_IDENTIFIER
はそのドメインのSSL証明書ARNです。.envファイル等でsls deploy
時の環境変数に設定します。
余談ですが、CloudFormationでS3 originのCloudFrontを作るときは、TargetOriginId
には.DomainName
ではなく.RegionalDomainName
を指定した方がよいです。.DomainName
の方は反映に時間がかかるらしく、参照エラーでCloudFrontの作成に失敗する場合があります。
CachePolicyId
, OriginRequestPolicyId
は環境によって違う可能性があるので要確認です。
s3-sync
最後にS3バケットにアセットファイルをコピーする設定です。serverless-s3-syncを使用します。
plugins:
# ...
- serverless-s3-sync
custom:
# ...
s3Sync:
- bucketName: ${env:S3BUCKET}
localDir: dist_assets
deleteRemoved: true
これで、大筋は完了です。sls deploy
でデプロイして、問題なければドメインを割り当てます。
その他
そろそろ説明に疲れてきたし、とっちらかるので割愛しますが、うちの場合はこれらに加えて以下も設定しています。
- CloudFront Functionsと.htaccessによるBASIC認証(開発環境、ステージング環境用)
- WAF
-
after:deploy
フックでのCloudFrontのCache Invalidation -
useDotenv: true
での環境変数設定の切り替え - buildspec.yml
- ローカル開発用のdocker-compose.yml
性能とか
コスト
今回の構成だと、PV数≒Lambda実行回数となります。それ以外にはCloudFront+S3の静的ホスティングの料金がかかりますが、多くの場合、全部合わせてもEC2でWebサーバーを運用するよりも安上がりになるかと思います。
耐久性とか負荷とか
使っているのはLambda、CloudFront、API Gateway、S3とフルマネージドサービスのみで、完全なサーバーレス構成のため、オートスケーリングとか面倒なことは考える必要がありません。
メンテナンス
同じくサーバーレスなため「サーバーに脆弱性対応のパッチをあてて、、」みたいな作業はほぼありません。Lambdaで動いているPHP,Apacheについての脆弱性のチェックはもちろん必要ですが、sls deploy
でダウンタイムゼロで更新できる他、今回の構成だとLambdaに他のAWSリソースにアクセスする権限を与える必要が一切ないので、万が一の場合の被害も最小限と思います。
性能
Docker on Lambdaは遅い印象があったのですが、今回のケースだとほとんど気にならず、元々のサーバー構成と比較しても遜色ない性能が出ました。今回動かしたWebサイトの場合は、LambdaがどうのよりもCMSサーバーへのアクセスが圧倒的に遅いということがわかったので、あまり立ち入った性能測定はしていません。少なくとも片手間でWebサイトを運営するには十分かと思います。
なお、CloudFront+S3の静的サイトホスティングを併用せず全てLambdaで賄う構成も試したのですが、こちらは明確にレスポンスの悪さを感じる結果となりました。アセットファイルもDockerコンテナに含めなくてはいけなくなるためコンテナサイズが肥大化し、かつ、アセットファイルへのアクセスでLambdaの同時実行数も大幅に増えるためと思われます。
最後に
この記事が少しでもサーバーメンテナンスから人類が解放される手掛かりになれば幸いです。
よいサーバーレスライフを!