CloudWatch Synthetics & Puppeteer ことはじめ

今さらながらCloudWatch SyntheticsでWebサイトの監視をしたのでまとめます。

# この記事の内容
* CloudWatch Syntheticsのために手動でS3バケットとIAM Roleを作る
* ローカル環境にPuppeteerをインストールしてNode.jsのコードを書く
  * Googleのトップページに「猫 wikipedia」を入力して検索する
  * トップにWikipediaの[記事](https://ja.wikipedia.org/wiki/%E3%83%8D%E3%82%B3)が出てこなかったら異常事態なのでスクショを撮ってCloudWatchでアラートを上げることにする
* このコードをCloudWatch Syntheticsに持って行ってCanaryを作成する

きちんとした環境構築などはしていません。非常に意識の低い内容となっています。。

# まず最初に：勝手にバケットやIAMを作ってほしくない
最初のセットアップで `s3://cw-syn-results-999999999999-ap-northeast-1` というS3バケット、 `CloudWatchSyntheticsRole-canary-123-4567890abcde` というIAM Roleが勝手に作られるのでちょっと気分が良くありません。手動で作ることにします。（気にしない人は次へ進みましょう）

<details><summary>IAM Role作成手順</summary><div>
## 空のIAM Roleを作る
IAM RoleのPathは `/service-role/` である必要があるようです。現状コンソールからはそのようなIAM Roleを作成することはできない（無条件で `/` になる）のでCLIを叩きましょう。

このようなJSONを作っておいて、

```json:policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
```

これで希望通りのPathを持った空のIAM Roleが作成できます。

```
aws iam create-role --role-name cloudwatch-synthetics --path /service-role/ --assume-role-policy-document file://policy.json
```

## IAM Policyをアタッチする
必要な権限を記述したインラインポリシーをアタッチして終わりです。
[2020-05-26追記] VPC内で動かすためのポリシーを追加しました。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetBucketLocation"
            ],
            "Resource": [
                "arn:aws:s3:::YOUR-BUCKET-NAME/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogStream",
                "logs:PutLogEvents",
                "logs:CreateLogGroup"
            ],
            "Resource": [
                "arn:aws:logs:ap-northeast-1:999999999999:log-group:/aws/lambda/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListAllMyBuckets"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Resource": "*",
            "Action": "cloudwatch:PutMetricData",
            "Condition": {
                "StringEquals": {
                    "cloudwatch:namespace": "CloudWatchSynthetics"
                }
            }
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:CreateNetworkInterface",
                "ec2:DescribeNetworkInterfaces",
                "ec2:DeleteNetworkInterface"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}
