GASでテスト書くときのちょうどいい塩梅を探る

この記事は個人ブログの転載です

kotamat.com

GASは気軽にコードが書けるというのもあり、テストを書かないケースが多いのかなとは思っているのですが、とはいえある程度の規模になったらテストも書きたくなってくると思うので、どのへんをライン引きとしてテストを書くか、またそのテストの環境はどう設計するかを考えてみたいと思います。

Level 0: Webコンソールでいじる場合

Webコンソール上で直接触るケースはおそらく非エンジニアも触る環境ないしは、すぐに捨てるコードであることが多いかなと思います。 そういったケースの場合、テスト環境どころか開発環境に対してのカスタマイズする余地が殆どないか、その環境を作る過程で実装が終わってしまうレベルの案件となってしまい、費用対効果が出せない状態になるかと思います。

こういったときは潔くテストは書かず、ただつらつらとコードを書き、サクッとデプロイ・実行するというのが良いかなと思います。

Level 1: 保守が見込める場合

今後ある程度の期間使われ、かつ保守メンテが必要になるであろうスクリプトの場合、コード自体はバージョン管理ツールに載せた上で機能開発をしていく必要があるかと思います。

その場合は clasp を用い、ローカルでの開発をしていくことになるのですが、後述のLevel2に満たないレベルだとしても TypeScript 化して最低限の型担保はしておかないと逆に開発生産性が下がってしまうため、TS化をこの範囲での保守性担保のラインとします。

導入方法

導入方法は簡単で、

yarn add -D clasp @types/google-apps-script

を実行した上で、tsconfig.jsonに下記を記載します。

{
  "compilerOptions": {
    "lib": ["esnext"],
    "experimentalDecorators": true
  }
}

その後、下記コマンドを実行し、スクリプトを作成します

npx clasp create --type standalone --rootDir src

あとはsrcディレクトリにて .ts ファイルを作成し、コードを書いていくだけです。

リリースはclasp pushコマンドで反映でき、clasp openで当該GASをWebコンソールで確認することができます。

メリット

今回依存に追加している@types/google-apps-script はGASでよく使うAPIのインターフェースが予め実装されているため、

  • あれ、Spreadsheetから取得したsheetってどういうAPI持ってるんだっけ?
  • getRangeの引数ってrowが先だったっけ?columnが先だったっけ?

という細かな仕様確認を、型情報をみるだけでわかるようになるため、不必要なtypoや、間違ったコードを書きづらくなります。

当然この状態ではテストコードレベルの品質は担保できないため、API上は問題ないけど意図しない挙動が発生するリスクはあります。 そのため、そのあたりの担保もしたい場合はLevel2を検討する必要が出てきます

Level 2: コードの実行結果が実行してみないとわからない場合

スプレッドシートのデータを高度なロジックで置換する場合や、複数のリソースに対して参照し計算結果を算出する場合、時間発火のロジックを書く場合は、コードの実行結果が実行してみないとわからない状態になるかと思います。

ここまで複雑になってくると、テストコードなしでの開発そのものが生産性悪くなるため、外部APIとの通信をしているところと処理ロジック部分を分離した上で、処理ロジック部分のユニットテストを書く必要が出てきます。

考慮点

GASの場合下記を考慮する必要が出てきます。

  1. GAS本体にテスト実行基盤があるわけではないため、別途テストツールを導入する必要がある
  2. テストコードはGAS上では不要なファイルになるため、rootDir外に必要がある
  3. ファイルを切り出してテスタビリティを向上する場合、ファイルの命名規則が大事になる。

1.に関しては今回はjestを使ってみます。こちらもTSベースでコードを書くため、下記の依存をインストールします。

yarn add -D jest "@types/jest" ts-jest 

2.に関しては、Level1でも紹介したようにデプロイ対象のファイルを src/ディレクトリに入れ、テストコードを同階層の__tests__ディレクトリに設置することで解消します。

3.が躓いたポイントなのですが、GASそのものはexport/importの機能を有していないため、GASでTSを書くとexport/importの記述が消された上で反映されます。 GASはファイル名が若い順にコードを読み込むため、エントリーポイントとなるファイルよりもファイル名が後ろのファイルは読み込まれず、実行時エラーが発生します。 現在はエントリーポイントをindex.ts、依存系ファイルを_utils.tsのように_をつけて先頭で読み込まれるようにしています。ここイケてない感じがすごいので、いい方法あれば教えてほしいです。

実際の書き方

テストを書くとはいえ、このレベル感であればソースコードとしてはそれほど肥大化しないことが想定されます。 雑にUtilsというクラスを用意し、そこに諸々のロジックを詰め込んでいくことを考えてみます。

UtilsはGASがない環境でユニットテストを実行する必要があるため、GASのAPIには依存しないインターフェースにする必要があることだけ考慮し設計していきます。

export class Utils {
  constructor(private now: Date = new Date) {}

  public isDateInMinute(date: Date, minute: number = 5): boolean {
      const minutesAfter: Date = new Date(this.now.getTime())
      minutesAfter.setTime(minutesAfter.getTime() + (1000 * 60 * minute))
      return this.now.getTime() < date.getTime() && date.getTime() <= minutesAfter.getTime()
  }
}

コンストラクタに何を入れるかと言うのは議論の余地はありそうですが、GASで頻出する表現は時間発火の概念なので、第一引数に現在を示す変数を入れていきます。

ここで雑にisDateInMinuteという関数を考えてみます。これは引数に指定した日時が現在時刻と比較して所定の分数の間にあるかどうかを判定する関数です。GASは毎分起動するということを設定できるため、スプレッドシートに記載した時間に発火するとかができるようになります。

これをベースにテストを書いてみます。

import { Utils } from "../src/_utils";

const data_isDateInMinute = [
    {
        now: "2021-06-07 10:00:00",
        target: "2021-06-07 10:01:00",
        inMinutes: 5,
        result: true
    },
    {
        now: "2021-06-07 10:00:00",
        target: "2021-06-07 10:06:00",
        inMinutes: 5,
        result: false
    },
    {
        now: "2021-06-07 10:00:00",
        target: "2021-06-07 10:05:00",
        inMinutes: 5,
        result: true
    },
    {
        now: "2021-06-07 10:00:00",
        target: "2021-06-07 10:00:00",
        inMinutes: 5,
        result: false
    },
]
describe.each(data_isDateInMinute)('Utils.isDateInMinute', (data) => {
    it(`${data.now} to ${data.target} in ${data.inMinutes} minutes?`, () => {
        const util = new Utils(new Date(data.now))
        const targetDate = new Date(data.target)
        expect(
            util.isDateInMinute(targetDate, data.inMinutes)
        ).toBe(data.result)
    })
})

このテストコードでは境界値を確認するテストを入れてみました。jestのdescribe.eachを使うことで、複数のシナリオを同時に実行できます。 大事なのはit()のところで new Utils(new Date(data.now))としているところかなと思います。時間に依存している処理を、コンストラクタでDIすることにより、実行時間に依存しないテストにすることができています。

