notebook

都内でWEB系エンジニアやってます。

HeadlessChrome on AWS Lambdaでスクレイピングする

前回の続き

AWSだけならSAMがいいと言われていますがまだserverlessもちゃんと触ったことがなかったのでまずはserverlessで実装してみます

内容としては「LabmdaでHeadlessChrome(Puppeteer)を用いてスクレイピングして結果をSlackへ投稿する」です

スクレイピングの中身に関してはとくに触れません

具体的にやることは下記

  • chromeのLayerプロジェクトの実装
    • インストール
    • deploy
  • Lambdaでスクレイピング部分のプロジェクト実装
    • Layerの参照
    • スケジューリング設定
    • 環境ごとの変数切り替え
    • tokenの暗号化、複合化
    • フォントの読み込み
    • スクレイピング処理の実装
    • デプロイ

それぞれ別のディレクトリを作成して別々にデプロイします

Layer用のプロジェクト

インストール

必要なものをインストールしたらデプロイします

今回Chromeのバイナリはchrome-aws-lambdaに入っている方を使うのでpuppeteerからはインストールしないようにします

  • nodejs/.npmrc
puppeteer_skip_chromium_download=true

インストールします

cd nodejs
npm install --save puppeteer serverless chrome-aws-lambda
  • serverless.yml
service: puppeteer
provider:
  name: aws
  runtime: nodejs8.10
  stage: dev
  region: ap-northeast-1

package:
  exclude:
    - serverless.yml

layers:
  modules:
    path: ./
    name: modules
    description: app modules
    compatibleRuntimes:
      - nodejs8.10
  • ディレクトリ構成
serverless.yml
nodejs
|-- node_modules
.....
.....
.....
|-- package-lock.json
`-- package.json

nodejsディレクトリを掘ってそこでnpmモジュールをインストールしています

AWS Lambda レイヤー - AWS Lambda

docs.aws.amazon.com

こうするとLayer用のプロジェクトでインストールしたライブラリが/opt/nodejs/node_modules以下で参照できるようになります

deploy

特別なことはせずにデプロイだけ

npx sls deploy

Layerのデプロイ

50Mギリギリのサイズになりました

ARNは後で使います

スクレイピング用のプロジェクト

次にスクレイピングする処理のLambdaを実装します

Layerの参照

同じservice内であればCloudFormationの書式にしたがってレイヤーのRefを指定することでよしなにレイヤー参照してくれるようになります

  • serverless.yml
layers:
  test:
    path: layer
functions:
  scraping:
    handler: handler.main
    layers:
      - {Ref: TestLambdaLayer}

1つの設定ファイル(もしくはプロジェクト)でLayerとメインのコードを書く場合はこちらの参照方法で行うのが良さそうです

今回serviceが違うので上記方法は使えないのでLayerのプロジェクトをデプロイしたときに出てくるARNをコピーして参照させます

  • serverless.yml(抜粋)
functions:
  scraping:
    handler: handler.execute
    layers:
      - arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:layer:modules:5

ARNで記述するとバージョンが固定になってしまうのでLayerの方を更新したらこちらも更新する必要が出てきてしまいます

なのでできればCfnの書式のほうが良いでしょう

これでLayerに入れたライブラリが使用できるようになります

パスが通っているところに置いたのでいつもスクリプトを書く感じと同様にrequireで読み込めるようになっているはずです

スケジューリング設定

  • serverless.yml(抜粋)
functions:
  scraping:
    handler: handler.execute
    events:
      - schedule: cron(0 1 ? * FRI *)

最初scheduleはcron式でいけるのか! ってことで楽勝だと思っていたらはまりました

ワイルドカードの使い方など通常のcrontabと微妙に違うところがあるようです

なのでまずドキュメントを読んだほうがよさそうです

ルールのスケジュール式 - Amazon CloudWatch Events docs.aws.amazon.com

cron 式の日フィールドと曜日フィールドを同時に指定することはできません。一方のフィールドに値 (または *) を指定する場合、もう一方のフィールドで ? (疑問符) を使用する必要があります。

ということで今回特定の曜日に実行したかったので曜日の指定をしたのですが日のフィールドに?を指定しないといけませんでした

これ気づくまで結構時間かかってしまいました。。。自戒を込めてちゃんとドキュメントは読みましょうw

環境ごとの変数切り替え

両方のプロジェクトで言えることですがステージを定義することでデプロイ先を切り替える事ができます

  • serverless.yml(抜粋)
provider:
  name: aws
  runtime: nodejs8.10
  stage: ${opt:stage, self:custom.defaultStage}
  environment:
    SLACK_USER_ID: ${self:custom.slackUserId.${opt:stage, self:custom.defaultStage}}
custom:
  defaultStage: dev
  slackUserId:
    dev: XXXXXXXX
    prod: YYYYYYYY

例だとprovider.environment.SLACK_USER_IDを開発と本番で分けてます

実行時にstagedevならXXXXXXXXに、prodならYYYYYYYYへ通知が送られるようになります

これでカスタム変数にdev,prod両方のIDを記述してstageによって環境変数にわたす値を変える(通知先を変える)ということができるようになりました

スクリプト側からはconst token = process.env.SLACK_USER_ID;という感じで取得できるようになります

tokenを暗号化、復号化して処理

SystemManagerのパラメータストア + KMSを使ってslackのAPI tokenを取得します

  • SLACK_TOKENのパラメーターをコンソールから設定

とりあえず使ってみるというところを目的としたので今回はマネジメントコンソールから作成します

f:id:swfz:20190411040030p:plain
暗号化したいパラメータの記入

必要項目を入力して作成します

  • serverlessから読み込み

デプロイ時のユーザーにパラメータストア、KMSに対する権限があれば読み込み、復号などができるので権限を付与して試してみます

  • serverless.yml
provider:
  environment:
    SLACK_TOKEN: ${ssm:/lambda/slackToken~true}

タイプ: 安全な文字列を選択した場合は ~trueを付与して ${ssm:名前~true}という形で読み込みます

serverlessの設定ではSLACK_TOKENの環境変数へ書き出すようにしてみます

まず権限がない場合

SSM経由でパラメーターを読めないときは下記のようにwarningが出ます

npx sls deploy

 Serverless Warning --------------------------------------

  A valid SSM parameter to satisfy the declaration 'ssm:/lambda/slackToken~true' could not be found.

Serverless: Packaging service...

これだけだと何が何だかわからないのでcliから確認します

  • aws cliから確認
aws ssm get-parameters --names "/lambda/slackToken"

An error occurred (AccessDeniedException) when calling the GetParameters operation: User: arn:aws:iam::xxxxxxxxxxxxxx:user/hoge is not authorized to perform: ssm:GetParameters on resource: arn:aws:ssm:ap-northeast-1:xxxxxxxxxxxxxx:parameter/lambda/slackToken
  • 権限を付与

documentを見てインラインポリシーをアタッチしただけだとうまく行かなかったのでとりあえずSSM,KMSにある程度権限をもたせたらうまく行きました

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ssm:DescribeParameters",
                "ssm:GetParameter",
                "ssm:GetParameters",
                "ssm:GetParameters"
            ],
            "Resource": "arn:aws:ssm:ap-northeast-1:xxxxxxxxxxxxxx:parameter/lambda/slackToken"
        },
        {
            "Effect": "Allow",
            "Action": [
                "kms:ListKeys",
                "kms:ListAliases",
                "kms:Describe*",
                "kms:Decrypt"
            ],
            "Resource": "arn:aws:kms:ap-northeast-1:xxxxxxxxxxxxxx:key/*"
        }
    ]
}
  • 確認
 aws ssm get-parameters --names "/lambda/slackToken" 
{
    "Parameters": [
        {
            "Name": "/lambda/slackToken",
            "Type": "SecureString",
            "Value": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
            "Version": 1,
            "LastModifiedDate": 1554787408.812,
            "ARN": "arn:aws:ssm:ap-northeast-1:xxxxxxxxxxxxxx:parameter/lambda/slackToken"
        }
    ],
    "InvalidParameters": []
}

パラメータストアから値がとれました

lambdaのdeploy,invokeもうまくいきました

リポジトリに残したくない処理などはパラメータストアを使うことで簡単に暗号化、復号化、使用することができるので気にすることが少なくなるので楽ですね

フォントの読み込み

chrome-aws-lambdaではフォントの読み込みまでサポートしてくれています

前回も紹介したが下記コードで日本語フォントに対応させることができます

  • handler.js(抜粋)
  const chromium = require('chrome-aws-lambda');
  module.exports.execute = async (event, context, callback) => {
    await chromium.font('https://raw.githack.com/googlei18n/noto-cjk/master/NotoSansJP-Black.otf');

ここではフォントのURL指定でraw.githack.comを使っていますがおそらく公式のサービスではないはずなのでrawgit.comみたいにサービス終了のアナウンスがあったりしたら対応をしなくてはならなそうですね

それを踏まえてもまぁ便利ではあると感じます

実際の処理

実際の細かな処理は省きますが下記のようなコードでスクレイピングまで始めることができるようになりました

  • handler.js(抜粋)
'use strict';
const puppeteer = require('puppeteer');
const chromium = require('chrome-aws-lambda');

module.exports.execute = async (event, context, callback) => {
  await chromium.font('https://raw.githack.com/googlei18n/noto-cjk/master/NotoSansJP-Black.otf');

  const browser = await puppeteer.launch({
    args: ['--no-sandbox', '--disable-gpu', '--single-process'],
    executablePath: await chromium.executablePath,
    defaultViewport: chromium.defaultViewport,
    headless: true
  });

  const page = await browser.newPage();
  await page.goto('スクレイピング対象のURL');

  .....
  .....
  ごにょごにょ
  .....
  .....
  • serverless.yml(抜粋)
package:
  exclude:
    - node_modules/**

今回はLayerからライブラリを読み込むようにするのでLambdaにあげないように除外設定を行います

本番のデプロイ

本番は--stage prodをつけるだけです

npx sls deploy --stage prod

Layerも読み込めていそうですね

f:id:swfz:20190411040139p:plain
デプロイ結果

CloudwatchEventでスケジューリングもできていそうです

f:id:swfz:20190411040208p:plain
スケジュール設定

まとめ

思った以上にいろいろと時間がかかってしまいましたがなんとか納得行くところまで進めることができました

1回雛形を作ってしまえば流用できる部分が多いと思うのでしばらくこのパターンで色々作ってみたいなと思います