```
</div></details>

# スクリプトの文法が良く分からんしテスト実行が遅い
CloudWatch Syntheticsの実体はAWS Lambda (Node.js)で、裏でHeadless Chromeを[Puppeteer](https://github.com/puppeteer/puppeteer)経由で動かしています。遅いのも当然ですしスクリーンショットに日本語が出てこないのも当然です（フォントを入れるにはLambdaの容量制限が厳しすぎます）。そこでローカルでコードを書いてから持って行くのが良さそうなのでその準備をします。なお今回Dockerは**使っていません**のでよろしくお願いします。

## ローカルでPuppeteerを動かす準備をする
### 新しめのNode.js をインストールする
[n package](https://www.npmjs.com/package/n)を使う[方法](https://qiita.com/seibe/items/36cef7df85fe2cefa3ea)が最もお手軽ですが、一時的に動かなかったという[報告](https://qiita.com/shio317/items/8e1a125bd73aa814c1e8)があったのでうまくやってください。なお2020/5/20時点においてCloudWatch SyntheticsはNode.js v10を利用しているそうです。そんなに複雑な文法を使わなければv12でもまあ問題はないでしょう。

### 依存モジュールをインストールする
[公式](https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md#chrome-headless-doesnt-launch-on-unix)の手順にある通り、必要なものを全部突っ込みます。勢いが大切です。

```sh:Debian/Ubuntu
sudo apt install ca-certificates fonts-liberation gconf-service libappindicator1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils
```

```sh:CentOS
sudo yum install alsa-lib.x86_64 atk.x86_64 cups-libs.x86_64 GConf2.x86_64 gtk3.x86_64 ipa-gothic-fonts libXcomposite.x86_64 libXcursor.x86_64 libXdamage.x86_64 libXext.x86_64 libXi.x86_64 libXrandr.x86_64 libXScrnSaver.x86_64 libXtst.x86_64 pango.x86_64 xorg-x11-fonts-100dpi xorg-x11-fonts-75dpi xorg-x11-fonts-cyrillic xorg-x11-fonts-misc xorg-x11-fonts-Type1 xorg-x11-utils
```

### 空のディレクトリを作って初期化する
```sh
mkdir hello-puppeteer; cd $_; npm init -y
```

### Puppeteerをインストールする
```sh
npm install --save puppeteer
```

### サンプルコードを書いて実行してみる
[公式](https://github.com/puppeteer/puppeteer#usage)のサンプルコードをちょっと変更して持ってきました。

```javascript:index.js
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://www.google.com');
  await page.screenshot({path: 'example.png'});
  await browser.close();
})();
```
さっそく実行しましょう。

```sh
node index.js
```

うまく行けば同じディレクトリの中にGoogleのトップページのスクリーンショット、 `example.png` という画像ファイルが生成されているはずです。

![example.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/2c387d9a-dcf7-cd01-ce5c-ed629e3f9e84.png)

おめでとうございます！　ここまでで最低限のコードは書けるようになりました！（フォントがないので日本語は全部文字「?」になってしまっています。どうせAWSに持って行くとこれは避けられないので諦めましょう）

# CSS SelectorとXPath
単なる死活監視ならこれでオッケーですが、検索や検索結果検証を行うためにはHTML内の要素を探し出して取得する必要があります。そのために必要な武器がCSS Selectorです。XPathに慣れている人はXPathでもいいです。どちらもHTMLのツリー構造をたどり、必要な要素――文字を入力したりボタンを押したり文字列を探し出したり――を探し出すのに使うことができます。こちらの記事がとても参考になりました。

[puppeteerでの要素の取得方法 - Qiita (@go_sagawaさん)](https://qiita.com/go_sagawa/items/85f97deab7ccfdce53ea)

XPathの文法はそれなりに複雑ですし、スクレイピングを柔軟に[^2]行うためにはある程度きちんと書く必要がありますが、**対象のWebサイトの構造が固定であるという前提**があれば何も覚える必要はありません。Chromeで対象の要素を右クリックし、「要素を調査」で開発者ツールを開き、対象の要素がハイライトされていることを確認したら、そこでさらに右クリックして「Copy XPath」をクリックするだけです。
[^2]: Chromeが生成するXPathをそのまま使うとちょっと要素が増減したりしただけで動かなくなったりします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618258/824eed28-5293-839a-8510-08d4c158c92a.png)


# コードを書いてみよう
## XPath版
必要なXPathが入手できたので実際にコードを書いてみます。

```javascript:index.js&nbsp;（XPathバージョン）
const puppeteer = require('puppeteer');
(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  const searchTextboxXPath = '//*[@id="tsf"]/div[2]/div[1]/div[1]/div/div[2]/input';
  const firstSearchResultXPath = '//*[@id="rso"]/div[1]/div/div/div[1]/a/h3';
  await page.goto("https://www.google.com");

  // 検索テキストボックスが見つかるまで待つ（タイムアウト3秒）
  await page.waitForXPath(searchTextboxXPath, { timeout: 3000 });
  // テキストボックスを取得（XPathでは配列で結果が返るので最初の要素を取る）
  const textbox = (await page.$x(searchTextboxXPath))[0];
  // フォーカスを合わせるために1回クリックしておく
  await textbox.click();
  // ディレイを入れつつキー入力
  await page.keyboard.type("猫 wikipedia", { delay: 100 });
  // エンターキーを押す。検索画面に移動するはず
  await page.keyboard.press('Enter');

  // 見出しが出てくるまで待つ（タイムアウト3秒）
  await page.waitForXPath(firstSearchResultXPath, { timeout: 3000 });
  // 見出しを取得（これも配列なので最初の1個を取る）
  const firstSearchResult = (await page.$x(firstSearchResultXPath))[0];
  // 文字列を取得するためのやり方
  const result = await (await firstSearchResult.getProperty('textContent')).jsonValue();
  await page.screenshot({ path: "example.png" });
  if (result !== "ネコ - Wikipedia") {
    throw new Error("Wikipedia dokka itta nya!!!");
  }
  await browser.close();
})();
```

## CSS Selector版
ほとんど変わりませんがCSS Selector版も書いておきます。

```javascript:index.js&nbsp;（CSS&nbsp;Selectorバージョン）
const puppeteer = require('puppeteer');
(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  const searchTextboxSelector = '#tsf input[type="text"]';
  const firstSearchResultSelector = '#rso div.r > a > h3';

  await page.goto("https://www.google.com");
  // 検索テキストボックスが見つかるまで待つ（タイムアウト3秒）
  await page.waitFor(searchTextboxSelector, { timeout: 3000 });
  // テキストボックスを取得
  const textbox = (await page.$(searchTextboxSelector));
  // フォーカスを合わせるために1回クリックしておく
  await textbox.click();
  // ディレイを入れつつキー入力
  await page.keyboard.type("猫 wikipedia", { delay: 100 });
  // エンターキーを押す。検索画面に移動するはず
  await page.keyboard.press('Enter');

  // 見出しが出てくるまで待つ（タイムアウト3秒）
  await page.waitFor(firstSearchResultSelector, { timeout: 3000 });
  // 見出しを取得
  const firstSearchResult = (await page.$(firstSearchResultSelector));
  // 文字列を取得するためのやり方
  const result = await (await firstSearchResult.getProperty('textContent')).jsonValue();
  // スクショを取っておく
  await page.screenshot({ path: "example.png" });
  // 求める値になっているかチェック
  if (result !== "ネコ - Wikipedia") {
    throw new Error("Wikipedia dokka itta nya!!!");
  }
  await browser.close();
})();
```

# CloudWatch Syntheticsにコピペしよう
「ハートビートのモニタリング」を選ぶとひな形のコードが出てきます。

```javascript
var synthetics = require('Synthetics');
const log = require('SyntheticsLogger');

const pageLoadBlueprint = async function () {
    // INSERT URL here
    const URL = "https://google.com";
    let page = await synthetics.getPage();
    // 中略
};

exports.handler = async () => {
    return await pageLoadBlueprint();
};
```

`page` オブジェクトは既に与えられているので、セレクタ宣言部分から `await browser.close()` 手前までをコピペすればいいですね。またスクリーンショットはローカルに保存することはできないので

```javascript
await synthetics.takeScreenshot("hoge");
await synthetics.takeScreenshot("fuga", "piyo");
```

などとSyntheticsの提供するメソッドを使う必要があります。（引数に与えた文字列がハイフンで結合されてファイル名になり、png形式で保存されます）

# CloudWatchのアラームを作成する
CloudWatch Syntheticsからも直接アラームは作成できますが、アラームの名前が勝手に決まってしまうためちょっとイケていません。アラームを作ったらChatbot経由でSlack通知をするなどしたら監視は完成です。