僕はめんどくさがりなので(?)テーブルドリブンなテストケースの作成にjsonを書くとフラストレーションがたまります。 その場合は下記のようにcsvにテストケースを書き、csvを読み込んでシナリオを構築するのでもいいでしょう。いくらここで依存ファイルをimportしたところでGASのアウトプットには一切入ってこないので。

import { Utils } from "../src/_utils";
import csv from "csv-parse/lib/sync";
import fs from "fs";
import path from "path";

// CSVから同期的に読み込む
const data_isDateInMinute = csv(fs.readFileSync( path.join(__dirname, './data/isDateInMinute.csv')))
describe.each(data_isDateInMinute)('Utils.isDateInMinute', (data) => {
    it(`${data.now} to ${data.target} in ${data.inMinutes} minutes?`, () => {
        const util = new Utils(new Date(data.now))
        const targetDate = new Date(data.target)
        expect(
            util.isDateInMinute(targetDate, data.inMinutes)
        ).toBe(data.result)
    })
})

メリット

当然ここまで書けば、テストしたい粒度のものをUtilにぶちこんであげるだけで簡単にテストをかけるようになってきます。殆どのケースであればここまでやっておけばいいでしょう。

Level 3: Webアプリケーションを作る

もはやここまで来ると、GASが動く環境をサーバーとした、Webアプリケーションを作るレベルになるかと思います。 この領域になるとWebアプリケーションとしての設計やテストを求められるようになるため、webpackでまとめたり、テストの分割の仕方も一筋縄ではいかなくなるかと思います。 infrastructure層をGASの外部APIとしたような設計を行い、ユニットテストだけではなくフィーチャーテストも書くようになるかもしれません。

正直ここの領域までGASでやろうと思ったことはないので未知数ですが、やる機会があったらチャレンジしてみようと思います。

まとめ

GASの保守性担保の方針を考えてみました。もしもっとこういうのやってみるといいかもとかあればTwitterまでいただけるとうれしいです

AWS App Runner でアプリケーションをデプロイする

この記事は個人ブログと同じ内容です

www.ritolab.com


2021 年 5 月下旬 に AWS から App Runner というサービスがローンチされました。

今回は App Runner を使ってアプリケーションをデプロイしてみます。

AWS App Runner

AWS App Runner は、インフラストラクチャを管理せずに AWS にアプリケーションをデプロイするサービスです。

ソースコードまたはコンテナイメージを指定するだけで、App Runner がアプリケーションを自動的にビルドおよびデプロイし、ネットワークトラフィックの負荷を分散し、自動的にスケールアップまたはスケールダウンし、アプリケーションの状態を監視し、暗号化を提供します。

aws.amazon.com

  • ソース(Github ソースコードまたはコンテナイメージ)が update されると自動でデプロイしてくれる(自動・手動は選択可)
  • 自動でスケーリングが行われ、設定したしきい値に従って自動でスケールアップ・ダウンされる
  • 公開されたアプリケーションはロードバランシングされており自動でトラフィックを分散してくれる
  • 証明書(TLS)も管理されデフォルトで付与される URL は HTTPS でアクセス可能。更新も自動で行われる

aws.amazon.com

docs.aws.amazon.com

App Runner では 1 つのデプロイを「サービス」と呼び、サービスを作成するだけでアプリケーションを公開できます。

アプリケーションについて

App Runner では、アプリケーションソースを「Githubソースコード」または「コンテナイメージ」かのどちらかを選択する事ができます。

今回は ECR にコンテナイメージを設置して、それをデプロイしていこうと思います。

アプリケーションについては、以下 App Runner のワークショップで使われていた node のソースとコンテナを使用します。

www.apprunnerworkshop.com

App Runner を構築する

ここから実際に App Runner を使ってデプロイができるまで進めていきます。

いつもであれば構成は terraform で管理するのですが、App Runner は「サービス作成=デプロイ」となるため、App Runner の構成を terraform で管理する必要性がありませんでした。(terraform で予めサービスを作成しておいてそれを何かで起動したり... といった概念ではない)

なので、最低限だけ terraform で作成して、ECR への image の push と、App Runner のサービス作成は Github Actions で行っていきます。

先述のアプリケーションと併せて、最終的には以下のファイル構成になります。

root/
├── .github
│   └── workflows
│       └── deploy.yml
├── Dockerfile
├── index.js
├── package.json
├── source-configuration.json.template
└── terraform
    ├── main.tf
    └── terraform.tfvars

ECR Repository 作成

コンテナイメージを設置する ECR Repository を作成します。

main.tf

# variables
variable "aws_id" {}
variable "aws_access_key" {}
variable "aws_secret_key" {}
variable "aws_region" {}

provider "aws" {
  access_key = var.aws_access_key
  secret_key = var.aws_secret_key
  region     = var.aws_region
}

# ECR Repository
resource "aws_ecr_repository" "app" {
  name                 = "sample_node_for_app_runner"
  image_tag_mutability = "MUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }
}

ECR リポジトリを作成しているだけです。当然空っぽなので、コンテナイメージについては後で Github Actions で push していきます。

ポイントとしては自動デプロイを有効にしたい場合、イメージのタグは固定する必要があるため、image_tag_mutability は MUTABLE である必要がありました。(サービス作成時にイメージのタグも指定するため)

App Runner の IAM Role 作成

App Runner に付与する IAM Role を作成します。

App Runner が ECR にアクセスできるようにするロールです。

main.tf

# IAM Role for AppRunner
## AWS管理ポリシー
data "aws_iam_policy" "AWSAppRunnerServicePolicyForECRAccess" {
  arn = "arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess"
}

## IAM Role - for AppRunner
resource "aws_iam_role" "for_app_runner" {
  name        = "tf-AppRunnerECRAccessRole"
  description = "This role gives App Runner permission to access ECR"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Sid    = ""
        Principal = {
          Service = [
            "build.apprunner.amazonaws.com"
          ]
        }
      },
    ]
  })
}

## Attach Policy to Role
resource "aws_iam_role_policy_attachment" "app_runner" {
  role       = aws_iam_role.for_app_runner.name
  policy_arn = data.aws_iam_policy.AWSAppRunnerServicePolicyForECRAccess.arn
}

ポリシー自体は AWS で管理しているものを使用しています。

デプロイ用 IAM User 作成

Github Actions から ECR への image の push と App Runner のサービス作成を行なうために、デプロイ用の IAM User を作成します。

main.tf

# deploy user
## IAM User
resource "aws_iam_user" "deploy_app_runner" {
  name = "deploy_app_runner"
}

## IAM Policy
resource "aws_iam_policy" "for_deploy_app_runner" {
  name        = "deploy-app-runner-policy"
  description = "ECR push and App Runner operations Policy."
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "ecr:GetAuthorizationToken",
          "ecr:BatchCheckLayerAvailability",
          "ecr:InitiateLayerUpload",
          "ecr:UploadLayerPart",
          "ecr:CompleteLayerUpload",
          "ecr:PutImage",
          "apprunner:ListServices",
          "apprunner:CreateService",
          "iam:PassRole",
          "iam:CreateServiceLinkedRole",
        ]
        Resource = "*"
      }
    ]
  })
}

