Apache+PHPサーバーのWebサイトをAWS Lambdaで動かす

別チームでホスティングしていた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サーバー構成かと思います。

```plantuml
@startuml
!include <awslib/AWSCommon>
!include <awslib/AWSSimplified>
!include <awslib/General/User>
!include <awslib/Groups/AWSCloud>
!include <awslib/NetworkingContentDelivery/ElasticLoadBalancingApplicationLoadBalancer>
!include <awslib/Compute/EC2Instances>
!include <awslib/General/Traditionalserver>

left to right direction

User(user, "User", "")
AWSCloudGroup(cloud) {
    ElasticLoadBalancingApplicationLoadBalancer(alb, "ALB\nロードバランサー", "")
    EC2Instances(apache, "EC2\nApache+PHP", "")
    alb --> apache
}
Traditionalserver(cms, "CMSサーバー\n（外部）", "")

user --> alb
apache --> cms
@enduml
```

## サーバーレスな構成

そこから四苦八苦して導き出したサーバーレスな構成がこちら。

```plantuml
@startuml
!include <awslib/AWSCommon>
!include <awslib/AWSSimplified>
!include <awslib/General/User>
!include <awslib/Groups/AWSCloud>
!include <awslib/NetworkingContentDelivery/CloudFrontDownloadDistribution>
!include <awslib/Storage/SimpleStorageServiceBucketWithObjects>
!include <awslib/ApplicationIntegration/APIGateway>
!include <awslib/Compute/LambdaLambdaFunction>
!include <awslib/Compute/EC2Instances>
!include <awslib/General/Traditionalserver>

left to right direction

User(user, "User", "")
AWSCloudGroup(cloud) {
    CloudFrontDownloadDistribution(cf, "CloudFront\nディストリビューション", "")
    APIGateway(apigw, "API Gateway\n", "")
    SimpleStorageServiceBucketWithObjects(bucket, "S3バケット\nアセットファイル", "")
    LambdaLambdaFunction(lambda, "Lambda\nApache+PHP", "")

    cf --> bucket : それ以外
    cf --> apigw : "html,php"
    apigw --> lambda
}
Traditionalserver(cms, "CMSサーバー\n（外部）", "")

user --> cf
lambda --> cms
@enduml
```

ホスティング対象のWebサイトのソースコードには極力手を加えず、それでいて保守やら負荷やらを考えないでいいというのがコンセプトです。CloudFront+S3による静的サイトホスティングをベースに動的生成が必要な部分だけAPI Gateway + AWS Lambdaを使用します。

API Gateway + AWS Lambdaの部分は[Lambda Web Adapter でウェブアプリを (ほぼ) そのままサーバーレス化する](https://aws.amazon.com/jp/builders-flash/202301/lambda-web-adapter/?awsf.filter-name=*all)を参考にしています。

https://aws.amazon.com/jp/builders-flash/202301/lambda-web-adapter/?awsf.filter-name=*all

# 実装方針

今回の作業では、なるべく元のソースコードには修正を加えません。IaC用のサーバーレスなデプロイ用の設定ファイルやデプロイスクリプトを追加してやります。

デプロイには、使い慣れたフレームワークということで、[Serverless Framework (v3)](https://www.serverless.com/)を使用します。また、PHPのLambda公式ランタイムはないためDocker on Lambdaで頑張って動かします。

# Dockerfile

以下のような感じになります。

```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](https://hub.docker.com/_/php)な `php:バージョン-apache` を使用しました。PHPとかapacheとかのセットアップは面倒なので、公式イメージがあるのはありがたいですね。ただ、公式のイメージに `-apache` はあるのですが、 `-apache` かつ `-alpine` なイメージは提供されていません。コンテナサイズが少し大きいので、Lambdaのコールドスタートのスピードが気になる場合は公式イメージを使わずに php `-alpine` をベースに自前でapache httpdを入れたほうがいいかもしれません。

[AWS Lambda Web Adapter](https://github.com/awslabs/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の記述を抜粋します。

:::note warn
説明の都合上、省略している箇所、順番を入れ替えている箇所がありますので注意してください。
:::

## Webサイト部分のビルド

最初に、Webサイトのビルドの設定です。`gulp build`すると`dist/`に成果物ができるので、それをDockerコンテナ内に入れるファイル(`dist_apigw/`)とS3に置くファイル(`dist_assets/`)に分けるスクリプト作成しました。`sls deploy`コマンド以外の余計な手間をかけたくないので、パッケージ作成前に自動実行されるように[serverless-hooks-plugin](https://github.com/uswitch/serverless-hooks-plugin)でそれをフックに仕込みます。

```yml
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への全アクセスをこれで受けます。

```yml
provider:
  ecr:
    images:
      latest:
        path: ./

functions:
  php:
    image:
      name: latest
    events:
      - httpApi:
          path: /{proxy+}
          method: any
    # ここに実行時環境変数とかを追加する
```

## CloudFrontディストリビューション

CloudFront Distributionおよびアセットファイルを置くS3バケットを作る設定です。加えて、CloudFrontからS3にアクセスするためのOriginAccessControlとBucketPolicyも作ります。

```yml
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](https://github.com/k1LoW/serverless-s3-sync)を使用します。

```yml
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の同時実行数も大幅に増えるためと思われます。

# 最後に

この記事が少しでもサーバーメンテナンスから人類が解放される手掛かりになれば幸いです。

よいサーバーレスライフを！