【Selenium】Lambda環境下において稀にWebDriverがハングする事象について

## はじめに
皆様、Seleniumでスクレイピング生活してますでしょうか？
Webサービスを持っている会社さんであれば、自社のサイトの正常性を定期的に確認するべく、
EventBridge + Lambdaで定期的に監視することもあるでしょう。
※CloudwatchSyntheticsを使え　という話は一旦置いときましょう。笑

私は、業務上LambdaでSelenium定期実行することが多いのですが、
掲題の通り稀にWebDriverがハングする事象を発見しました。
今回はそれに対する考察と対策について記載したいと思います。

**※本記事の文章には生成AIを一切使用しておらず、すべて自分の言葉で表現しています**

## 構成図
Dockerコンテナ型Lambdaでデプロイします。
今回は事象の再現が難しいので、インフラを構成するためのコードは載せません。
![lambda_selenium.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4112783/be5e8f65-1876-4e2a-9475-0ccdc24416c0.png)
## 事象
言語：Python3.12
OS：AmazonLinux2
Lambdaスペック：メモリ2GB
コード：
```Python
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By

# オプション設定
options = Options()
options.add_argument("--headless")
options.add_argument("--disable-gpu")
options.add_argument("--no-sandbox")
options.add_argument("--single-process")  # これがないとLambdaで動かない

service = Service(executable_path="/opt/chrome/chromedriver")

# WebDriverの起動
driver = webdriver.Chrome(service=service, options=options)

# タイムアウト設定（秒）
driver.set_page_load_timeout(10)
driver.set_script_timeout(10)

try:
    url = "https://hotel-example-site.takeyaqa.dev/ja/"
    driver.get(url)
except Exception as e:
    pass
finally:
    driver.quit()
```

この条件下の時に、たまーに（100回に1回くらい）`driver.get(url)`のところでハングします。
Lambdaの最大実行時間は15分ですが、15分いっぱいまでハングします。

`set_page_load_timeout`、`set_script_timeout`でタイムアウトを指定していますが、まったく意味を成しません。

体感ですが、傾向としては重たいページで頻度が多いイメージです。


## 考察
ローカルやEC2上で動かしているときに遭遇したことはないので、
おそらく`--single-process`が原因だと思います。
シングルプロセスで動かしているので、Webリソースの取得で通信不可になった際に割り込みでタイムアウト処理（=切断処理）が入らないと予想しています。
※当てずっぽうです。WebDriver詳しい方居れば教えてください。

## 対策
結論としては、[timeout_decorator](https://pypi.org/project/timeout-decorator/)を使いましょう。
`pip install timeout-decorator`でインストールしたうえで、以下のように実装します。
```Python
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By

import timeout_decorator
import os
import signal
import subprocess


# オプション設定
options = Options()
options.add_argument("--headless")
options.add_argument("--disable-gpu")
options.add_argument("--no-sandbox")
options.add_argument("--single-process")  # これがないとLambdaで動かない

service = Service(executable_path="/opt/chrome/chromedriver")

# WebDriverの起動
driver = webdriver.Chrome(service=service, options=options)

# タイムアウト設定（秒）
driver.set_page_load_timeout(10)
driver.set_script_timeout(10)

@timeout_decorator.timeout(10)
def access():
    url = "https://hotel-example-site.takeyaqa.dev/ja/"
    driver.get(url)
    driver.quit()

try:
    access()
except Exception as e:
    # Chromeプロセスが残存している可能性もあるので、killする
    result = subprocess.run(['pgrep', '-f', 'chrome'], stdout=subprocess.PIPE, text=True)
    pids = result.stdout.strip().split('\n')
    for pid in pids:
        if pid.isdigit():
            os.kill(int(pid), signal.SIGKILL)
```
* `@timeout_decorator.timeout(10)`を付与したメソッドにラップすることで、ハングしてもtimeout_decoratorが割り込みしてException発生させてくれます。
* `driver.get(url)`でハングした場合は、`driver.quit()`まで行かないので、Chromeのプロセスがゾンビで生き残ってしまいます。
そのため、`except Exception as e:`の後にkillする仕組みを導入しています。
* 余談ですが、Lambdaは内部的に同じインスタンスが使い回されることがあります。（サーバレスとは言っても中身はEC2インスタンス（と思われます）。）
前回実行時に/tmp配下に保存したファイルや、プロセスが残存している可能性があるので、後処理を必ず実装することをお勧めします。


(余談)
ECS Fargateを使用するという手もあります。
ただしLambdaよりは高額になるのと、タスクとして定義するので起動レイテンシが若干ネックです。


以上