## attach Policy
resource "aws_iam_user_policy_attachment" "deploy_app_runner" {
  user       = aws_iam_user.deploy_app_runner.name
  policy_arn = aws_iam_policy.for_deploy_app_runner.arn
}

ポリシーは「ECR への push」と「App Runner のサービス作成」のためのミニマムの権限を付与しています。

terraform で IAM User を作成しましたが、アクセスキーは AWS コンソール画面から手動で生成します。

ECR への image 登録と App Runner サービス作成

ここからは Github Actions で ECR への image 登録と App Runner サービス作成を行っていきます。

deploy.yml

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}

      - name: Amazon ECR "Login" Action for GitHub Actions
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - name: Build, tag, and push image to Amazon ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: ${{ secrets.AWS_ECR_REPOSITORY }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:latest .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest

      # App Runner サービス作成
      - name: Create App Runner Service if no exist.
        env:
          AWS_ID: ${{ secrets.AWS_ID }}
          AWS_REGION: ${{ secrets.AWS_REGION }}
          SERVICE_NAME: sample_node
        run: |
          SERVICE=`aws apprunner list-services --query "length(ServiceSummaryList[?ServiceName=='$SERVICE_NAME'])"`
          if [ $SERVICE -eq 0 ]; then
              sed -e "s/<AWS_ID>/${AWS_ID}/" -e "s/<AWS_REGION>/${AWS_REGION}/" ./source-configuration.json.template > ./source-configuration.json
              aws apprunner create-service --cli-input-json file://source-configuration.json
          fi

App Runner に関しては、既にサービスが作成されているかチェックし、存在しなければサービスを作成する。という処理にしています。

実際のところ、AWS CLI を使えばコマンド一発でサービスは作成できます。

docs.aws.amazon.com

今回でいうとここです

aws apprunner create-service --cli-input-json file://source-configuration.json

サービスを作成する際に、設定項目を渡す必要があるので、それは Github Actions の中で json ファイルを作成しています。

アプリケーションのソースに json ファイルのテンプレートを作成しておいて、 Github Actions で必要な設定値を入れているだけです。

source-configuration.json.template

{
    "ServiceName": "sample_node",
    "SourceConfiguration": {
        "ImageRepository": {
            "ImageIdentifier": "<AWS_ID>.dkr.ecr.<AWS_REGION>.amazonaws.com/sample_node_for_app_runner:latest",
            "ImageConfiguration": {
                "Port": "3000"
            },
            "ImageRepositoryType": "ECR"
        },
        "AutoDeploymentsEnabled": true,
        "AuthenticationConfiguration": {
            "AccessRoleArn": "arn:aws:iam::<AWS_ID>:role/tf-AppRunnerECRAccessRole"
        }
    },
    "HealthCheckConfiguration": {
        "Protocol": "TCP",
        "Interval": 10,
        "Timeout": 5,
        "HealthyThreshold": 1,
        "UnhealthyThreshold": 5
    },
    "AutoScalingConfigurationArn": "arn:aws:apprunner:<AWS_REGION>:<AWS_ID>:autoscalingconfiguration/minimum_setting/1/xxxxxxxxxxxxxxxxxxxxxxxxxxx"
}

この辺の項目は AWS コンソール画面でのサービス作成時に出てくるものとほぼ一緒なので、最初はコンソール画面からサービス登録してみるとイメージが掴みやすいと思います。

ちなみに今回は、スケール設定に関してはミニマムの設定にしておきたかったので、デフォルトのものではなくて予め作成した設定を指定しています(minimum_setting)

動作確認

一通りの設定は完了したので、terraform で AWS のリソースを作成したら Github Actions からデプロイすると、App Runner のサービスが作成されます。

f:id:ro9rito:20210614191344p:plain

デフォルトドメインに表示されている URL にブラウザからアクセスすると、アプリケーションが公開されていることが確認できます。

f:id:ro9rito:20210614191411p:plain

また、コンテナイメージが更新される(アプリケーションのソースコードを更新してイメージを再 pushする)と、自動的にデプロイが走ります。

f:id:ro9rito:20210614191423p:plain

ソースが更新されるとサービスも更新されることが確認できました。

f:id:ro9rito:20210614191440p:plain

まとめ

簡単にデプロイできて、ネットワーク周りやスケーリングを気にしなくて良いのは便利だなと思いました。

ただし、コンテナイメージでも github リポジトリでも、1つのソース(イメージ・リポジトリ)しか選択できないので、フロントエンドとバックエンドがソースとして別れている場合はすべてを 1 つのサービスで一撃構築!みたいな用途では使えない。(nginx と php-fpm 2コンテナ兄弟みたいなやつも同じくダメ)

ネットワークからスケーリングまでフルマネージドである特性上、全部のせアプリケーションでないと成立しないっていうのには納得。(片側だけ動かすとかなら良いかも)

Github リポジトリを使う場合も、AWS コンソール画面から App Runner のサービス作成をやってみましたが、サービス作成画面で AWSGithubリポジトリを連携させるだけなので操作は簡単でした。

ちなみにサービス作成してから構築完了までは約 5 分ほど、削除に関してはおよそ 1 分以内程度かかりました。

AWS からは便利なサービスがどんどん出てきますが、特性を知って必要な時に選択肢の一つとして出せるようになっておきたいですね。

Autify を使ってみた所感と Puppeteer と Playwright

この記事は個人ブログと同じ内容です

http://ratatatat30.hatenablog.jp/entry/2021/06/13/114921

sekitats です。

さて、back check では E2Eテストツール Autify が導入されました。

f:id:sekilberg:20210613194119p:plain

1日触ってみたので所感を書きたいと思います。

よかった点

まず、海外発サービスかと思いきや、日本人が作った感のある馴染みやすいUIが良い。

シナリオ作成では、ブラウザでの操作をそのままレコーディングしてテストシナリオとして保存できるのが画期的だ。すぐに使い方を覚えることができた。 これであれば誰でもテストシナリオを書くことができるだろう。

そして、何よりクロスブラウザテストをサポートしているところが嬉しい。これだけで導入するだけの価値はある。

また、AI技術を使って変更の差分を検出するなど、きめ細かなサポートが素晴らしい。

ダミーのメールアドレスを生成してそれを使いまわせるのが便利だし、開発者にとっては JavaScript でコードを実行できるのも柔軟さがあっていい。

気になった点

使い心地で不便を感じたのは、シナリオ作成途中で修正が発生した場合、基本的にブラウザでレコーディングをする以外の方法がないことだ。

それから、例えば、コードであれば当然あるシナリオをコメントアウトやスキップするなりして一部のシナリオを実行させないことができる。一部シナリオを無効化するなどできると嬉しいと感じた。

細かくシナリオを区切ってパーツ化したとしても、最終的に一つのテストプランに書き出すマスタリングのような工程が必要なため、つぎはぎのままではテストを実行できない。

それから、ローカルでの設定、ステージングでの設定など、設定を簡単に切り替えられるのもコードなら簡単にできる。

テストが異常終了した場合もコンソール上であれば分かりやすい。

それなら Autify である必要がないのはもっともだ。

結局、細かいところの制御はコードには勝てない。

要は用途によって使い分けるべきということでしょうか。

しかし、E2Eテストをここまで進化させた Autify を応援したい気持ちでいっぱいです。Autify の更なる進化に期待せざるを得ません。

Master of Puppeteer

f:id:sekilberg:20210613203131p:plain

私は Puppeteer でテストシナリオを書き始めた。Puppeteer は E2E テストフレームワークではないが、Autify で不便に感じたところを解消できそうな気がしたのである。

Puppeteer と聞くと、Metallica“Master of Puppets” が思い浮かぶ。 Metallica の代表的な曲であり海外では有名な曲なので聞いたことない人は一度聞いてみて損はない。(ギターリフを全て異常な速さのダウンピッキングで弾いていることでも有名)

話が脱線しましたが Puppeteer の tips を共有。

iframe 内の要素にアクセス

const frame = await page.frame().find(f => f.name() === 'preview-html')
const button = await frame.$('.button')

タブの切り替え

const pages = await browser.pages() // 全タブを取得
const newTab = pages[pages.length - 1] // 開いたタブを取得

新規タブが開いたあと画面がローディング中のまま進まないことがあった。 一度他のタブに切り替え、新規タブに切り替え直すとローディングから先に進むようになった。

const pages = await browser.pages() // 全タブを取得
...
const prevTab = pages[pages.length - 2] // 一個前のタブ
await prevTab.bringToFront() // 一個前のタブを前面に
await prevTab.waitForTimeout(2000) // 2秒待機
await newTab.bringToFront() // 開いたタブをもう一度選択する

クリップボードにコピー

const documentHandle = await page.evaluateHandle('document');
await page.evaluate((document) => {
    // documentHandle を渡すと evaluate 内でdocumentにアクセスできる
    const oneTimePass = document.getElementById('password').innerText
    navigator.clipboard.writeText(password)
}, documentHandle)
await documentHandle.dispose() // 使い終わったら破棄

クリップボードの中身を取得

const password = await page.evaluate(async () => {
    return await navigator.clipboard.readText()
})

ちなみにクリップボードのアクセスを許可しておかなければならない。

const browser = await puppeteer.launch()
await browser
  .defaultBrowserContext()
  .overridePermissions(' host名 ', ['clipboard-read'])

おまけ playwright も触ってみた

f:id:sekilberg:20210614131150p:plain

playwright とは

Playwrightとは、Microsoftが提供しているWebDriverやpuppeterと同じ種類の自動テスト用のライブラリです。2020年2月1日に、オープンソースで公開されました。ChromiumFirefoxWebKitといったブラウザの動作をプログラムで再現し、自動的にテストをすることができます。

ざっくりいうと puppeteer の制作チームが Microsoft に行って Chromium だけでなく Firefox, WebKit にも対応した新しいライブラリを作ろうってことで puppeteer の改良版 playwright を作ることになったてな感じでしょうか。

今回、最初から playwright で書けば良いのではと聞こえてきそうだが、“Master of Puppeteer” ってどうしても言いたかったのでよいのである。

puppeteer と Playwright との詳細な違いは他の記事に任せるとして、puppeteer とのざっくり変更点。browser インスタンス生成のところだけ変えただけでだいたい動いた。

const { chromium, webkit } = require('playwright');

const browser = await chromium.launch({ ...config })
const context = await this.browser.newContext()
await context.grantPermissions(['clipboard-read'], ' host名 ')

playwright になってタブの操作方法が変わった(以前の書き方でもできそうだが、context ってのを使うっぽい)

const [newPage] = await Promise.all([
  context.waitForEvent('page'),
  page.click('a[target="_blank"]') // Opens a new tab
])
await newPage.waitForLoadState();
console.log(await newPage.title());

(新規タブが開いたあと画面がローディング中のまま進まないところは以前として解決できない。これは他の原因がありそう。)

playwright について言えば、なんとユーザー操作からコードを生成するなんてことができるようだ。機会があれば使ってみて記事を書いてみたい。

まとめ

Autify は素晴らしいツールだ。人力でやっている非効率な作業から解放され、プロダクトの価値向上のためによりリソースを使える。こういったサービスがさらに進化し続け、世の中の負の解消になれば良いと思う。

他のE2Eテストフレームワークもどんどんと進化を続けている。 Karma によるブラウザの自動操作を初めて見たときは衝撃を受けた。もちろん Selenium もあったし、ヘッドレスブラウザに関しては、PhantomJS, CasperJS, Nightwatch.jsNightmare もあった。IEやレガシーEdge がなくなった世界になれば、E2Eテストはさらに楽になる。願ってやまない。

それから今回同じく E2Eテストフレームワークcypress も触ってみた。しかし、cypress は別タブ遷移ができないので痒いところに手が届かなそう、ということで断念。

結局何が言いたかったかというと、Metallica も E2Eテストでも何でも進化を止めてはいけない。という雑なまとめで締めたいと思う。

スカウトメールに 3割 返信もらったはなし

この記事は個人Qiitaと同じ内容です

qiita.com


スカウトメールに 3割 返信もらえたはなし

まえおき

エンジニア採用担当のみなさま、スカウト送信、やってますか?

ビズリーチ、転職ドラフト、Forkwell、キャリトレ、LAPRAS SCOUT等々、スカウト型転職サイトが、特にエンジニア採用において注目されていると思います。

企業にとって採用とは従来、応募や紹介など待ちのスタイルだったものが、スカウト型転職サイトにおけるスカウトの利用によって、必要な時に必要な分だけ企業側から行動を起こせる攻めのスタイルが選択できるという点で、採用の可能性が広がる仕組みと言えます。

ただし、経験ある方ならわかると思いますが、スカウト送信って、1件1件かなり時間がかかるものです。

私は業務の合間に対応して、残業時間も適度に利用して、1日10件送信が限度でした。

それだけ時間をかけて送るスカウトですから、返信率はなるべく高いほうがいい。

スカウト送信の目的

転職サイトへのスカウト送信作業の直接的なゴールは、 良質な面接 数を獲得することかな、と思います。

スカウトの先にあるのは採用面接ですので、企業と求職者の双方が入社してほしい/入社したいと感じるのが真の目標達成ですが、お互いの状況次第でそうはならない場合もあります。

スカウト送信における良質な面接とは

面接後に、企業側がぜひ入社してほしいと思えた方との面接や、または内定水準にはぎりぎり達しなかったけど、実際に会ってみなければわからなかった内容だったから面接できてよかったと腹落ちできる面接は、良質な面接と言ってよさそうです。

なので一つ注意点としては、検索範囲を闇雲に拡げて内定水準にマッチしないことがおおよそわかっている返信が増えてもそれは良質な面接にはつながらないため、自社のニーズにおいて妥協のない検索条件に対してヒットした求職者を送信対象にする、というのは大前提になります。

では以上を踏まえて、今回はスカウトの送信内容にフォーカスして、具体的に行ったことを見返してみましょう。

スカウトテンプレート作成のポイント

まず送信されたすべての求職者に届く定形部分になる、メールテンプレートにおいて気をつけた点があります。

応募ハードルを下げる

まず、応募のハードルを下げるために以下の2点を実施しました。

  • 必要技術スタックはMUST、WANT含め「習得できる技術スタック」として表現し応募ハードルを下げる
  • フルスタックと説明したいところは、表現を和らげて「マルチスタック」と表記する

具体的な技術スタックを、応募資格や応募条件のように表現すると、その技術スタックすべての既修得者だけを募集しているように見えるため、入社後の修得も可能であることを理解してもらうため、習得可能な技術として表現しました。

また、チームのEng.の目標はフルスタックになることではあるのですが、募集に「フルスタック」を書きすぎると気後れする求職者もいるかも知れないと考え、「マルチスタック」と柔らかい表現を使用し、"オラつき感"を抑えました。

伝えたい魅力を伝える

エンジニアとして、こういう項目に喜びを感じる方に入社してほしいという観点から、以下2件を魅力として訴求しました。

  • プロダクトの市場におけるチャレンジングな側面を説明する
  • ユーザー顧客と直接的に関われることを説明する

事業自体がチャレンジングであることと、ユーザーの声と近いことは、大切な価値であると共感いただける方と働きたいと思っています。

カスタマイズ文言作成のポイント

定形テンプレートの次に、最も力を注ぐべき、求職者ごとのカスタマイズ文言において気をつけた点をあげだしてみます。

まず、プロフィール自体と、併せて掲載されているリンクからGitHub、Qiita、スライド、ポートフォリオ、個人ホームページなどに目を通します。

ここで読み取るポイントは以下です。

  • 何を伝えたがっているのかを理解する
  • 他者と異なる点(個性、特性、属性)を探す
  • 求職者がやりたがっていることとこちらがお願いしたいことが一致するポイントを探す
  • 求職者が重視しているらしい環境や労働条件、役割、課題のうち、弊社なら提供、解決できそう、と伝えられそうなポイントを探す
  • その他、特徴的で同調できる取り組みや実績を探す

つまり、論点を抽出すると以下のどれかの切り口になります。

  • あなたが必要である
  • あなたの現状や希望を解決できる
  • あなたの体験に深く同調できる

これらを1つ以上、可能であれば複数掲載できると、より読んでもらいやすいスカウトメッセージになると予想しました。

そしてこれらの観点で読み取った内容を、例文にしてみると以下のようになります。 これらは実際に送信した文章の抜粋です。

  • ◯◯◯ を重視して開発されるスタイルについて理解
  • ご指摘の課題について当社スクラムチームでは継続的に解決しながら開発を進めている
  • お力添えをいただきたい内容が多くぜひともに働きたい
  • アーキテクトを包括的にリードしていただける役割を担っていただけないかと思う
  • スタートアップ企業においてすべて見渡せる規模の環境で xxx 様の技術力を存分に発揮してほしい
  • 技術スタックにおいて当社が導入を検討したい項目が見受けられ、力添えをいただきたい
  • 網羅的に対応し積極的に技術課題を解決されておりオールマイティに広く深い技術知見をお持ちとお見受けできる
  • 当社はでは ◯◯◯ についても対話が可能
  • ご希望の ◯◯◯ につながるキャリアパスも描きやすい環境
  • 技術者として好感の持てるプロセスを実直に遂行されている
  • xxx 様が記載されているフローやアプローチは、当社においても重要な観点
  • ◯◯◯ というフレーズが新鮮で、目を留めてしまった
  • ドキュメントの書き方から、エンジニアとしての誠実さを感じ取ることができた
  • ユーザー目線を大切にされていること、業務改善に高い意識で取り組まれていることが伝わる
  • 仲間やご家族を大切に思う気持ちが伝わる

スカウトされる側の心得...

少しテーマからそれますが、以上の内容は求職者(スカウトされる側)のプロフィールの書き方の参考にもなるかと思います。

求職者さんがスカウトをたくさん送られたい場合は、以下を気にかけると、スカウトを送る側にとって送りやすいプロフィールになるのかもしれません...

  • 企業に必要と思われるスキルや実績のアピール
  • 現業で実現できない、やりたいことや解決したいこと
  • 開発や事業に対する個人の想いや目標

私自身がスカウトされようとした実体験がないので、あくまで仮説の域を出ておりません。 的外れな発言だったらごめんなさい。

まとめ

以上のスカウト送信のポイントを実行して、私の今回のトライアルでは、母数はあまり多くないですが、20件送信して6件の返信、ちょうど3割の返信を獲得し、返信いただいたみなさんとカジュアル面接を実施することができました。

最後に、スカウトに限らず採用においては、私は以前から以下のことは大原則として心得るようにしています。

  • 採用プロセスはごく丁寧な対応に努めること
    • 採用候補者は企業のために時間を割いていただいているため
    • 採用候補者はプロダクトのユーザー、あるいは将来ユーザーになる可能性があるため

スカウトも、求職者の人生に少なからず関わろうとする行為であることを考慮しますと、丁寧にできることをやりきってから連絡するのが最低限のマナーなのかな、と感じました。

Bolt + lambda を使って Slack に通知メッセージを送る API を作る

この記事は個人ブログと同じ内容です

zenn.dev

 

 

Boltを利用してWebアプリと連携し、Slackワークスペースに所属するユーザーに応じて通知を出し分けるAPIを作ってみたので知見として書きます。

当記事で書くこと

  • Slack Appの設定
  • Bolt + serverless によるSlackBotのAPI実装
    • emailからユーザーとのDMチャンネルを検索
    • 取得したDMチャンネルIDへのメッセージ送信
  • lambdaへのデプロイ

当記事で書かないこと

  • Boltを利用したOAuth周りの認証設定

手順

【SlackApp側の設定】

Slack App 作成 ←のリンクから Slack App を作成する

App Home タブにてアプリの DM に表示するタブを設定

  • "App Display Name" の Edit ボタンから、好きな display name を設定して保存する
  • Messages Tab
    • ここのチェックをtrueにすると Messages Tab でユーザーがメッセージを送信できるようになる Allow users to send Slash commands and messages from the messages tab

OAuth & Permissions タブにて

  • Slack API の利用に必要な以下の権限を設定する
"channels:read",
"chat:write:bot",
"groups:read",
"im:read",
"mpim:read",
"users:read",
"users:read.email"

以下のAPIWorks withに必要なscopeが書いてあります

  • Install to WorkSpace する

【Slack App開発】

サンプルコードは以下のリポジトリで公開しています

まずはサーバーレスアプリケーションを開発、デプロイするためのツールをインストールします

こちらの記事で紹介されている事前準備を行ってください

Lambdaの作成

下記コマンドを実行してNode.js用の作業ディレクトリとLambdaの定義ファイル作成します。

$ serverless create --template aws-nodejs --path myService

以下を実行して初期設定を行います

$ yarn init
myService
├── .npmignore
├── handler.js
├── package.json
└── serverless.yml

以下のコマンドで必要なパッケージをインストールします

$ yarn add @slack/bolt @vendia/serverless-express multiparty
$ yarn add -D serverless serverless-offline

package.jsonに以下のscriptを追記します これでyarn devすることでlocalでデバックできるようになりました

package.json

{
    ...
    "scripts": {
        "dev": "sls offline",
        "deploy": "npx serverless deploy"
    },
}

Serverlessの設定ファイルを以下の内容に変更します

serverless.yml

service: serverless-bolt-js
frameworkVersion: "2"
provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
  environment:
    SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET}
    SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN}
