S3デプロイをCodePipeline+Lambdaでやる

## 概要

　S3 + CloudFrontで静的コンテンツを配信して稼働しているWebサイトをよく見かけます。
LPサイトや自社のホームページなどに向いている構成です。
　当記事ではCodePipelineを使った静的サイトの自動デプロイについて解説していきます。

#### 基本的な流れ

1. Githubへプッシュ
2. CodePipelineの開始
3. 成果物の生成
4. Lambdaの起動
5. S3へのデプロイ
6. キャッシュの削除

## 注意点

　CodePipelineではデフォルトでS3に配置するオプションがあるのですが、既存ファイルの削除には対応していません。
　そのため、S3に配置する前に一旦Lambdaを通すことで削除と配置の両方に対応する方法についてみていきます。

## 手順
### Serverlessによる初期化

　LambdaのデプロイにServerlessFrameworkを使います。まずはプロジェクトを作りましょう。

```bash
serverless create --template aws-nodejs --path static-contents-to-s3
```

### Serverlessデプロイ用ユーザー作成

　AWSコンソールよりIAMポリシーおよびIAMユーザーを作成します。
このユーザーはServerlessのデプロイ用ユーザーです。

#### ポリシー

Serverlessのデプロイ時に以下のアクセス権限が必要になりますので付与します。
`CloudFormation`、`CloudWatch`、`EventBridge`、`IAM`、`Lambda`、`S3`
なおフルアクセス+全てのリソースを対象としていますが、セキュリティを意識する場合は権限を絞りましょう。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "cloudformation:*",
                "iam:*",
                "lambda:*",
                "s3:*",
                "logs:*",
                "events:*"
            ],
            "Resource": "*"
        }
    ]
}
```

![スクリーンショット 2019-08-24 9.11.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/466431/1a0456ae-34db-cd3a-dcbc-a331676e9707.png)

#### IAMユーザー

`プログラムによるアクセス`でユーザーを作成します。
ポリシーに先ほど作成したものをアタッチします。
なお出力される`アクセスキーID`と`シークレットアクセスキー`は後ほど使用するので控えておきます。

![スクリーンショット 2019-08-24 9.13.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/466431/7727f9dc-5de9-37a4-8b35-ff79ffea3932.png)

### Lambda関数作成

#### 関数の実行ロール
Lambda関数ですが、まずは実行ロールの権限についてみていきます。
関数がCodePipelineから呼ばれる場合、以下のポリシーが必要になります。（[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/codepipeline/latest/userguide/actions-invoke-lambda-function.html)より）

```json
{
  "Version": "2012-10-17", 
  "Statement": [
    {
      "Action": [ 
        "logs:*"
      ],
      "Effect": "Allow", 
      "Resource": "arn:aws:logs:*:*:*"
    },
    {
      "Action": [
        "codepipeline:PutJobSuccessResult",
        "codepipeline:PutJobFailureResult"
        ],
        "Effect": "Allow",
        "Resource": "*"
     }
  ]
} 
```

そして以下のポリシーも合わせて設定します。

- 成果物取得元S3バケットへのアクセス権限(Get)
- デプロイ先のS3バケットへのアクセス権限(Put, Delete, List)
- CloudFrontキャッシュ削除権限

これら実行ロールに付与するポリシーは`serverless.yml`に記載することで関数と一緒にデプロイします。
なお、全てへのリソースへのアクセスを許容していますが、セキュリティを意識する場合は絞りましょう。

```serverless.yml
(一部抜粋)

# you can add statements to the Lambda function's IAM Role here
  iamRoleStatements:
    - Effect: 'Allow'
      Action:
        - 'logs:*'
      Resource:
        - 'arn:aws:logs:::*:*:*'
    - Effect: 'Allow'
      Action:
        - 'codepipeline:PutJobSuccessResult'
        - 'codepipeline:PutJobFailureResult'
      Resource:
        - '*'
    - Effect: 'Allow'
      Action:
        - 's3:GetObject'
        - 's3:PutObject'
        - 's3:DeleteObject'
      Resource:
        - 'arn:aws:s3:::*/*'
    - Effect: 'Allow'
      Action:
        - 's3:ListBucket'
      Resource:
        - 'arn:aws:s3:::*'
    - Effect: 'Allow'
      Action:
        - 'cloudfront:CreateInvalidation'
      Resource:
        - '*'
