WordPressを静的サイト化してslackからDeployできるようにしてみた

## はじめに

WordPressでサイトを公開していて、以下のような課題に悩まされた経験はありませんか？

- 脆弱性を狙った攻撃の対象になる **セキュリティ** の課題
- 負荷対策が必要となる **パフォーマンスとスケーラビリティ** の課題
- DBが落ちて `データベース接続確立エラー` となったり EC2のリタイアメント等の必須メンテナンスの発生による **運用** の課題
- サーバ費用等の **コスト** の課題

このような課題をシンプルに解決するため、[コーポレートサイト](https://www.vega-c.com)のリニューアルを機に、WordPressを静的サイト化してみました。

## 全体像
![undefined__5__png.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38574/21097f02-b479-36bd-1fab-79b094d4ad73.png)

## この構成がもたらす課題解決
- セキュリティ
    - OriginがEC2上のWordPressではなくS3上の静的HTMLとなる為、攻撃可能領域がほとんど無くなります
- パフォーマンスとスケーラビリティ
    - CDN(CloudFront)を噛ませる事で、スケーラビリティとパフォーマンスの課題を解決します
- 運用
    - slack経由で簡単にEC2の起動/停止とデプロイが可能となります
        - デザイナーやバックオフィス系の方々（広報/人事等）が、ご自身でサーバ起動停止やデプロイが出来るようになりました
    - WordPress本体(EC2)が公開サイトから切り離されている為、いつでも好きなタイミングでメンテナンスが可能となります
- コスト
    - 常時発生するコストはCloudFrontとS3のみとなり大幅なコスト削減が可能となります


## この構成のポイント
- WordPress(EC2)
    - slack経由でEC2の起動/停止とデプロイを指示
        - EC2はサイト更新時にのみ起動すればよい
        - WordPress内のコンテンツを更新しても閲覧サイトへは反映されない（静的HTML出力→S3デプロイ後に閲覧可能となる
    - 信頼する接続元IP（管理者のOffice， VPN等）からのみアクセス可能となるようSecurityGroupでIP制限を行う
    - EC2の停止忘れ防止の為、夜間自動的に停止する処理を導入
- 閲覧用サイト(CloudFront+S3)
    - キャッシュ期間は長めでOK（デプロイ時にキャッシュ削除(Invalidation)を実施
    - S3＋CloudFrontの構成で静的Webサイトを構築した場合、サブディレクトリからデフォルトのルートオブジェクトを返さないという問題をCloudFront Functionsで解決
- AWS Chatbot
    - slack→Lambda→SSM(Run Command)経由でEC2上のデプロイスクリプトを実行
    - EC2の起動停止はslack→AWS Chatbotからaws cli経由で直接実行
- 余談
    - EC2に直接S3をマウントする方法(goofys, s3fs等)も検討しましたが、動作が不安定だったため不採用としました。

## 構築手順

### 1. WordPress(EC2)の構築
EC2上に、WordPressを構築しWebサイトを作成します
* EC2上にWordpressをインストールします。詳細な手順は割愛しますが下記の記事が参考になります

https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/install-LAMP.html

https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/hosting-wordpress.html
* WordPressを使ってWebコンテンツを作成します

### 2. 閲覧用サイト(CloudFront+S3)の構築
閲覧用サイトを構築します
* CloudFront+S3で静的Webサイトを構築します。詳細な手順は割愛しますが下記の記事が参考になります

https://dev.classmethod.jp/articles/cloudfront-s3-web/

### 3. デプロイ処理の構築
EC2上の静的HTMLをS3に同期し、CloudFrontのキャッシュ削除(Invalidation)を行う処理を作ります
* WordPressに静的化プラグインを導入します
    * [Simply Static](https://ja.wordpress.org/plugins/simply-static/)等のプラグインを導入し、WordPress上のWebコンテンツを静的HTMLとしてローカルに出力できる事を確認します

https://dis-play.net/wordpress/plugins/simply-static/

* デプロイに必要な権限を付与したEC2用のIAM Roleを作成し、EC2(WordPress)にアタッチします

```config:IAM Role
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "cloudfront:CreateInvalidation",
                "cloudfront:GetDistribution",
                "cloudfront:GetDistributionConfig",
                "cloudfront:GetInvalidation",
                "cloudfront:ListDistributions",
                "cloudfront:ListInvalidations",
                "cloudfront:ListStreamingDistributions"
            ],
            "Resource": [
                "arn:aws:cloudfront::123456789012:distribution/ディストリビューションID"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:DeleteObject"
            ],
            "Resource": [
                "arn:aws:s3:::s3-bucket-name/*",
                "arn:aws:s3:::s3-bucket-name"
            ]
        },
        {
            "Effect": "Allow",
            "Action": "s3:ListAllMyBuckets",
            "Resource": "arn:aws:s3:::*"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetEncryptionConfiguration"
            ],
            "Resource": "*"
        }
    ]
}
```
* デプロイ用スクリプトを作成し、動作確認します
```shell:shellの実装例(/usr/local/bin/s3_sync.sh)
#!/bin/sh
#set -e

DISTRIBUTION_ID=ディストリビューションID
ORIGIN_PATH=静的HTML出力先DIRのパス
DISTINATION_PATH=s3://s3-bucket-name/

INVALIDATION_PATHS="/*"

echo "----- s3 sync ----"
set -x
aws s3 sync ${ORIGIN_PATH} ${DISTINATION_PATH} --delete

set +x
echo ""
echo "----- create invalidation ----"

set -x
aws cloudfront create-invalidation --distribution-id "$DISTRIBUTION_ID" \
  --paths "$INVALIDATION_PATHS"
```

### 4. Lambdaの設定
LambdaからSSM経由でEC2上のデプロイ用スクリプトを実行させます
* EC2(WordPress)に割り当てたIAM RoleにSSM関連の許可ポリシーを追加でアタッチします
![IAM_Management_Console.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38574/479364e6-c8f7-ddcb-a517-d837a0c66d1b.png)
* Lambda用のIAM Roleを新規作成し、以下のポリシーをアタッチします
![IAM_Management_Console.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38574/748ffdd4-1b29-50d5-19da-7716a39ce461.png)
* slackのweb hook用URLを発行しておきます（手順は割愛
* Lambda関数を作成します
    * `一から作成` を選択し、上で作成したIAM Roleを実行ロールとして割り当て関数を作成します
        * (詳細設定はチェック不要)
* コードを実装します
```python:Python3.9の実装例(lambda_function.py)
import boto3
import logging
import requests
import time
import json

logger = logging.getLogger()
logger.setLevel(logging.INFO)

ec2 = boto3.client('ec2')
ssm = boto3.client('ssm')

def lambda_handler(event, context):
    try:
        ## 固定値(インスタンス再作成時は更新すること)
        instanceId = "i-*****************"
        r = ssm.send_command(
            InstanceIds = [instanceId],
            DocumentName = "AWS-RunShellScript",
            Parameters = {
                "commands": [
                    "sh /usr/local/bin/s3_sync.sh"
                ],
                "executionTimeout": ["3600"]
            },
        )
        command_id = r['Command']['CommandId']
        
        ## Command結果待ちループ
        while True:
            time.sleep(3)

            res = ssm.list_command_invocations(
              CommandId = command_id,
              Details = True
            )

            invocations = res['CommandInvocations']
            if len(invocations) <= 0:
                continue
            
            status = invocations[0]['Status']
            if status in {'Success','Failed'}:
                break

        ## 結果格納
        dict = {
            'Success':{
                'color':'good',
                'pretext':'Result:成功 :white_check_mark: :thumbsup:'},
            'Failed':{
                'color':'danger',
                'pretext':'Result:失敗 :warning: :thumbsdown:'}
        }
        response = invocations[0]['CommandPlugins'][0]['Output']
        logger.info('## 実行結果 Status:[' + status + ']\r' + response)

        ## ｓlackにPost
        WEB_HOOK_URL = "https://hooks.slack.com/services/XXXXXXX/YYYYYYY/abcdefg1234567890"
        requests.post(WEB_HOOK_URL, headers={'Content-Type': 'application/json'}, data=json.dumps({
            'text': dict[status]['pretext'] + '```' + response + '```'
        }))
    except Exception as e:
        logger.error(e)
        raise e
```
* コードをzipで固めてLambda関数にデプロイします

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-package.html

* Lambda関数のテスト実行
    * LambdaからEC2上のデプロイ処理スクリプトが実行され、結果がCloudWatch Logsから確認できればOKです
![CloudWatch_Management_Console.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38574/d25b1dba-7ac4-ab25-3205-3308f3f49f62.png)

### 5. AWS Chatbotの設定
AWS Chatbotをslackと連携し、slackからLambdaを実行したりEC2を起動停止したり出来るようにします
* AWS Chatbot とSlackを連携します
    * 下記サイトを参考に設定を行います(Lambda関数は既に作成済みのため `Lambda関数作成` の章は飛ばしてOK

https://dev.classmethod.jp/articles/execute-lambda-function-with-slack/
* slackから`@aws lambda invoke --function-name Lambda関数名 --region ap-northeast-1` のようにしてLambdaを実行 （途中 `[Run] command`ボタンをクリックする必要がある）し、下記のような実行結果が表示されればOKです
![Slack___a_corpsite-ops___vega-c_png.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38574/a943e9c8-8a85-884f-32f0-b24cd1f75bb2.png)

### 6. その他、細かい設定
* slackからEC2を起動停止できるようにする
    * Chatbot用IAM Roleに以下のようなインラインポリシーを追加(WordPressのインスタンスIDのみ起動/停止/確認を許可)し、 slackから `@aws ec2 {start|stop}-instances --instance-ids i-*************** --region ap-northeast-1` のようにして実行
```{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:StartInstances",
                "ec2:StopInstances"
            ],
            "Resource": "arn:aws:ec2:*:*:instance/i-***************"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeInstances",
                "ec2:DescribeInstanceStatus"
            ],
            "Resource": "*"
        }
    ]
}
```
* S3＋CloudFrontの構成で静的Webサイトを構築した場合に、サブディレクトリからデフォルトのルートオブジェクトを返さないという問題の解決
    * 過去に別記事としてまとめましたので参考までに

https://qiita.com/kazumax55/items/af85909ec3472138f426

以上！

## あとがき
いかがでしたでしょうか？

リプレース前は、WordPressの前にCloudFrontやWAFを置いて負荷耐性やセキュリティ対策をしていたのですが、EC2のメンテナンスがやり辛い点、たまにプロセスが落ちたり固まってしまう問題等、運用面で面倒な課題がありましたが、現在の構成にすることで様々な課題が解決され、低コストで楽に運用ができるようになりました。

同じような課題に悩まされている方の参考になれば幸いです。