AWS CDKとGitHub ActionsでVueのWebページを環境別デプロイ


このディレクトリ構成で、AWSへのデプロイフローを作る。
Vue.jsのWebサイトを `本番 (prod)` / `ステージング (stg)`環境に分けて管理する。ステージング環境は検証用として使える。

```
v2okimochi-dev/
├ .gitub/
│ └ workflows/
│   └ cdk.yml (GitHub Actionsワークフローを定義するymlファイルを置く)
│
├ frontend/
│ ├ staging_assets/
│ │ └ robots.txt
│ └ ... (ここにVueのアレコレを置く)
│
├ batch/
│ └ signed_url/
│   └ ... (ここに証明書付きURL作成用のPythonバッチアレコレを置く)
│
└ infra/
  └ aws-cdk/
    └ ... (ここにAWS CDKのアレコレを置く)
```

ほとんどのAWSサービスは [AWS CDK](https://docs.aws.amazon.com/cdk/latest/guide/home.html) ([docsはコレ](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-construct-library.html))でデプロイする。CDKの性質上、CloudFormationを軸に管理されるみたい。

CDKによるデプロイ部分は、 (local上でも良いが、) [GitHub Actions](https://docs.github.com/ja/actions)に定義して **developブランチにpush (merge)されたらステージング環境へデプロイし、masterブランチにpush (merge)されたら本番環境へデプロイする**ような自動デプロイフローを作る。

全体としては、こうなる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201962/f487779d-a7d4-3b57-c6f3-e62a0efd323b.png)


- S3
    - `frontend/dist/`を置く場所
    - CloudFront以外からアクセスさせる気はないのでOrigin Access Identityだけ許す
    - ステージング環境だけ `robots.txt`も置く
        - 念のため検索ロボットによる捕捉も防ぐ
- CloudFront
    - S3内のファイルは全てCloudFrontを通してアクセスさせる
    - HTTPS通信だけ許す
    - 本番環境だけ、予め取得しておいたAWSのパブリック `SSL/TLS証明書`＆独自のドメイン名を設定する
        - 詳細はそれぞれRoute53, Certificate Managerの項目に書いた
        - デフォルトではAWSによってCloudFront用のドメイン名と証明書が付与される
    - ステージング環境だけ、 `index.html`へのアクセスは署名付きURL認証を差し込む (公開されても嬉しくないので)
        - 全ファイルに差し込みたかったが `index.html`以外が403を返してしまい、原因もわからず泣く泣く妥協
        - `index.html`以外のファイルはURLがバレれば未認証でもアクセスできてしまうのがだいぶアレ
- Route53
    - 本番環境だけ、独自ドメインを含むドメイン名 (たとえば `www.mydomain.com`)でCloudFrontへ繋ぐ
    - 今回は `お名前.com` (外部)で登録したドメイン名なので、 `お名前.com`側に **予めRoute53で作ったホストゾーン内のネームサーバー**を登録しておく
    - CDKではAレコード・AAAAレコードの作成をやる (FQDNとCloudFrontドメイン名の紐付け)
- Certificate Manager
    - AWSのパブリック `SSL/TLS証明書`を取得する
    - リージョンは `バージニア北部 (us-east-1)`で作成する (CloudFrontとの結びつけがここしか対応してなさそう)
    - 予めRoute53に `CNAME`レコードとして登録しておく





# AWS側で予めやっておく手動作業
全部cdkでやりたかったけど。。。 :thinking: :thinking: :thinking: 

## AWS外で登録した独自ドメインをAWS Route53で管理
たとえば [お名前.com](https://www.onamae.com/)で独自ドメイン `mydomain.com`を登録した場合。

1. Route53でパブリックホストゾーン `mydomain.com`を作成  
   このホストゾーンIDは後で使うのでメモしておく
2. お名前.comで独自ドメイン `mydomain.com`のネームサーバーを、 "1.によりホストゾーンに自動生成された **NSレコード** `mydomain.com`のネームサーバー"に変更

Route53でメモするネームサーバーはこのへん
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201962/243872ef-8d54-65c3-2f56-ba26753d9af6.png)

お名前.comで変更するネームサーバーはこのへん
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201962/9ded6b2c-c362-9c3c-c8d0-e9b91e5b0ef9.png)