```

#### AWSへのアクセス情報

`.env`を作成し、先ほど作成したIAMユーザーのアクセスキーIDおよびシークレットアクセスキーを記載しておきます。
後ほど、Docker上で実行する際に環境変数として読み込ませるようにするためです。

```.env
AWS_ACCESS_KEY_ID={Your AWS Access Key ID}
AWS_SECRET_ACCESS_KEY={Your AWS Secret Access Key}
```

(例)

```bash
$ docker run -it --rm -v $PWD:/app --env-file .env -w /app node:10 /bin/bash
```

#### 必要パッケージ

`serverless`はLambdaのアップで必要になるので入れておきます。
ついでに`yarn run deploy -- --stage dev` のような雰囲気でアップできるようにスクリプト化しておきます。

```package.json
{
  "name": "static-contents-to-s3",
  "scripts": {
    "deploy": "node_modules/.bin/sls deploy -v",
    "remove": "node_modules/.bin/sls remove -v"
  },
  "dependencies": {
    "mime-types": "^2.1.24",
    "node-zip": "^1.1.1"
  },
  "devDependencies": {
    "aws-sdk": "^2.517.0",
    "serverless": "^1.50.0"
  }
}
```

#### 関数の実装

以下にサンプルを置いていますので参照してください。
https://github.com/flatnyat/static-contents-to-s3

##### デプロイ用関数
CodePipelineから呼ばれるLambdaには`event["CodePipeline.job"]`に各種パラメータが渡されることに注意してください。
また、関数の完了時にはCodePipelineへ状態を通知することも必要です。

以下の部分はCodePipelineからユーザーが明示的に指定するもので後ほど設定します。
`const userData = JSON.parse(data.actionConfiguration.configuration.UserParameters);`

```js
'use strict';

const Aws = require('aws-sdk');
const NodeZip = require('node-zip');
const MimeTypes = require('mime-types');
const Pipeline = require('./pipeline');

/** S3 deploy */
module.exports.handle = async (event, context) => {
    console.log(JSON.stringify(event));
    try {
        const data = event["CodePipeline.job"].data;

        // 成果物を取得して解凍
        const artifactBucket = data.inputArtifacts[0].location.s3Location.bucketName;
        const artifactKey = data.inputArtifacts[0].location.s3Location.objectKey;
        const S3 = new Aws.S3({ region: 'ap-northeast-1' });
        const object = await S3.getObject({ Bucket: artifactBucket, Key: artifactKey }).promise();
        const zip = new NodeZip(object.Body, { base64: false, checkCRC32: true });
        const files = Object.keys(zip.files);
        console.log(files);

        // 一旦バケットの中身をリセット
        const userData = JSON.parse(data.actionConfiguration.configuration.UserParameters);
        const objects = await S3.listObjects({ Bucket: userData.bucket, MaxKeys: 1000 }).promise();
        await Promise.all(objects.Contents.map((content) => {
            return S3.deleteObject({ Bucket: userData.bucket, Key: content.Key }).promise();
        }));

        // デプロイ
        await Promise.all(files.map((i) => {
            const f = zip.files[i];
            if (! f.name.startsWith(".")) {
                return S3.putObject({
                    Bucket: userData.bucket,
                    Key   : f.name,
                    Body  : Buffer.from(f.asBinary(), 'binary'),
                    ContentType: MimeTypes.lookup(f.name) || 'application/octet-stream',
                }).promise();
            }
        }));

        // CodePipelineへ通知
        await Pipeline.putJobSuccess(event["CodePipeline.job"].id, "", context);
    } catch (ex) {
        await Pipeline.putJobFailure(event["CodePipeline.job"].id, ex, context);
    }
};
```

##### CloudFrontキャッシュ削除用関数

`DistributionId`は後ほどCodePipelineからユーザー指定パラメータとして設定します。

```js
'use strict';

