Headless ChromeとSeleniumをLambdaで動かす

AWSに限らない話ですが、クラウドを基盤とするシステムに関わると、自然とAPIベースで必要な情報を取得する機会は多くなります。ただ、テキストをパースするよりGUIベースで情報を自動取得したい場合も依然とあり、そちらの方が適しているユースケースは依然と多い、と個人的には感じています。

ということで(色々な実現方法がありますが)、今回はHeadless ChromeSeleniumをLambda関数で動かし、webページのスクリーンショットを取得する方法をご紹介します。

本記事ではHeadless ChromeSeleniumをLambda Layerで作成し、Lambda関数から使っています。ついでに開発環境としてCloud 9を使い、デプロイツールとしてServerless Frameworkを使いました。
全体の構成としては、次の通りです。

Serverless Framework

残念ですが、本記事ではServerless Frameworkの紹介は割愛するため、既に導入済みの環境であることを前提とします。
Getting Started with the Serverless Framework and AWS

serverless.com

Lambda Layerの作成

Headless Chrome+WebdriverとSelenium、2つのLayerを作成するために、まずは準備です。

Headless Chrome、Webdriverの準備

ヘッドレス Chrome ことはじめ

WebDriver is now a W3C Recommendation


それぞれ優れた技術ですが、本記事では説明を割愛します。

ヘッドレスモードがリリースされたのは2017年6月。WebDriverは2018年にW3勧告されている仕様で、Seleniumを使う上では重要なコンポーネントです。なお、本記事ではChromeを扱っていますが、Firefoxでもヘッドレスモードは提供されています。

 

ヘッドレスブラウザを自動化する上で必要なものは、"ブラウザの実行バイナリ"と"WebDriver"です。

 

Chromeバイナリは、chromiumソースコードからあの手この手でビルドすると軽量になります。ビルド済みのものが下記Githubプロジェクトでリリースされていました。感謝しつつ、今回はこちらののリリースからchromiumバイナリをダウンロードして使用します。
serverless-chrome

ChromeのWebdriverはchromedriverという名称ですが、ダウンロードはこちら。
ChromeDriver - WebDriver for Chrome


面倒なTipsとして、Chrome本体とChromeDriverの間に互換性の規則があることです。下記ページにある通り、Chromeバージョンのピリオド区切り数字(major,minor,buildの各番号)が一致するChromeDriverでなければ動きません。
Version Selection

Each version of ChromeDriver supports Chrome with matching major, minor, and build version numbers. For example, ChromeDriver 73.0.3683.20 supports all Chrome versions that start with 73.0.3683

個人的経験としても、最初はローカルPCでchromedriverを使うスクリプトを動かしていたら、Chrome本体の自動アップデート機能によってchromedriverとの互換性エラーが勝手に発生し、沢山のエラーが出ました。。。そんなつまらないエラーを回避するためにも、サーバレス環境を一考することは重要だなーと再認識しました。
ご参考ブログ:自動バージョンアップスクリプト「chromedriver_update_tool」を公開しました

なお、chromeとchromedriverのバージョンは上記の通りに守りましたが、seleniumはバージョン気にせず導入しても動作しました。

Seleniumの準備

AWS Lambda Deployment Package in Python

docs.aws.amazon.com

Lambdaに標準外パッケージを含めたい場合、上記ドキュメント通りのディレクトリ構成にする必要があります。そのため、Seleniumをpipインストールする際は、次のようなコマンドが必要です。

pip install -t {任意ディレクトリ名}/lib/python3.x/site-packages selenium
## "python3.x"は使用するPythonバージョン

Chromeの実行バイナリ、ChromeDriver、Selenium、それぞれの準備を済ませ、次のようなディレクトリ構成ができました。

.
├── headless-chrome
│    ├── chromedriver
│    └── headless-chromium
├── selenium
│    └── lib
└── serverless.yml

Lambda Layerの作成

データ一式の準備は完了したので、あとはデプロイするのみです。serverless.ymlの記述は次の通り。 今回、sls create -t aws-python -p {プロジェクト名}でテンプレートを作成した都合上、Pythonバージョンは"3.6"の指定で統一しています。

service: selenium-layer

provider:
  name: aws
  runtime: python3.6
  stage: dev
  region: ap-northeast-1

layers:
  selenium:
    path: selenium
    description: layer for selenium
    CompatibleRuntimes: 
      - python3.6
  chromedriver:
    path: headless-chrome
    description: layer for chromedriver and headless-chromium
    CompatibleRuntimes:
      - python3.6

resources:
  Outputs:
    SeleniumLayerExport:
      Value:
        Ref: SeleniumLambdaLayer
      Export:
        Name: SeleniumLambdaLayer
    ChromedriverLayerExport:
      Value:
        Ref: ChromedriverLambdaLayer
      Export:
        Name: ChromedriverLambdaLayer

serverless.ymlと同じ階層でデプロイ実行すれば、Lambda Layerが2つ作成されます。

# sls deploy -v
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Excluding development dependencies...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service selenium.zip file to S3 (1.32 MB)...
Serverless: Uploading service chromedriver.zip file to S3 (48.67 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
CloudFormation - UPDATE_IN_PROGRESS - AWS::CloudFormation::Stack - selenium-layer-dev
CloudFormation - UPDATE_IN_PROGRESS - AWS::Lambda::LayerVersion - SeleniumLambdaLayer
CloudFormation - UPDATE_IN_PROGRESS - AWS::Lambda::LayerVersion - ChromedriverLambdaLayer
CloudFormation - UPDATE_IN_PROGRESS - AWS::Lambda::LayerVersion - SeleniumLambdaLayer
CloudFormation - UPDATE_COMPLETE - AWS::Lambda::LayerVersion - SeleniumLambdaLayer
CloudFormation - UPDATE_IN_PROGRESS - AWS::Lambda::LayerVersion - ChromedriverLambdaLayer
CloudFormation - UPDATE_COMPLETE - AWS::Lambda::LayerVersion - ChromedriverLambdaLayer
CloudFormation - UPDATE_COMPLETE_CLEANUP_IN_PROGRESS - AWS::CloudFormation::Stack - selenium-layer-dev
CloudFormation - DELETE_IN_PROGRESS - AWS::Lambda::LayerVersion - ChromedriverLambdaLayer
CloudFormation - DELETE_IN_PROGRESS - AWS::Lambda::LayerVersion - SeleniumLambdaLayer
CloudFormation - DELETE_COMPLETE - AWS::Lambda::LayerVersion - SeleniumLambdaLayer
CloudFormation - DELETE_COMPLETE - AWS::Lambda::LayerVersion - ChromedriverLambdaLayer
CloudFormation - UPDATE_COMPLETE - AWS::CloudFormation::Stack - selenium-layer-dev
Serverless: Stack update finished...
Service Information
service: selenium-layer
stage: dev
region: ap-northeast-1
stack: selenium-layer-dev
resources: 4
api keys:
  None
endpoints:
  None
functions:
  None
layers:
  selenium: arn:aws:lambda:ap-northeast-1:123456789012:layer:selenium:2
  chromedriver: arn:aws:lambda:ap-northeast-1:123456789012:layer:chromedriver:2

Stack Outputs
SeleniumLambdaLayerQualifiedArn: arn:aws:lambda:ap-northeast-1:123456789012:layer:selenium:2
SeleniumLayerExport: arn:aws:lambda:ap-northeast-1:123456789012:layer:selenium:2
ChromedriverLayerExport: arn:aws:lambda:ap-northeast-1:123456789012:layer:chromedriver:2
ChromedriverLambdaLayerQualifiedArn: arn:aws:lambda:ap-northeast-1:123456789012:layer:chromedriver:2
ServerlessDeploymentBucketName: selenium-layer-dev-serverlessdeploymentbucket-xxxxxxxxxxxx

Serverless: Run the "serverless" command to setup monitoring, troubleshooting and testing.

なお、上記メッセージは既にLambda Layer作成後に再実行した際です。同じ定義のserverless.ymlであれば、既存のAWSリソースをアップデートする挙動になります。

Lambda関数の準備

Lambda関数に関しては、準備はないのでコードを作成するだけです。 ...というわけには行きませんでした。日本人特有のフォント問題に対処する必要があります。

いわゆる豆腐問題です。デジタル用語辞典:豆腐

最終的に、".fonts"ディレクトリをLambdaのパッケージに含めて一緒にデプロイすることで解決しました。フォントディレクトリについては、Linux一般のはなしで、下記ページが参考になりました。

ご参考ブログ:How to Manage Fonts in Linux

www.linux.com

フォントデータはIPAが配布していたものをダウンロードし、Cloud 9環境に配置します。

個人的にはNotoフォントを使用するつもりでしたが、一式のデータサイズがGB単位だったので断念しました。IPAに感謝です。

No more Tofu

www.google.com

 

準備が完了すると、次のようなディレクトリ構成になりました。

.
├── .fonts
│    ├── ipaexg.ttf
│    └── ipaexm.ttf
├── serverless.yml
└── headless-chrome.py

Lambda関数の作成

Layerの際と同じく、ymlファイルにデプロイ内容を記述します。

service: test-headless-chrome

provider:
  name: aws
  runtime: python3.6
  memorySize: 256
  timeout: 150
  stage: ${opt:stage, 'dev'}
  region: ap-northeast-1
  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - "s3:GetObject"
        - "s3:PutObject"
      Resource: "arn:aws:s3:::{アップロード先のS3バケット名}/*"

functions:
  headless-chrome: 
    handler: headless-chrome.lambda_handler
    description: Headless Chrome
    environment:
      TZ: Asia/Tokyo
      STAGE: ${self:provider.stage}
      S3BUCKET: headless-chrome-${self:provider.stage}
    package:
      include: 
        - '.fonts/**' 
    layers: 
      - arn:aws:lambda:ap-northeast-1:123456789012:layer:chromedriver:1
      - arn:aws:lambda:ap-northeast-1:123456789012:layer:selenium:1

 

  • memorySize, timeout
    メモリサイズとタイムアウトの値は上積みした方が良いです。ヘッドレスとはいえブラウザを起動するため、オーバーヘッドかかります。
  • package
    こちらで".fonts"ディレクトリを定義します。severless docs: Package Configuration
  • layers
    作成したLambda LayerのARNを指定します。

Lambda関数のスクリプト本体は次の通りです。

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import time
import boto3
import os

os.environ['HOME'] = '/var/task'

def lambda_handler(event, context):
    options = webdriver.ChromeOptions()
    options.binary_location = '/opt/headless-chromium'
    options.add_argument('--headless')
    options.add_argument('--no-sandbox')
    options.add_argument('--window-size=1280,1024')
    options.add_argument("--hide-scrollbars")
    options.add_argument("--enable-logging")
    options.add_argument("--disable-application-cache")
    options.add_argument("--disable-infobars")
    options.add_argument('--disable-dev-shm-usage')
    options.add_argument('--disable-gpu')
    options.add_argument("--ignore-certificate-errors")

    driver = webdriver.Chrome('/opt/chromedriver', chrome_options=options)
    s3 = boto3.client('s3')
    url = 'https://xxx'
    bucket = 'アップロード先のS3バケット名'

    driver.get(url)
    body = f"Title: {driver.title}"
    user = driver.find_element_by_css_selector("ログイン名のCSSセレクターパス")
    user.send_keys("ログイン名文字列")
    word = driver.find_element_by_css_selector("パスワードのCSSセレクタ―パス")
    word.send_keys("パスワード文字列")
    
    time.sleep(1)
    driver.save_screenshot('/tmp/test.png')

    response = {
        "statusCode": 200,
        "body": body
    }
    
    s3.upload_file(
        Filename="/tmp/test.png",
        Bucket=bucket,
        Key="test.png")

    driver.close();
    driver.quit();
    return response

os.environ['HOME']

OS環境変数HOMEとLambdaの実行ディレクトリを一致させます。分かりやすくするため、上記コードでは"/var/task"を直指定してますが、将来的な仕様変更に備えるためにも os.getenv(LAMBDA_TASK_ROOT) で指定したほうが安全です。

LAMBDA_TASK_ROOT
options.binary_location

Lambda Layerとして作成したchromiumのパスを指定します。'/opt/'に配置されるのがLambda Layer仕様です。

webdriver.Chrome

Lambda Layerとして作成したchromedriverのパスを指定します。

url

本記事では、は弊社グループウェアのログインページを指定しています。

CSSセレクターパス

自分のhtml知識が皆無のため、Firefoxの開発者モードに頼り切りました。

save_screenshot

Selenium仕様により、.pngフォーマットのみサポートされます。
serverlessを導入していると、次のようにLambda関数を開発環境から実行することも可能です。

# sls invoke -f headless-chrome
{
    "statusCode": 200,
    "body": "Title: ログイン - サイボウズ Office"
}

TOFUも解消しました。

 

最後に

スクリーンショットを自動取得できるようになると、用途は色々考えられます。Webシステムのリグレッションテストはもとより、あるWebページの情報を通知したいだけでも、テキストより画面をみたほうが理解が早い場合は多いはずです。

個人的に今回一番手こずったのは、日本語フォントの文字化け対処でした。普段、意識もしない分野だったので苦戦しましたが、目新しく面白かったです。
Font hinting

en.wikipedia.org

ちなみに、今回使用したchromiumバイナリのように軽量ビルドしたい場合、下記ブログがとても参考になります。興味のある方はどうぞ。

How to get headless Chrome running on AWS Lambda

medium.com

このブログの著者



AWS相談会バナー