functions:
  slack:
    handler: app.handler
    events:
      - http:
          method: ANY
          path: /{any+}
useDotenv: true
plugins:
  - serverless-offline
package:
  patterns:
    - '!.git/**'
    - '!README.md'

あわせて環境変数を追加します

.env

SLACK_SIGNING_SECRET="xxx"
SLACK_BOT_TOKEN="xoxb-xxx"

準備ができたら処理を書いていきます

handler.js

const { App, ExpressReceiver } = require("@slack/bolt");
const serverlessExpress = require("@vendia/serverless-express");
const multiparty = require("multiparty");

const accessToken = process.env["SLACK_BOT_TOKEN"];

const expressReceiver = new ExpressReceiver({
  signingSecret: process.env["SLACK_SIGNING_SECRET"],
  processBeforeResponse: true,
});

const app = new App({
  token: accessToken,
  receiver: expressReceiver,
});

// /slack/events/massegesへのpostリクエストのエンドポイント作成
app.receiver.router.post("/slack/events/masseges", async (req, res) => {
  // req から fields を抽出する
  const data = await new Promise((resolve, reject) => {
    const form = new multiparty.Form();
    form.parse(req, (err, fields, files) => {
      resolve(fields);
    });
  });

  // validation
  if (!data.email) {
    res.status(400).send("error: no_email");
    return;
  }
  if (!data.text) {
    res.status(400).send("error: no_text");
    return;
  }

  const userEmailList = data.email.find((_, i) => i === 0).split(",");
  const massege = data.text.find((_, i) => i === 0);

  let userIds = [];
  for (const email of userEmailList) {
    try {
      // reqパラメーターのemilがワークスペースに存在するか確認
      const user = await app.client.users.lookupByEmail({
        token: accessToken,
        email: email,
      });
      if (user) {
        userIds = [...userIds, user.user.id];
      }
    } catch (error) {
      res.status(400).send(`error: user is Not Found. ${email}`);
      return;
    }
  }

  // DMチャンネル一覧を取得
  const conversationsList = await app.client.conversations.list({
    token: accessToken,
    types: "im",
  });
  const channels = conversationsList.channels;

  if (!!userIds.length) {
    // メールアドレスから取得したユーザーの DM チャンネルのみにフィルター
    channels
      .filter((x) => {
        return userIds.some((y) => {
          return y === x.user;
        });
      })
      .forEach((x) => {
        // メッセージ送信
        app.client.chat.postMessage({
          token: accessToken,
          channel: x.id,
          blocks: massege,
        });
      });
  }
  res.status(200).send("success!!");
});

// Handle the Lambda function event
module.exports.handler = serverlessExpress({
  app: expressReceiver.app,
});

デプロイ

以下コマンドでlambdaへデプロイします

yarn deploy

これで完成です!

試してみる

APIエンドポイントに対してcurlでリクエストを送ってみる (email が一致したユーザーは Slack の DM にメッセージが送信される)

aws

通知メッセージを作成する

paramater

  • email : カンマ( , )区切りで通知を送信したいユーザーのメールアドレスを渡す
  • text : メッセージの block を作成しパラメーターに渡す
$ curl --location --request POST 'https://xxxxxxxxxx.amazonaws.com/dev/slack/events/masseges' \
--form 'email="taro@hoge.com,jiro@hoge.com"' \
--form 'text="[
    {
        \"type\": \"section\",
        \"text\": {
            \"type\": \"mrkdwn\",
            \"text\": \"*twitterフォローしてね!*\"
        }
    },
    {
        \"type\": \"section\",
        \"fields\": [
            {
                \"type\": \"mrkdwn\",
                \"text\": \"https://twitter.com/Area029S\"
            }
        ]
    }
]"'

送信できました!

screeen_shot

あとがき

以上でSlackワークスペースに所属するユーザーに応じて通知を出し分けるAPIを作成することができました。 今後はBoltの認証機能を利用したマルチワークスペース対応の実装例を紹介できればと思います。 ちなみにシンプルに通知のみを実行したいのであればBoltを使わないでサービス側で直接SlackAPIを叩いてしまう方が低コストに実現できます。