const Aws = require('aws-sdk');
const Pipeline = require('./pipeline');

/** CloudFront cache delete */
module.exports.handle = async (event, context) => {
    console.log(JSON.stringify(event));
    try {
        const data = JSON.parse(event["CodePipeline.job"].data.actionConfiguration.configuration.UserParameters);

        const cloudfront = new Aws.CloudFront({});
        await cloudfront.createInvalidation({
            DistributionId: data.DistributionId,
            InvalidationBatch: {
                CallerReference: String(new Date().getTime()),
                Paths: {
                    Quantity: 1,
                    Items: ['/*']
                }
            }
        }).promise();

        await Pipeline.putJobSuccess(event["CodePipeline.job"].id, "", context);
    } catch (ex) {
        await Pipeline.putJobFailure(event["CodePipeline.job"].id, ex, context);
    }
};
```

##### CodePipelineへの結果通知

```js
'use strict';

const Aws = require('aws-sdk');
const codePipeline = new Aws.CodePipeline();

exports.putJobSuccess = (async(jobId, message, context) => {
    try {
        await codePipeline.putJobSuccessResult({
            jobId: jobId,
        }).promise();
        context.succeed(message);
    } catch (ex) {
        context.fail(ex);
    }
});

exports.putJobFailure = (async(jobId, message, context) => {
    await codePipeline.putJobFailureResult({
        jobId: jobId,
        failureDetails: {
            message: JSON.stringify(message),
            type: 'JobFailed',
            externalExecutionId: context.invokeid
        }
    }).promise();
    context.fail(message);
});
```

#### 関数デプロイ

　CodePipelineを作る前に関数をアップしておきましょう。
当記事の例では以下のコマンドでできるようになっています。

```bash
$ docker run -it --rm -v $PWD:/app --env-file .env -w /app node:10 /bin/bash
```
```bash
$ yarn run deploy -- --stage dev
```

### CodePipeline作成

AWSコンソールからCodePipelineを作成します。

#### ソースステージ

ソースプロバイダーをGithubで登録します。
リポジトリとブランチを指定して次に進みます。

![スクリーンショット 2019-08-24 15.05.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/466431/3d1d8d9b-e06f-0e08-8625-cba2f3bef86b.png)

#### ビルドステージ

ビルドステージは使いませんのでスキップします。

![スクリーンショット 2019-08-24 15.06.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/466431/99e5c22d-9b2e-020a-ff04-615d899018b2.png)

#### デプロイステージ

デプロイステージはLambdaを選択・・・と言いたいところですが
デフォルトでは選択できないようになっています。
ですので初めは適当なS3にデプロイする設定にしておきます。

![スクリーンショット 2019-08-24 15.07.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/466431/59d8d0d8-54f9-0711-8d24-1192ce7501a7.png)

#### デプロイステージの編集

一旦作成したら「編集する」で再設定をおこないます。
アクションプロバイダーを`AWS Lambda`とし、関数名は先ほどServerlessでアップしたものを指定します。

ユーザーパラメーターには`{"bucket":"{バケット名}"}`のようにデプロイ先のS3バケットを指定します。
この値がLambda関数に渡されるようになっています。

![スクリーンショット 2019-08-24 15.12.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/466431/876faf2b-c06f-d40c-cfde-5cf74c256c54.png)

#### キャッシュ削除ステージ

CloudFrontのキャッシュ削除用のステージを新しく作成します。

<img width="1323" alt="スクリーンショット 2019-08-24 17.37.27.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/466431/44e08554-213c-5b25-ebe8-de3d1853f2b0.png">

ユーザーパラメーターにCloudFrontのDistributionIdを指定します。
`{"DistributionId":"{自身のCloudFrontのID}"}`

![スクリーンショット 2019-08-24 17.38.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/466431/b927d56c-469f-e7af-2018-26e3da859efa.png)

ここまでできれば完成です。
Githubの自身で指定したブランチにプッシュすることで、一連の処理が実行されるようになります。

## サンプルコード
https://github.com/flatnyat/static-contents-to-s3