## AWSでSSL/TLS証明書を取得する

1. Certificate Managerでパブリック証明書 `*.mydomain.com`をリクエストする  
   リクエストが作られ、ステータスが `検証保留中`となるはず
2. 証明書のCNAMEレコードをRoute53のホストゾーン `mydomain.com`に追加する  
   ( `Route53に追加`みたいなボタンを押せば自動で追加してくれる)
3. 証明書のARNをメモしておく (CloudFrontとの紐付けをcdkでやるため)





# local上での作業
## shellで手作業
CDKの導入は [Getting started with the AWS CDK](https://docs.aws.amazon.com/cdk/latest/guide/getting_started.html)に従った。

あとCDKは全部TypeScriptで書いた。


```shell:必要なライブラリのインストール
npm install @aws-cdk/aws-s3 @aws-cdk/aws-s3-deployment @aws-cdk/aws-cloudfront @aws-cdk/aws-certificatemanager @aws-cdk/aws-route53 @aws-cdk/aws-route53-targets
```

AWSアカウントIDとリージョンを指定して `CDKTookKit`というCloudFormation スタックを作成

```shell
cdk bootstrap XXXXXXXXXXXX/ap-northeast-1
```

AWS CDKで利用するみたい。作成してないと、こういうエラーを吐かれる。。。 :innocent: 

```:エラー文
❌  v2okimochi-dev failed: Error: This stack uses assets, so the toolkit stack must be deployed to the environment (Run "cdk bootstrap aws://unknown-account/unknown-region")
```

cf. [AWS CDKの'aws-s3-deployment'を使ってクライアントサイドも一緒にデプロイする](https://qiita.com/tetsuya-zama/items/398e9fbae42d31c40db5)






## AWS CDKのstackを記述

<details><summary>各ソースコード (いっぱいあるので折りたたんだ)</summary><div>

```typescript:aws-cdk/bin/aws-cdk.ts
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from '@aws-cdk/core';
import { AwsCdkStack } from '../lib/stacks/v2okimochi-dev';
import * as util from "../lib/util";

const validateEnvironment = (target: string) => {
  /**
   * 意図した環境が `--context`オプションで指定されていない場合、例外終了させる
   */

  if (!target) throw new Error("環境が指定されていません");

  const validTarget = util.findEnvironment(target);
  if (!validTarget) throw new Error(`環境名が正しくありません: ${target}`);

  return validTarget;
};

const productName = "v2okimochi-dev";

const app = new cdk.App();
const target = validateEnvironment(app.node.tryGetContext("target"))

new AwsCdkStack(app, `${productName}-${target}`, target);
```

```typescript:aws-cdk/lib/util.ts
/**
 * プロダクト環境名の定義
 */

export enum Environments {
  PROD = "prod",
  STG = "stg",
}

export function findEnvironment(env: string): string | undefined {
  if (env == Environments.PROD) return Environments.PROD;
  else if (env == Environments.STG) return Environments.STG;
  else return undefined;
}

/**
 * 環境変数の取り出し
 */

export function validateEnvironmentVariable(key: string): string {
  const value = process.env[key];
  if (!value) throw new Error(`環境変数の値が見つかりません: ${key}`);
  return value;
}
```

```typescript:aws-cdk/lib/stacks/v2okimochi-dev.ts
import * as cdk from "@aws-cdk/core";
import * as certificatemanager from "@aws-cdk/aws-certificatemanager";
import * as s3 from "@aws-cdk/aws-s3";
import * as s3deploy from "@aws-cdk/aws-s3-deployment";
import { Environments, validateEnvironmentVariable } from "../util";
import { setupCloudFront } from "../services/cloudfront/setupCloudFront";
import { setupRoute53 } from "../services/route53/setupRoute53";

export class AwsCdkStack extends cdk.Stack {
  constructor(
    scope: cdk.Construct,
    id: string,
    target: string,
    props?: cdk.StackProps
  ) {
    super(scope, id, props);

    /** ##############################
     * S3 Bucket
     */

    const s3WebName = `${id}-web`;
    const s3Web = new s3.Bucket(this, id, {
      bucketName: s3WebName,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // Prod環境以外では、Webクローラに登録させないためのrobots.txtもデプロイする
    const s3WebAssets: s3deploy.ISource[] =
      target === Environments.PROD
        ? [s3deploy.Source.asset("../../frontend/dist")]
        : [
            s3deploy.Source.asset("../../frontend/dist"),
            s3deploy.Source.asset("../../frontend/staging_assets"),
          ];
    new s3deploy.BucketDeployment(this, `${s3WebName}-deployment`, {
      sources: s3WebAssets,
      destinationBucket: s3Web,
    });

    /** ##############################
     * CloudFront, Certificate Manager
     */

    const validDomain = validateEnvironmentVariable(
      "V2OKIMOCHI_DEV_PROD_DOMAIN"
    );
    const validSubDomain = validateEnvironmentVariable(
      "V2OKIMOCHI_DEV_PROD_SUBDOMAIN"
    );
    const fqdn = `${validSubDomain}.${validDomain}`;
    const arn = validateEnvironmentVariable(
      "V2OKIMOCHI_DEV_PROD_CERTIFICATE_ARN"
    );
    const validCertificate = certificatemanager.Certificate.fromCertificateArn(
      this,
      `${id}-acm-certificate`,
      arn
    );

    const cloudFront = new setupCloudFront(this, {
      id: `${id}-cloudfront`,
      target: target,
      s3WebBucket: s3Web,
      certificate: validCertificate,
      fqdns: [fqdn],
    });

    /** ##############################
     * Route53
     */

    const route53 =
      target === Environments.PROD
        ? new setupRoute53(this, {
            id: `${id}-route53`,
            target: target,
            domain: validDomain,
            subDomain: validSubDomain,
            cloudFront: cloudFront,
          })
        : undefined;

    /** ##############################
     * Tag
     */

    cdk.Tag.add(this, "Product", id);
  }
}
```

```typescript:aws-cdk/lib/services/cloudfront/setupCloudFront.ts
import * as cdk from "@aws-cdk/core";
import * as certificatemanager from "@aws-cdk/aws-certificatemanager";
import * as cloudfront from "@aws-cdk/aws-cloudfront";
import * as s3 from "@aws-cdk/aws-s3";
import { Environments, validateEnvironmentVariable } from "../../util";

export interface setupCloudFrontProps {
  /**
   * リソースの共通名 (たとえばプロダクト名)
   */
  readonly id: string;
  /**
   * 環境 (Prodなど)
   */
  readonly target: string;
  /**
   * オリジンとして指定するS3 Bucket
   */
  readonly s3WebBucket: s3.Bucket;
  /**
   * SSL/TLS証明書を取得したAWS Certificate Manager
   */
  readonly certificate: certificatemanager.ICertificate;
  /**
   * CloudFrontのカスタムFQDNリスト
   */
  readonly fqdns: string[];
}

export class setupCloudFront {
  private cloudFrontWebDistribution: cloudfront.CloudFrontWebDistribution;
  public distribution(): cloudfront.CloudFrontWebDistribution {
    return this.cloudFrontWebDistribution;
  }

  constructor(context: cdk.Stack, props: setupCloudFrontProps) {
    /** ##############################
     * 本番 (Prod)以外の環境では、公開しないようにデプロイする
     */

    const signedAccount = validateEnvironmentVariable(
      "V2OKIMOCHI_DEV_SIGNED_ACCOUNT_ID"
    );

    // Prod環境以外では署名付きURLを使用する
    const behaviorsWithEnv: cloudfront.Behavior[] =
      props.target === Environments.PROD
        ? [
            {
              isDefaultBehavior: true,
              compress: true,
              minTtl: cdk.Duration.seconds(0),
              maxTtl: cdk.Duration.days(0),
              defaultTtl: cdk.Duration.days(0),
            },
          ]
        : [
            {
              isDefaultBehavior: false,
              pathPattern: "/index.html",
              trustedSigners: [signedAccount],
              compress: true,
              minTtl: cdk.Duration.seconds(0),
              maxTtl: cdk.Duration.days(0),
              defaultTtl: cdk.Duration.days(0),
            },
            {
              isDefaultBehavior: true,
              compress: true,
              minTtl: cdk.Duration.seconds(0),
              maxTtl: cdk.Duration.days(0),
              defaultTtl: cdk.Duration.days(0),
            },
          ];

    // Prod環境以外では403を200にリダイレクトさせる
    const errorConfigurationsWithEnv: cloudfront.CfnDistribution.CustomErrorResponseProperty[] =
      props.target === Environments.PROD
        ? [
            {
              errorCode: 403,
              errorCachingMinTtl: 0,
              responseCode: 200,
              responsePagePath: "/index.html",
            },
          ]
        : [];

    // Prod環境にだけ独自ドメイン名を割り当てる
    const viewerCertificateWithEnv: cloudfront.ViewerCertificate | undefined =
      props.target === Environments.PROD
        ? {
            aliases: props.fqdns,
            props: {
              acmCertificateArn: props.certificate.certificateArn,
              sslSupportMethod: "sni-only",
            },
          }
        : undefined;

    // CloudFrontだけがS3にアクセスできるようにする (ユーザはS3に直接アクセスできない)
    const oai = new cloudfront.OriginAccessIdentity(
      context,
      `${props.id}-oai`,
      {
        comment: "s3 access.",
      }
    );

    this.cloudFrontWebDistribution = new cloudfront.CloudFrontWebDistribution(
      context,
      `${props.id}-cloudfront`,
      {
        defaultRootObject: "index.html",
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
        viewerCertificate: viewerCertificateWithEnv,
        httpVersion: cloudfront.HttpVersion.HTTP2,
        priceClass: cloudfront.PriceClass.PRICE_CLASS_200,
        originConfigs: [
          {
            s3OriginSource: {
              s3BucketSource: props.s3WebBucket,
              originAccessIdentity: oai,
            },
            behaviors: behaviorsWithEnv,
          },
        ],
        geoRestriction: {
          restrictionType: "whitelist",
          locations: ["JP"],
        },
        errorConfigurations: errorConfigurationsWithEnv,
      }
    );
  }
}
```

```typescript:aws-cdk/lib/services/route53/setupRoute53.ts
import * as cdk from "@aws-cdk/core";
import * as route53 from "@aws-cdk/aws-route53";
import * as route53targets from "@aws-cdk/aws-route53-targets";
import { setupCloudFront } from "../../services/cloudfront/setupCloudFront";
import { validateEnvironmentVariable } from "../../util";

export interface setupRoute53Props {
  /**
   * リソースの共通名 (たとえばプロダクト名)
   */
  readonly id: string;
  /**
   * 環境 (Prodなど)
   */
  readonly target: string;
  /**
   * ドメイン名
   */
  readonly domain: string;
  /**
   * サブドメイン名
   */
  readonly subDomain: string;
  /**
   * CloudFront distribution
   */
  readonly cloudFront: setupCloudFront;
}

export class setupRoute53 {
  constructor(context: cdk.Stack, props: setupRoute53Props) {
    const fqdn = `${props.subDomain}.${props.domain}`;
    const hostedZoneId = validateEnvironmentVariable(
      "V2OKIMOCHI_DEV_PROD_HOSTED_ZONE_ID"
    );

    const hostedZone = route53.HostedZone.fromHostedZoneAttributes(
      context,
      `${props.id}-hosted-zone`,
      {
        zoneName: props.domain,
        hostedZoneId: hostedZoneId,
      }
    );

    const aRecord = new route53.ARecord(context, `${props.id}-a-record`, {
      zone: hostedZone,
      recordName: fqdn,
      target: route53.RecordTarget.fromAlias(
        new route53targets.CloudFrontTarget(props.cloudFront.distribution())
      ),
    });

    const aaaaRecord = new route53.AaaaRecord(
      context,
      `${props.id}-aaaa-record`,
      {
        zone: hostedZone,
        recordName: fqdn,
        target: route53.RecordTarget.fromAlias(
          new route53targets.CloudFrontTarget(props.cloudFront.distribution())
        ),
      }
    );
  }
}
```

</div></details>




アカウントIDとか、ハードコーディングするとアレなやつは環境変数として埋め込むようにした。

- V2OKIMOCHI_DEV_PROD_DOMAIN
    - 本番環境で使う独自ドメイン
    - 今回は `mydomain.com`みたいなやつ
- V2OKIMOCHI_DEV_PROD_SUBDOMAIN
    - 本番環境で使うサブドメイン
    - たとえば `www` (FQDNにすれば `www.mydomain.com`になる)
- V2OKIMOCHI_DEV_PROD_HOSTED_ZONE_ID
    - 予め作っておいたRoute53ホストゾーンのID
- V2OKIMOCHI_DEV_PROD_CERTIFICATE_ARN
    - 予め作っておいたCertificate Manager SSL/TLS証明書のARN
- V2OKIMOCHI_DEV_SIGNED_ACCOUNT_ID
    - 署名済みユーザとして登録するアカウントID
    - 今回は自分のアカウントIDだけ


たとえばこうやってCDKコマンドを使う。
diffで差分と文法エラーを確認、deployで実際にデプロイ、destroyで該当スタックを削除する。

```shell:aws-cdk/
# 本番
$ cdk diff --context target=prod

# ステージング
$ cdk diff --context target=stg
```

実行時の引数でスタックIDごと変えることによって、本番環境とステージング環境を分けるようにした。



### S3の設定
`aws-cdk/lib/stacks/v2okimochi-dev.ts`に書いた。

- `removalPolicy`を `DESTROY`にする
    - CloudFormationスタックが削除される時S3バケットも削除するため  
      cf. [[AWS CDK(Cloud Development Kit)] スタック削除時にS3バケットも削除されるように設定する] (https://dev.classmethod.jp/articles/aws-cdk-s3-delete-policy/)
    - ただしS3バケットが空でないと削除できずにエラー失敗するので、事前にバケットを空にしておく必要がある  
      cf. [Automatically delete an S3 bucket with the AWS CDK stack](https://www.scavasoft.com/aws-cdk-s3-auto-delete/)



### CloudFrontの設定
`aws-cdk/lib/services/cloudfront/setupCloudFront.ts`に書いた。
本番/ステージングの環境ごとに設定を用意したりして長くなったのでファイルを分けている。

- SSL/TLS証明書を予め手作業で (Certificate Managerに)作っておき、そのARNからCDKで参照する
- `trustedSigners`にアカウントIDを指定することで、署名付きURLでのアクセス時にそのアカウントIDで持っているCloudFrontキーペアで照合されるっぽい
    - 自分のアカウントIDだけ指定した場合、CloudFront管理画面上では `self`と表示される
- Route53のレコードでも紐付けるが、CloudFront側にもaliasとして独自ドメイン名を付ける ( `CNAMEs`列に表示される)
- なんとなく日本だけからアクセスできるようにした (geoRestriction)
    - 今回は海外からアクセスされても嬉しくないので、リスクを軽減した
    - CDKでは `文字列のリスト`型なので `JP`を見出すのに苦労した
    - ちなみに国コードは [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)に従っているらしい  
      cf. [AWS - ディストリビューションを作成または更新する場合に指定する値 - 制限 - Countries (国)](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/distribution-web-values-specify.html#DownloadDistValuesRestrictions)


### Route53の設定
`aws-cdk/lib/services/route53/setupRoute53.ts`に書いた。
本番環境でだけ作成する (条件分岐は `aws-cdk/lib/stacks/v2okimochi-dev.ts`のほう)

- ホストゾーンは予め手作業で作っておき、そのID ( `HostedZoneId`)からCDKで参照する
- CDKで作るのはAレコードとAAAAレコード
- どちらもCloudFrontディストリビューションと独自ドメイン名を紐付ける
    - ディストリビューションはCDK内で作成したインスタンスから拾う
    - もしCDKでやらずにWebから操作するなら、紐付け対象を `CloudFront配信`、リージョンを `バージニア北部`にしてCloudFrontドメイン名のほうを貼り付ければ良い (選択候補には出ないので手動で貼り付ける)


### Tagの設定
`aws-cdk/lib/stacks/v2okimochi-dev.ts`の最後にしれっと書いている。
どうせCloudFormationによって管理されてるけど。。。






## 署名付きURLの作成
(手動だけど。。。)

`/batch/signed_url/`に、 **ステージング環境のWebページにアクセスするための署名付きURL**を作成するPythonスクリプトを書いた。

そんな頻繁に定期実行するわけじゃないからバッチと呼ぶことには諸説ありそうだが。。。


### AWS側での事前準備
署名済みユーザ (今回は自分のアカウントID)でCloudFrontキーペアを作り、プライベートキー ( `.pem`)をダウンロードする。
rootユーザとしてログインしないと作れないみたい。

cf. [署名付き URL と署名付き Cookie (信頼された署名者) の作成が可能な AWS アカウントの指定](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-trusted-signers.html)


### スクリプト作成と実行
pipenvで適当にpython3.8の仮想環境を整えて、 [boto3](https://aws.amazon.com/jp/sdk-for-python/)をimportして使う。

```:signed_url/Pipfile
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true

[packages]
boto3 = "==1.4.4"

[dev-packages]
flake8 = "==3.8.3"

[requires]
python_version = "3.8.3"

[scripts]
flake8 = "flake8 --ignore E501 ."
```

([dev-packages]と[scripts]に関する記述はAWSとは無関係なので無くても良い)


公式docsに従い、署名付きURL生成のコードを書く。

```python:signed_url/src/main.py
import datetime
import os

from botocore.signers import CloudFrontSigner
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding


def rsa_signer(message):
    private_pem_path = os.getenv(
        'V2OKIMOCHI_DEV_STG_CLOUDFRONT_PRIVATE_PEM_PATH')
    with open(private_pem_path, 'rb') as key_file:
        private_key = serialization.load_pem_private_key(
            key_file.read(), password=None, backend=default_backend())
    return private_key.sign(message, padding.PKCS1v15(), hashes.SHA1())


def handler():
    key_id = os.getenv('V2OKIMOCHI_DEV_STG_CLOUDFRONT_KEY_ID')
    url = os.getenv('V2OKIMOCHI_DEV_STG_CLOUDFRONT_URL')
    expire_date = datetime.datetime(2020, 7, 28)

    cloudfront_signer = CloudFrontSigner(key_id, rsa_signer)

    # Create a signed url that will be valid until the specfic expiry date
    # provided using a canned policy.
    signed_url = cloudfront_signer.generate_presigned_url(
        url, date_less_than=expire_date)
    print(signed_url)


if __name__ == "__main__":
    handler()
```

cf. [Boto3 Docs - Generate a signed URL for Amazon CloudFront](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cloudfront.html#generate-a-signed-url-for-amazon-cloudfront)

ハードコーディングがアレな値は環境変数にした。

- V2OKIMOCHI_DEV_STG_CLOUDFRONT_PRIVATE_PEM_PATH
    - ダウンロードしたCloudFrontプライベートキー ( `.pem`)へのパス
    - たとえば `/batch/signed_url/`に置いたなら値は `./xxxxx.pem`みたいになる
- V2OKIMOCHI_DEV_STG_CLOUDFRONT_KEY_ID
    - pemファイル名に含まれているkey ID (大文字で20字くらいあるアレ)
- V2OKIMOCHI_DEV_STG_CLOUDFRONT_URL
    - `https://xxxxxxxxxxx.cloudfront.net/index.html`みたいになる



### スクリプトで署名付きURLを発行

この `main.py`を実行して、署名付きURLを発行する。
今回はpipenvでPython仮想環境を作ったので `pipenv run`でやる

```shell:/batch/signed_url/
$ pipenv run python src/main.py
```

- 上記コードでは `2020/7/28`まで有効なURLを発行する
- print文で標準出力されたURLを (改行は取り除いて)使う
- このへんもガチで自動化しようとするなら、コードを[Lambda](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/welcome.html)化して[Cognito](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/what-is-amazon-cognito.html)や[API Gateway](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/welcome.html)と連携させてログインページ作るくらいのことが必要そう
    - Cookie付与とかもしんどかったので今回はクエリパラメータだけで済む署名付きURLにした





## GitHub Actionsの記述
公式docsに従い、GitHubリポジトリに `/.github/workflows/`ディレクトリを用意してyamlで定義する。

cf. https://docs.github.com/ja/actions/configuring-and-managing-workflows/configuring-a-workflow

動きとしてはこんな感じ

- `test_and_build_frontend`
    - `/frontend/`でlintして問題なければbuild
    - (テストがあればtestもやるべきだけど今回はテスト書いてない :innocent: )
- `infra`
    - 前のjobが成功してから動くように `needs`で依存関係を作った
    - `/infra/aws-cdk/`でdiffして問題なければdeploy
    - masterブランチ以外へのpushなら、ステージング環境へのdiffまでやる
    - developブランチへのpushなら、ステージング環境へのdiffとdeployまでやる
    - masterブランチへのpushなら、本番環境へのdiffとdeployまでやる


<details><summary>ソースコード (長いので折りたたんだ)</summary><div>

```/.github/workflows/cdk.yml
name: cdk

on: push
env:
  production-branch: 'refs/heads/master'
  staging-branch: 'refs/heads/develop'
  frontend-working-directory: frontend
  infra-working-directory: infra/aws-cdk
  aws-default-region: 'ap-northeast-1'
jobs:
  test_and_build_frontend:
    runs-on: ubuntu-18.04
    defaults:
      run:
        shell: bash
        working-directory: frontend
    timeout-minutes: 5
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Cache frontend Node modules
        uses: actions/cache@v2
        env:
          cache-name: frontend-cache-node-modules
        with:
          # npm cache files are stored in `~/.npm` on Linux/macOS
          path: ~/.npm
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-build-${{ env.cache-name }}-
            ${{ runner.os }}-build-
            ${{ runner.os }}-
      
      - name: Setup Node
        uses: actions/setup-node@v1
        with:
          node-version: '12.18'
      
      - name: Setup dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Build
        run: npm run build

      - name: Upload frontend built path
        uses: actions/upload-artifact@v2
        with:
          name: frontend-build-path
          path: frontend/dist

  infra:
    needs: [test_and_build_frontend]
    runs-on: ubuntu-18.04
    defaults:
      run:
        shell: bash
        working-directory: infra/aws-cdk
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Cache infra Node modules
        uses: actions/cache@v2
        env:
          cache-name: infra-cache-node-modules
        with:
          # npm cache files are stored in `~/.npm` on Linux/macOS
          path: ~/.npm
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-build-${{ env.cache-name }}-
            ${{ runner.os }}-build-
            ${{ runner.os }}-

      - name: Setup Node
        uses: actions/setup-node@v1
        with:
          node-version: '12.18'

      - name: Setup dependencies
        run: npm ci

      - name: Download frontend built path
        uses: actions/download-artifact@v2
        with:
          name: frontend-build-path
          path: frontend/dist

      - name: CDK Diff (Validate) with Stg
        if: ${{github.ref != env.production-branch }}
        run: npm run cdk diff -- --context target=stg
        env:
          AWS_DEFAULT_REGION: ${{ env.aws-default-region }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          V2OKIMOCHI_DEV_PROD_DOMAIN: ${{ secrets.V2OKIMOCHI_DEV_PROD_DOMAIN }}
          V2OKIMOCHI_DEV_PROD_SUBDOMAIN: ${{ secrets.V2OKIMOCHI_DEV_PROD_SUBDOMAIN }}
          V2OKIMOCHI_DEV_PROD_HOSTED_ZONE_ID: ${{ secrets.V2OKIMOCHI_DEV_PROD_HOSTED_ZONE_ID }}
          V2OKIMOCHI_DEV_PROD_CERTIFICATE_ARN: ${{ secrets.V2OKIMOCHI_DEV_PROD_CERTIFICATE_ARN }}
          V2OKIMOCHI_DEV_SIGNED_ACCOUNT_ID: ${{ secrets.V2OKIMOCHI_DEV_SIGNED_ACCOUNT_ID }}

      - name: CDK Diff (Validate) with Prod
        if: ${{github.ref == env.production-branch }}
        run: npm run cdk diff -- --context target=prod
        env:
          AWS_DEFAULT_REGION: ${{ env.aws-default-region }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          V2OKIMOCHI_DEV_PROD_DOMAIN: ${{ secrets.V2OKIMOCHI_DEV_PROD_DOMAIN }}
          V2OKIMOCHI_DEV_PROD_SUBDOMAIN: ${{ secrets.V2OKIMOCHI_DEV_PROD_SUBDOMAIN }}
          V2OKIMOCHI_DEV_PROD_HOSTED_ZONE_ID: ${{ secrets.V2OKIMOCHI_DEV_PROD_HOSTED_ZONE_ID }}
          V2OKIMOCHI_DEV_PROD_CERTIFICATE_ARN: ${{ secrets.V2OKIMOCHI_DEV_PROD_CERTIFICATE_ARN }}
          V2OKIMOCHI_DEV_SIGNED_ACCOUNT_ID: ${{ secrets.V2OKIMOCHI_DEV_SIGNED_ACCOUNT_ID }}

      - name: CDK Deploy to Stg
        if: ${{github.ref == env.staging-branch }}
        run: npm run cdk deploy -- --context target=stg --require-approval never
        env:
          AWS_DEFAULT_REGION: ${{ env.aws-default-region }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          V2OKIMOCHI_DEV_PROD_DOMAIN: ${{ secrets.V2OKIMOCHI_DEV_PROD_DOMAIN }}
          V2OKIMOCHI_DEV_PROD_SUBDOMAIN: ${{ secrets.V2OKIMOCHI_DEV_PROD_SUBDOMAIN }}
          V2OKIMOCHI_DEV_PROD_HOSTED_ZONE_ID: ${{ secrets.V2OKIMOCHI_DEV_PROD_HOSTED_ZONE_ID }}
          V2OKIMOCHI_DEV_PROD_CERTIFICATE_ARN: ${{ secrets.V2OKIMOCHI_DEV_PROD_CERTIFICATE_ARN }}
          V2OKIMOCHI_DEV_SIGNED_ACCOUNT_ID: ${{ secrets.V2OKIMOCHI_DEV_SIGNED_ACCOUNT_ID }}

      - name: CDK Deploy to Prod
        if: ${{github.ref == env.production-branch }}
        run: npm run cdk deploy -- --context target=prod --require-approval never
        env:
          AWS_DEFAULT_REGION: ${{ env.aws-default-region }}
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          V2OKIMOCHI_DEV_PROD_DOMAIN: ${{ secrets.V2OKIMOCHI_DEV_PROD_DOMAIN }}
          V2OKIMOCHI_DEV_PROD_SUBDOMAIN: ${{ secrets.V2OKIMOCHI_DEV_PROD_SUBDOMAIN }}
          V2OKIMOCHI_DEV_PROD_HOSTED_ZONE_ID: ${{ secrets.V2OKIMOCHI_DEV_PROD_HOSTED_ZONE_ID }}
          V2OKIMOCHI_DEV_PROD_CERTIFICATE_ARN: ${{ secrets.V2OKIMOCHI_DEV_PROD_CERTIFICATE_ARN }}
          V2OKIMOCHI_DEV_SIGNED_ACCOUNT_ID: ${{ secrets.V2OKIMOCHI_DEV_SIGNED_ACCOUNT_ID }}
```

</div></details>

---

- `npm run build`した成果物をupload保存しておき、後のjobでdownloadしてデプロイする
- ブランチ名は `github.ref`で取得できるみたい
- `npm run`のコマンドに引数を渡す際は `--`を間に差し込む  
  cf. https://docs.npmjs.com/cli/run-script
- 今回cdkコマンドには引数として `target`を渡す (環境を判別する)
    - 独自に引数を渡すには `--context`
- `cdk deploy`には `--require-approval never`も加える
    - IAM roleなどセキュリティ関係の変更もスッと通す (デフォルトだと確認が差し込まれてしまい `y`でEnterせねばならない)
- cdkのコードで利用する環境変数は、GitHub側の[secrets](https://docs.github.com/ja/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets)に定義した上でworkflow上の `env`に紐付けておく (今回はstepごとに定めた)
- `timeout-minutes`を仕込んだ (cdkでたまに無限デプロイを始めてしまうので)





# おわり
pushすればこんな感じで動く ( `Actions`タブから確認)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/201962/60f50cf8-2758-957e-b57e-f0780477c315.png)

- 上の画像ではdevelopブランチがpushされたので、ステージング環境のdiffとdeployが行われている
    - 対象外のstepはスキップされるらしい
    - 途中で失敗すると、後のstepはスキップされる
- 画像右上の `Artifacts`はフロー内でアップロードしたfrontendのビルド成果物
- 画像右上の `Re-run all jobs`でActionsを回し直せる
    - 偶然失敗したケースならpushし直さなくて済む
    - CircleCIと比べると、rerun対象のjobを選べない (全jobやり直してしまう)


かなり多くのサイトを参考にしたけど、あまりにもそれぞれのサイトから断片的な情報を組み合わせたので記憶の彼方に。。。 :innocent: