【AWS】S3 / LambdaでSSIっぽいことをやる

2020.11.16更新
[Alibaba Cloud版](https://qiita.com/imaichiyyy/items/02ab2002c435ee1c938a)もつくりました。

# TL;DR
SSI（include virtual）が記述されたHTMLを、S3の特定バケットに格納しLambdaでインクルード内のソースを結合した上で別のバケットに格納する。

# やりたかったこと
S3は静的サイトホスティングサービスのため、サーバーサイド側で動的に処理をするということが原則できません。  
もちろんその後キャッシュする先であるCDN側（CloudFront）もできません。  

その場合、今であればローカル開発環境を構築しローカル上では別々のファイルにしておき、コンパイルする際に結合する方法がスタンダードだと思いますが、元々SSIが使われていた既存サイトをS3+CloudFrontへ移管する場合などを含め残念ながらすべての案件でそのフローが導入できるわけでもありません。

なので、元々SSIが使われていた場合、そのまま置換など行わずそのままの形で使用できるようにAWS側で調整を試みました。

# やったこと

（前提としてIAMの設定が終わった状態です）

基本的にこちらの記事を参考にして、自分用にカスタマイズしています。
[S3とLambdaでSSIっぽいことをする](https://www.blog.danishi.net/2019/12/11/post-2664/)

参考サイトだと元ファイルの拡張子にサフィックスとして`.ssi`を追加しなければならないのがちょっと気になったので、
temp用のバケットを別途用意し、サフィックスを追加せずに処理できるよう調整してます。

## 構成
基本的に使用するサービスはS3とLambdaのみです。
CloudFrontは必要であれば。
![ARMS_TECHSTACK (1) (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161498/86943886-b2ef-ee6e-7ebd-0223a78954eb.png)


## 設定方法

### S3
ファイルアップするtemp用のバケットと公開用のバケットを2つ用意します。
#### 公開用バケット
名前は何でも良いです。
今回は `s3-ssi-include` としています。
#### temp用バケット
名前は何でも良いです。
今回は `s3-ssi-include-base` としています。

#### 各設定
アクセス許可はそれぞれ適宜設定されているものとします。
temp用バケットはファイルが格納され、公開用バケットに渡すだけのものなので公開する必要はありません。
公開用バケットもCloudFrontを使用する場合は、公開する必要はないです。

##### temp用バケット
temp用バケットの詳細ページから「プロパティ」→「イベント通知」→「イベント通知を作成」へ遷移し、
ファイルをアップロードしたタイミング（PUTイベント）でLambda関数が起動するように

- イベントタイプ：PUT
- 送信先：Lambda関数

まで選択したら一度設定を完了してください。
後でLambda関数を作成した後にまたこの画面に戻り、

- Lambda関数を特定：「Lambda関数から選択する」で先程作成したLambda関数を指定

の設定を行う必要があります。

### Lambda

LambdaではPUTイベントを検知して、アップされたHTMLファイル内にSSI（サーバーサイドインクルード）が記述されていれば、Lambdaにインクルードさせ、別のバケットに格納するような関数を作成します。
S3はtemp用公開用それぞれリソースベースポリシーを編集し、権限を付与してください。
以下が参考になると思います。
[S3をトリガーにしたときのLambdaのリソースベースポリシー](https://www.yamamanx.com/aws-lambda-s3-resource-policy/)

#### 関数コード
```python
import json
import os
import logging
import boto3
from botocore.errorfactory import ClientError
import re
import urllib.parse

logger = logging.getLogger()
logger.setLevel(logging.INFO)
s3 = boto3.client('s3')
def lambda_handler(event, context):
    logger.info('## ENVIRONMENT VARIABLES')
    logger.info(os.environ)
    logger.info('## EVENT')
    logger.info(event)
 
    input_bucket = event['Records'][0]['s3']['bucket']['name']
    output_bucket = os.environ['S3_BUCKET_TARGET']

    logger.info('## INPUT BUKET')
    logger.info(input_bucket)
    
    input_key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'], encoding='utf-8')
    logger.info('## INPUT KEY')
    logger.info(input_key)

    try:
        # 入力ファイルの取得
        response = s3.get_object(Bucket=input_bucket, Key=input_key)

        if not input_key.endswith('.html'):
            s3.copy_object(Bucket=output_bucket, Key=input_key, CopySource={'Bucket': input_bucket, 'Key': input_key})
        else:
            input_html = response[u'Body'].read().decode('utf-8')
            output_html = input_html
            # SSI記述を取得
            include_path_base = re.findall(r'<!--#include virtual="/(.*?)" -->.*?\n', input_html, flags=re.DOTALL)
            logger.info('## PATH BASE')
            logger.info(include_path_base)
            if len(include_path_base) > 0:
                for path in include_path_base:
                    include_path = path
                    logger.info('## PATH')
                    logger.info(include_path)
            
                    # SSIファイルの取得
                    try:
                        include = s3.get_object(Bucket=input_bucket, Key=include_path)
                        include_html = include[u'Body'].read().decode('utf-8')
                        # SSIを実行
                        output_html = output_html.replace('<!--#include virtual="/' + include_path + '" -->', include_html)
                    except ClientError:
                        pass
            
            # ファイル出力
            logger.info('## OUTPUT BUKET')
            logger.info(output_bucket)
            
            output_key    = input_key
            logger.info('## OUTPUT KEY')
            logger.info(output_key)
            
            out_s3   = boto3.resource('s3')
            s3_obj   = out_s3.Object(output_bucket, output_key)
            response = s3_obj.put(Body = bytes(output_html, 'UTF-8'))
    except Exception as e:
        logger.info(e)
        raise e
```

#### その他の設定
##### 環境変数
管理画面上の環境変数 `S3_BUCKET_TARGET` には 公開用バケット名（今回は`s3-ssi-include`）を設定します。

また、ここまでで一度保存し、S3側の__Lambda関数を特定：「Lambda関数から選択する」で先程作成したLambda関数を指定__を行ってください。

# おわりに
これでtemp用のS3バケットにファイルをアップロードしたタイミングでLambdaがインクルードファイル埋め込みの処理を行い、本来の公開用S3バケットへ転送する機能の完成です。

普段のWebサーバーにファイルアップする感覚でS3に格納できると思いますので、サイト移行などでSSIを使用しているサイトをS3+CloudFrontに移行する必要がある場合にわざわざSSIで記述されている共通ファイルなどを一括置換することなく移行することができます。
元々SSIで管理している共通ファイル等を各ファイルに置換してしまうと、その後の共通ファイルがハードコーディングされるため運用工数やリスクが上がってしまうのが結構ツラかったりします。またそもそも一括置換自体人的ミスのリスクもあるのであまりやりたくないというのが正直なところ。
それを考えるとこの機能はまぁまぁ便利ではないかなぁと思ったりしています。
ただデメリットを挙げるとするなら、バケット2つ使用しているのでその分料金がかかるというのと、ちょっとわかりづらい仕組みなのでちゃんと周知する必要があるというところでしょうか。

今はもうCI/CDやDockerの進化により上記のようなことに悩む場面は少なくなっていると思いますが、
世の中そんなサイトばかりでも無いので、地味にこういうの需要あるのではないかなぁと。

現場からは以上です。

※キービジュアルは[ぱくたそ](www.pakutaso.com)さんで「AWS」で検索してヒットした画像です。何故かみなとみらい。