参考にした記事

Date系ライブラリとIE11とタイムゾーン

この記事は個人ブログと同じ内容です

http://ratatatat30.hatenablog.jp/entry/2021/05/14/130211


sekitats です。backcheck で主に送りバントをしています。

さて、これまで backcheck では Date系ライブラリとして moment-timezone.js を使っていました。2020年9月 moment.js はすでにメンテナンスモードに入っており、backcheck ではこの度重い腰を上げて代替ライブラリへの移行をすることになりました。

以下の候補から最適なライブラリを選択するため調査を行いました。

  • date-fns (date-fns-tz)
  • Day.js
  • Luxon
  • js-joda(js-jodaについてはほぼ調査していませんが、念の為候補としてあげています。)

IE11 サポートとタイムゾーンの壁

前提として、Backcheck では IE11のブラウザ使用率が数%あり、IE11のサポートを完全に切ることができません。 選定の課題として IE11上でタイムゾーンの機能が正常に動作するかという点が必須項目となりました。

以下メリデメを表にしました。

ライブラリ名 メリット デメリット
Day.js ・他のライブラリと比べて最もファイルサイズが軽い
・moment に寄せて作られているため移行コストが最も安い
・開発継続性高い
・学習コストがほぼいらない
・IE11や timezone 関連のサポートの信頼性が低い(ライブラリの中では最もモダンブラウザに振り切ってる印象)
・IE11で意図した出力がされなかったため致命的と判断
date-fns(date-fns-tz) ・IE11での動作を確認
・採用しているプロジェクトが多い
・開発継続性高い。
・使用経験者がいる
・関数型設計のため、NuxtプロジェクトやPHPユーザーとは相性が悪い
・学習コストが要る
Luxon ・IE11での動作を確認
・moment.js のコントリビューターをしている人が作っているため後継として信頼性が高い
・Moment の後継であり開発継続性は高い
・IE11サポートに関するドキュメントが充実
・あまり人気がない
・使用経験者がいない
・学習コストが要る
js-joda - 独自実装のためIE9までサポートしている ・人気がない
・使用経験者がいない
java.time, Joda-Tome or Noda Time に慣れた人にはメリットだが、おそらくチーム的に不向き
・学習コストがいる

Luxon に決定 🎉

f:id:sekilberg:20210514111452j:plain
IE11で各種日付ライブラリを検証する

