AWS Lambda + Serverless Framework + Selenium でつまずいたこと

これは YAMAP エンジニア Advent Calendar 2022 5日目の記事です。

https://qiita.com/advent-calendar/2022/yamap-engineers

# はじめに
以前に Heroku で Selenium を動かすということをやっていたのですが、諸般の事情により Heroku から AWS Lambda への移行が必要となりました。簡単にできるだろうと高をくくっていたのですが、いくつかハマりポイントがあったのでここに記しておきます。

https://qiita.com/higuhey/items/565cad6f6cc37f66b153

# やりたいこと
とある Web サイトを表示して、スクリーンショットを AWS S3 に保存します。

# 結論
最終的には以下の構成となりました。

## 環境/バージョン情報
- serverless
  - Framework Core: 3.24.1
  - Plugin: 6.2.2
  - SDK: 4.3.2
- ChromeDriver 2.40
- Headless Chromium v1.0.0-45
- AWS Lambda
  - Amazon Linux
  - Python 3.7

## ディレクトリ構成
lambda: Selenium を実行する Lambda 関数
selenium-layer: Selenium 関連と font ファイルをアップロードする Lambda Layer

```
$ tree -L 2
.
├── lambda
│   ├── handler.py
│   └── serverless.yml
├── node_modules
└── selenium-layer
    ├── driver
    ├── fonts
    ├── selenium
    └── serverless.yml
```

## Selenium と font を Lambda Layer にデプロイする

- serverless framework をインストール

```
$ npm install -g serverless
```

- headless-chrome をインストール

```
$ CHROMEVERSION="v1.0.0-45" \
&& CHROMEFILE=https://github.com/adieuadieu/serverless-chrome/releases/download/${CHROMEVERSION}/stable-headless-chromium-amazonlinux-2017-03.zip \
&& curl -SL $CHROMEFILE > headless-chromium.zip \
&& unzip headless-chromium.zip \
&& rm headless-chromium.zip \
&& mv headless-chromium selenium-layer/driver/
```

- chromedriver をインストール

```
$ DRIVERVERSION="2.40" \
&& CHROMEDRIVER=https://chromedriver.storage.googleapis.com/${DRIVERVERSION}/chromedriver_linux64.zip \
&& curl -SL $CHROMEDRIVER > chromedriver.zip \
&& unzip chromedriver.zip \
&& rm -rf chromedriver.zip \
&& mv chromedriver selenium-layer/driver/
```

- Selenium をインストール

```
$ pip install -t selenium/python/lib/python3.7/site-packages 'selenium<4'
```

- selenium-layer/fonts 配下にフォントファイルを配置
フォントは [IPAex フォント](https://moji.or.jp/ipafont/ipafontdownload/) を利用しました。

```
.
└── selenium-layer
    └── fonts
        └── .fonts
            ├── ipaexg.ttf
            └── ipaexm.ttf
```

- selenium-layer/serverless.ymlを編集

```yml
service: selenium-layer

custom:
  pythonVer: python3.7
  stage: dev
  region: ap-northeast-1

provider:
  name: aws
  runtime: ${self:custom.pythonVer}
  stage: ${self:custom.stage}
  region: ${self:custom.region}

layers:
  selenium:
    path: selenium
    description: selenium layer
    compatibleRuntimes:
      - ${self:custom.pythonVer}
  chromedriver:
    path: driver
    description: chrome driver layer
    compatibleRuntimes:
      - ${self:custom.pythonVer}
  fonts:
    path: fonts
    description: fonts for selenium

resources:
  Outputs:
    SeleniumLayerExport:
      Value:
        Ref: SeleniumLambdaLayer
      Export:
        Name: SeleniumLambdaLayer
    ChromedriverLayerExport:
      Value:
        Ref: ChromedriverLambdaLayer
      Export:
        Name: ChromedriverLambdaLayer
    FontsLayerExport:
      Value:
        Ref: FontsLambdaLayer
      Export:
        Name: FontsLambdaLayer
```

- デプロイする

```
$ sls deploy
```

## Lambda 関数をデプロイする

- lambda/serverless.ymlを編集

```yml
service: example

custom:
  pythonVer: python3.7
  timeout: 30
  memorySize: 1024
  stage: dev
  region: ap-northeast-1
  seleniumLayer: selenium-layer

frameworkVersion: '3'

useDotenv: true

provider:
  name: aws
  runtime: ${self:custom.pythonVer}
  timeout: ${self:custom.timeout}
  memorySize: ${self:custom.memorySize}
  stage: ${self:custom.stage}
  region: ${self:custom.region}

  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - "s3:ListBucket"
        - "s3:GetObject"
        - "s3:PutObject"
        - "s3:DeleteObject"
      Resource:
        Fn::Join:
          - ""
          - - "arn:aws:s3:::"
            - ${env:AWS_S3_BUCKET_NAME}
            - "/*"

  environment:
    AWS_S3_BUCKET_NAME: ${env:AWS_S3_BUCKET_NAME}
    REGION: ${self:custom.region}

package:
  include: 
    - ".fonts/**"

functions:
  hello:
    handler: handler.hello
    reservedConcurrency: 1
    layers:
      - ${cf:${self:custom.seleniumLayer}-${self:custom.stage}.SeleniumLayerExport}
      - ${cf:${self:custom.seleniumLayer}-${self:custom.stage}.ChromedriverLayerExport}
      - ${cf:${self:custom.seleniumLayer}-${self:custom.stage}.FontsLayerExport}

resources:
  Resources:
    Bucket:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${env:AWS_S3_BUCKET_NAME}
```

- .env を編集する

```
AWS_S3_BUCKET_NAME=xxxxxx
```


- handler.py を編集する

```python
import traceback
import requests
import json
import time
import datetime
import boto3
import os

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC


# .fonts を参照するため
os.environ["HOME"] = "/opt/"


def create_driver():
    options = Options()
    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("--single-process")
    options.add_argument("--disable-dev-shm-usage")
    options.add_argument("--homedir=/tmp")

    try:
        driver = webdriver.Chrome(executable_path="/opt/chromedriver", chrome_options=options)
    except Exception as e:
        print(traceback.format_exc())
    return driver


def upload_s3(file_path):
    s3 = boto3.client("s3")
    bucket_name = os.environ["AWS_S3_BUCKET_NAME"]
    s3.upload_file(file_path, bucket_name, file_path)


def hello(event, context):
    try:
        driver = create_driver()
        wait = WebDriverWait(driver, 10)
        driver.get("https://example.com")

        page_width = driver.execute_script("return document.body.scrollWidth")
        page_height = driver.execute_script("return document.body.scrollHeight")
        driver.set_window_size(page_width, page_height)

        file_path = "/tmp/example.png"
        driver.save_screenshot(file_path)

        upload_s3(file_path)
        os.remove(saved_file_path)
    except Exception as e:
        print(traceback.format_exc())
        return error_response()
```

- デプロイする

```
$ sls deploy
```

# つまずいたこと

## Selenium 起動時に Timeout エラーになる
エラーログ上は Timeout しか出力されないので調査が難航しました。
こちらは以下のバージョンを使用することで解決しました。
（もっと新しいバージョンの組み合わせでも動作するかもしれませんが、時間が無く未調査です。。）

- Python のバージョンを 3.8 -> 3.7 に変更
- Selenium のバージョンを 4系 -> 3系に変更
- headless-chrome のバージョンを v1.0.0-45 に変更
- chromedriver のバージョンを 2.40 に変更

## 日本語が文字化けする
Heroku で実行したときも同じ問題が発生しました。 .fonts ディレクトリにフォントファイルを配置したら良いのですが、Lambda Layer を利用する際は参照先の設定に注意が必要です。上記のパス指定で解決しました。