本命の Dayjs は、IE11ではタイムゾーン('Asia/Tokyo’)を指定した日時が意図した結果のになりませんでした。

date-fns (date-fns-tz)とLuxon はdate-fnsが標準時の表記(JST)、Luxon が IANA Time Zone 表記('Asia/Tokyo’)といった違いが出たものの日時は意図した時間が出力されました。 あとは好みや使い勝手といったところになってきますが Nuxt プロジェクトやPHPer との相性(Carbonの使用感との差)といった点で date-fns は脱落。最後に残ったのは Luxon でした。もともと Luxon は Moment.js のコントリビューターが作っているということもあり、IE11サポートのドキュメントも充実していてやはり安心感が違います。

polyfill

さて、IE11は UTC以外の特定のタイムゾーンをサポートしていません。IE11でタイムゾーンをサポートするためには polyfill が必要になります。

Option value ‘Asia/Tokyo’ for 'timeZone' is outside of valid range. Expected: ['UTC’]

https://stackoverflow.com/questions/54364061/ie-11-throwing-timezone-is-outside-of-valid-range-when-setting-timezone-to

IE11でタイムゾーンを扱う際の polyifll として date-time-format-timezone があります。しかし minified でも 2.64MBあり、そのままバンドルに含めると moment-timezone.jsよりもファイルサイズが増えてしまうため注意が必要です。さらにリポジトリアーカイブされており今後更新はされないため最新の polyfill を使うようにします。

最新のはこちら formatjs.io

すべて CDNから取得する場合は以下になります。polyfill.io は userAgent を見て polyfill が必要であるかを判断してファイルを返してくれるため、モダンブラウザで不要なファイルのロードをせずに済みます。

// nuxt.config.js
head: {
  script: [
    {
      src: ‘https://polyfill.io/v3/polyfill.min.js?features=Intl.~locale.ja,Intl.Locale,Intl.getCanonicalLocales,Intl.PluralRules.~locale.ja,Intl.NumberFormat,Intl.NumberFormat.~locale.ja,Intl.DateTimeFormat,Intl.DateTimeFormat.~locale.ja,Intl.NumberFormat.~locale.ja’,
      defer: true
    },
  ],
}

しかし、試してみたところ Intl.DateTimeFormat.~locale.ja のところで正しくロードできませんでした。 import する場合は下記のようになるかと思います。(そのほか必要な polyfill は適宜追加してください。 全てCDNにするか、全てimport にするかにしないと運用がたいへん。。)

// plugins/polyfill-datetimeformat.js
import { shouldPolyfill } from "@formatjs/intl-datetimeformat/should-polyfill";
(async function polyfill() {
  if (shouldPolyfill()) {
    try {
      const dataPolyfills = [
        import("@formatjs/intl-getcanonicallocales/polyfill"),
        import("@formatjs/intl-locale/polyfill"),
        import("@formatjs/intl-numberformat/polyfill"),
        import("@formatjs/intl-numberformat/locale-data/ja"),
        import("@formatjs/intl-pluralrules/polyfill"),
        import("@formatjs/intl-pluralrules/locale-data/ja"),
        import("@formatjs/intl-datetimeformat/polyfill"),
        import("@formatjs/intl-datetimeformat/locale-data/ja"),
        import("@formatjs/intl-datetimeformat/add-all-tz"),
      ];
      await Promise.all(dataPolyfills);
    } catch (e) {
      console.error(e);
    }
  }
})();

まとめ

IE11とタイムゾーンのサポートまでしているところはあまりないのではないでしょうか。タイムゾーンは日本で住んでいるとあまり意識することがありませんが、アプリケーションの世界では必ず必要になる概念であり、非常に学びの多いミッションでした。

代替ライブラリの選定にあたり、date-fns (date-fns-tz), Dayjs, Luxon, (js-joda) について多くの記事を調べましたが、IE11とタイムゾーンに言及した情報というのは見た限りはなかったと思います。この記事が有益な情報となれば幸いです。


AWS CloudFront で S3 にホスティングした静的ウェブサイトを CDN 配信(&独自ドメインのHTTPS化)

この記事は個人ブログと同じ内容です

www.ritolab.com


AWS の S3 にホスティングした静的 WEB サイトを CloudFront を用いて CDN での配信を行うと共に、SSL 証明書等の整備も行って独自ドメインHTTPS でアクセスできるようにしていきます。

なお、S3 にホスティングした WEB サイトを独自ドメインHTTPS でアクセスできるようにするにも同じで、今回のように CloudFront を用いて構築する必要があり、手段手順としては同じになります。

S3 にホスティングした静的 WEB サイト

今回は CloudFront や SSL/TLS 証明書回りがメインです。

S3 に静的 WEB サイトをホスティングする部分については前回の記事にまとめています。

今回は前回の記事の続きになるので、環境変数周りや Route53 や S3 の設定等もこちらを確認してください。

www.ritolab.com

実行環境

今回の実行環境は以下になります。

  • Terraform v0.15.1

コードはモジュール化を行わずに一つの tf ファイルに書いていきます。

AWS プロバイダの追加

まずは aws プロバイダを追加します。

既に前回で東京リージョンの aws プロバイダを作成済みですが、ACM で作成した SSL/TLS 証明書を CloudFront にアタッチすためには証明書をバージニア北部リージョンで作成する必要があるため(2021/05/04現在)、別途こちらの aws プロバイダを作成しておきます。

main.tf

provider "aws" {
  access_key = var.aws_access_key
  secret_key = var.aws_secret_key
  region     = "us-east-1"
  alias      = "virginia"
}

リージョンをバージニア北部にし、エイリアスを付けてデフォルトとは別にこちらを指定して使用できるようにしています。

ログ用の S3 バケット作成

CloudFront のアクセスログを格納するバケットを S3 に作成します。

main.tf

## S3 for cloudfront logs
resource "aws_s3_bucket" "cloudfront_logs" {
  bucket = "${local.fqdn.name}-cloudfront-logs"
  acl    = "private"

  tags = {
    Name        = var.project_tag_name
    Environment = var.project_environment_name
  }
}

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

CloudFront のディストリビューションを作成します。

main.tf

## キャッシュポリシー
data "aws_cloudfront_cache_policy" "managed_caching_optimized" {
  name = "Managed-CachingOptimized"
}

## ディストリビューション
resource "aws_cloudfront_distribution" "main" {
  origin {
    domain_name = "${local.bucket.name}.s3-${var.aws_region}.amazonaws.com"
    origin_id   = "S3-${local.fqdn.name}"
  }

  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"

  # Alternate Domain Names (CNAMEs)
  aliases = [local.fqdn.name]

  # 証明書の設定
  viewer_certificate {
    cloudfront_default_certificate = false
    acm_certificate_arn            = aws_acm_certificate.main.arn
    minimum_protocol_version       = "TLSv1.2_2019"
    ssl_support_method             = "sni-only"
  }

  retain_on_delete = false

  logging_config {
    include_cookies = true
    bucket          = "${aws_s3_bucket.cloudfront_logs.id}.s3.amazonaws.com"
    prefix          = "log/"
  }

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "S3-${local.fqdn.name}"
    viewer_protocol_policy = "allow-all"
    compress               = true
    cache_policy_id        = data.aws_cloudfront_cache_policy.managed_caching_optimized.id
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
}
  • キャッシュポリシー
    • 今回は特に細かく設定する必要がないためマネージドのものを使用しています。
    • 管理キャッシュポリシーの使用
  • ディストリビューション
    • 証明書の設定
      • acm_certificate_arn は、この次で設定する ACM の値を設定します。
      • 前項でバージニア北部リージョンのプロバイダを作成しましたが、記事執筆時点では us-east-1 region のものしか指定できません。

SSL/TLS 証明書 作成

ACM(AWS Certificate Manager) にて SSL/TLS 証明書を作成し、独自ドメインに紐づけていきます。

aws.amazon.com

証明書のリクエス

SSL/TLS 証明書の発行を ACM にリクエストします。

main.tf

resource "aws_acm_certificate" "main" {
  provider                  = aws.virginia
  domain_name               = local.fqdn.name
  validation_method         = "DNS"
}
  • プロバイダはバージニア北部リージョンの方を指定しています。
  • 今回使用する独自ドメインFQDN を指定しています。
  • 検証は DNS で行なうように指定しています。

CNAME レコード 作成

証明書リクエストに対して DNS で検証を行なうため、CNAME レコードを追加します。

main.tf

## CNAME レコード
resource "aws_route53_record" "main_acm_c" {
  for_each = {
    for d in aws_acm_certificate.main.domain_validation_options : d.domain_name => {
      name   = d.resource_record_name
      record = d.resource_record_value
      type   = d.resource_record_type
    }
  }
  zone_id = data.aws_route53_zone.naked.id
  name    = each.value.name
  type    = each.value.type
  ttl     = 172800
  records = [each.value.record]
  allow_overwrite = true
}

証明書の検証

証明書のリクエストに CNAME レコードを連携させ、DNS 検証の完了を確認します。

main.tf

## ACM 証明書 / CNAME レコード 連携
resource "aws_acm_certificate_validation" "main" {
  provider        = aws.virginia
  certificate_arn = aws_acm_certificate.main.arn
  validation_record_fqdns = [for record in aws_route53_record.main_acm_c : record.fqdn]
}

独自ドメインの向き先を変更する

最後に、前回までは S3 に向いていた独自ドメインの向き先を CloudFront に変更します。

main.tf

## A レコード(to CloudFront)
resource "aws_route53_record" "main_cdn_a" {
  zone_id = data.aws_route53_zone.naked.zone_id
  name    = local.fqdn.name
  type    = "A"
  alias {
    evaluate_target_health = true
    name                   = aws_cloudfront_distribution.main.domain_name
    zone_id                = aws_cloudfront_distribution.main.hosted_zone_id
  }
}

CloudFront へ A レコードを設定しました。

前回の記事で S3 に向けた A レコードは削除します。

## 削除
## A レコード(to S3 Bucket)
//resource "aws_route53_record" "main_a" {
//  zone_id = data.aws_route53_zone.naked.zone_id
//  name    = local.fqdn.name
//  type    = "A"
//  alias {
//    evaluate_target_health = true
//    name = "s3-website-${var.aws_region}.amazonaws.com"
//    zone_id = aws_s3_bucket.app.hosted_zone_id
//  }
//}

動作確認

ここまでで terraform から環境を構築すると、CloudFront 及び HTTPS でのアクセスが可能となっている事が確認できます。

f:id:ro9rito:20210514061244p:plain

HTTPS でのみのアクセスにする

現時点では http と https どちらでもアクセス可能な状態なので、http でアクセスがきたものは https にリダイレクトするようにします。

main.tf

# allow-all を redirect-to-https に変更する
# viewer_protocol_policy = "allow-all"
viewer_protocol_policy = "redirect-to-https"

aws_cloudfront_distribution リソースの default_cache_behavior にある viewer_protocol_policy の値を allow-all から redirect-to-https へ変更します。

これで http でアクセスがきたものは https にリダイレクトされるようになります。

S3 へのアクセスを制限する

訪問者が S3 のバケットへ直接アクセスしないように制限を掛け、CloudFront ディストリビューションを介してのみアクセスできるようにします。

docs.aws.amazon.com

Origin Access Identity(OAI)作成

Origin Access Identity(以下、OAI)を作成します。

main.tf

## CloudFront OAI 作成
resource "aws_cloudfront_origin_access_identity" "main" {
  comment = "Origin Access Identity for s3 ${local.bucket.name} bucket"
}

OAI をディストーションに関連付ける

OAI で作成した CloudFront ユーザーをディストリビューションに関連付けます。

main.tf

resource "aws_cloudfront_distribution" "main" {
  origin {
    # 追加
    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.main.cloudfront_access_identity_path
    }
  }
  .
  .
  .

aws_cloudfront_distribution リソースの origin に s3_origin_config として origin_access_identity を設定します。

IAMポリシードキュメント作成

アクセスを制御するポリシードキュメントを作成します。(ここで作成したポリシーを json としてアタッチする)

main.tf

# IAMポリシードキュメント作成
data "aws_iam_policy_document" "s3_policy" {
  statement {
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.app.arn}/*"]

    principals {
      identifiers = [aws_cloudfront_origin_access_identity.main.iam_arn]
      type        = "AWS"
    }
  }
}

ポリシーをバケットにアタッチ

作成したポリシーをアプリケーションのバケットに紐付けます。

main.tf

# ポリシーをバケットに紐付け
resource "aws_s3_bucket_policy" "main" {
  bucket = aws_s3_bucket.app.id
  policy = data.aws_iam_policy_document.s3_policy.json
}

アプリケーションのバケットへのアクセス制御変更

前回の記事で作成したアプリケーションを配置するバケットでは、PublicRead が ON になっている状態です。これをプライベートに変更します。

main.tf

## S3 for Static Website Hosting
resource "aws_s3_bucket" "app" {
  bucket = local.bucket.name
  acl    = "private"

  website {
    index_document = "index.html"
    error_document = "error.html"
  }

  tags = {
    Name        = var.project_tag_name
    Environment = var.project_environment_name
  }
}

これで、作成した OAI での CloudFront からのアクセスのみ S3 バケットへのアクセスが可能になりました。

S3 アプリケーションログ用のバケット削除

この時点で S3 へのアクセスは全て CloudFront 経由になり、S3 への訪問者のアクセスログバケットは不要になるので、前回の記事で作成したバケットは削除します。

main.tf

# 削除
//data "aws_canonical_user_id" "current_user" {}

# 削除
## S3 for app logs
## https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket
//resource "aws_s3_bucket" "app_logs" {
//  bucket = "${local.bucket.name}-logs"
//
//  grant {
//    id          = data.aws_canonical_user_id.current_user.id
//    permissions = ["FULL_CONTROL"]
//    type        = "CanonicalUser"
//  }
//  grant {
//    permissions = ["READ_ACP", "WRITE"]
//    type        = "Group"
//    uri         = "http://acs.amazonaws.com/groups/s3/LogDelivery"
//  }
//
//  tags = {
//    Name        = var.project_tag_name
//    Environment = var.project_environment_name
//  }
//}

動作確認

ここまでの設定を適用させると、ブラウザから S3 の URL でコンテンツへアクセスできなくなった事が確認できました。

f:id:ro9rito:20210514061805p:plain

f:id:ro9rito:20210514061813p:plain

まとめ

静的な WEB を公開するのであればコスト面で優れている S3 へのホスティングはおすすめです。

また、CloudFront も使うと高速にコンテンツを配信する、独自ドメインHTTPS 化するなど、できる幅も広がるのでおすすめです。