Dockerコンテナからseleniumを使ってスクレイピング

こんにちは、けいすけと申します。


今回はDockerコンテナからseleniumを利用してchromeを操作して、スクレイピングに挑戦しようと思います。

## [selenium](https://www.selenium.dev/)とは
さまざまな用途があると思いますが、私はスクレイピングをする際に利用します。
最近だとページをスクロールした時に次のコンテンツを表示するといったwebページが増えてきました。
このような、JavaScriptを用いた動きのあるページの情報を取得する際にseleniumを用います。

## Dockerでの利用について
このseleniumはローカル環境であれば簡単に動作します。
私は[Pythonによるビジネスに役立つWebスクレイピング](https://www.udemy.com/course/python-web-scraping-with-beautifulsoup-selenium-requests/)というudemy講座で学習しましたが、ビデオ通りに動作しました。

しかしDocker上で動かそうとすると話は別で、簡単には操作できませんでした。
そこで今回はDockerにこだわってseleniumの使い方をご紹介します。

## 前提知識
下記を使用したことがあると読みやすいです。

- スクレイピング
- Python
- Docker
- docker-compose
- JupyterLab

## 実装環境
- macOS

## seleniumのイメージを起動し、ローカルからアクセス
[SeleniumHQ/docker-selenium](SeleniumHQ/docker-selenium)というGitHubページにはseleniumのイメージを用いた方法が紹介されています。

ローカルのターミナルappなどで

```
$ docker run -d -p 4444:4444 -v /dev/shm:/dev/shm selenium/standalone-chrome:4.0.0-beta-1-prerelease-20201208
```
と、実行するだけで、chrome環境がDockerコンテナ上に構築されます。
そして、同じくローカルでJupyterLabなどを立ち上げて、下記のPythonコードを動かすことでコンテナ上のchromeを操作してスクレイピングを行うことができます。
※)コードの内容は[10分で理解する Selenium](https://qiita.com/Chanmoro/items/9a3c86bb465c1cce738a)から拝借しております。
※)seleniumが入っていない場合は事前に`$ pip install selenium`としてseleniumをインストールしておきましょう。
※)掲載の通り、command_executor に`http://localhost:4444/wd/hub`を指定してアクセスしてください。

```python
from selenium import webdriver

# Chrome のオプションを設定する
options = webdriver.ChromeOptions()

# Selenium Server に接続する
driver = webdriver.Remote(
    command_executor='http://localhost:4444/wd/hub',
    options=options,
)

# Selenium 経由でブラウザを操作する
driver.get('https://qiita.com')
print(driver.current_url)

# ブラウザを終了する
driver.quit()
```
`https://qiita.com/`と出力されれば接続ができています。

## seleniumをDockerコンテナから操作する
上記ではchromeはDockerコンテナ上で動いていましたが、ローカルのJupyterLabからアクセスしていました。
ここではJupyterLabもコンテナ上に構築して、chromeのコンテナを操作してみましょう。
つまり、コンテナからコンテナにアクセスして操作するということです。

ここでDockerのネットワーク機能を知っていただきたいです。
詳しくは[Docker Compose入門 (3) ～ネットワークの理解を深める～](https://knowledge.sakura.ad.jp/23899/)をご覧ください。
要約すると、Dockerはコンテナを起動する際、ネットワークという空間にコンテナを配置します。
そして、そのネットワーク内であればコンテナ同士で通信ができるのです。

試しにターミナルに`$ docker network ls`と入力してみてください。

```
$ docker network ls

NETWORK ID          NAME                DRIVER              SCOPE
e5dd457767fd        bridge              bridge              local
8836c0e5c268        host                host                local
acdbf891b515        none                null                local
```
このような表示がされるのではないでしょうか？
NAMEがネットワーク名です。デフォルトではbridge、host、noneの3種類のネットワークが存在します。

新たなネットワークを作成してみましょう。
以下のコードを実行します。

```
$ docker network create mybridge
```
これで`mybridge`というネットワークが作成できました。
確認してみると、mybridgeというネットワーク名が追加されています。

```
$ docker network ls

NETWORK ID          NAME                DRIVER              SCOPE
e5dd457767fd        bridge              bridge              local
8836c0e5c268        host                host                local
bc51faf6c0b7        mybridge            bridge              local
acdbf891b515        none                null                local
```
そして、2つのコンテナを起動させる際に、どちらも`mybridge`ネットワークに配置するようにすることで相互に通信を行うことができるようになります。

では、例をお見せします。
まず、上記の[seleniumのイメージを起動し、ローカルからアクセス](https://qiita.com/kei0919/items/f6f696169c92c936374c#selenium%E3%81%AE%E3%82%A4%E3%83%A1%E3%83%BC%E3%82%B8%E3%82%92%E8%B5%B7%E5%8B%95%E3%81%97%E3%83%AD%E3%83%BC%E3%82%AB%E3%83%AB%E3%81%8B%E3%82%89%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9)の時に登場した`$ docker run -d -p 4444:~~~`というコマンドを実行するのですが、そこに`--net {network_name}`を追加します。
こうすることでコンテナを起動させる際に指定した{network_name}ネットワーク内にコンテナを配置します。
下記のコマンドでコンテナを起動します。コンテナ名は`chrome_driver`という名前を指定しました。

```
$ docker run -d -p 4444:4444 -v /dev/shm:/dev/shm --net mybridge --name chrome_driver selenium/standalone-chrome:4.0.0-beta-1-prerelease-20201208
```
では次に、`chrome_driver`コンテナを操作するコンテナを、同じく`mybridge`ネットワーク内に起動させたいと思います。
私は以下のDockerfileで作成したイメージを利用します。
ubuntuのイメージにAnacondaをインストールしてパスを通し、seleniumをインストールし、最後にJupyterLabを起動するというものです。

```
FROM ubuntu:latest

RUN apt-get update && apt-get install -y \
  sudo \
  wget \
  vim \
  git

WORKDIR /opt

RUN wget https://repo.anaconda.com/archive/Anaconda3-2020.07-Linux-x86_64.sh && \
  sh Anaconda3-2020.07-Linux-x86_64.sh -b -p /opt/anaconda3 && \
  rm -f Anaconda3-2020.07-Linux-x86_64.sh
ENV PATH /opt/anaconda3/bin:$PATH

RUN pip install selenium

WORKDIR /work

CMD ["jupyter", "lab", "--ip=0.0.0.0", "--allow-root"]
```
このDockerfileがあるディレクトリで`$ docker build .`として、下記コマンドでコンテナを起動します。
コンテナ名は`selenium`という名前を指定しました。
※)ポートとマウンティングディレクトリは適当に指定しています。
※)最後のd15d2c3cf86cの部分は、`$ docker build .`した際に作成されたイメージIDを指定してください。

```
docker run -p 2222:8888 -v ~/Desktop/:/work/ --net mybridge --name selenium d15d2c3cf86c
```
どちらのコンテナもmybridgeネットワークに配置できましたでしょうか？
下記コマンドでmybridgeネットワーク内を確認してみましょう。

```
$ docker network inspect mybridge

[
    {
        "Name": "mybridge",
        "Id": "04954f265ec2bf1db15b211037ebe345bd4d4421c17f315e80e445232271341b",
        "Created": "2020-12-11T05:55:28.1440964Z",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "192.168.48.0/20",
                    "Gateway": "192.168.48.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {
            "32ba38def25833c2632f04c560e69718b2505fd60eea728d5485f1474f8478f6": {
                "Name": "selenium",
                "EndpointID": "a8d9b9fad5810fca32231addfa21057e384833e9167e15da8d10838aa64fdebe",
                "MacAddress": "02:42:c0:a8:30:03",
                "IPv4Address": "192.168.48.3/20",
                "IPv6Address": ""
            },
            "58089eec577c69c13124328e1b83f68b1da4c8e1d907312c3e91b40996c28fa8": {
                "Name": "chrome_driver",
                "EndpointID": "165c0a9281ba038782108fd61f7f435986f50f8eac2768025e836beea3ce65cd",
                "MacAddress": "02:42:c0:a8:30:02",
                "IPv4Address": "192.168.48.2/20",
                "IPv6Address": ""
            }
        },
        "Options": {},
        "Labels": {}
    }
]
```
Containersを見ると`selenium`と`chrome_driver `という2つのコンテナが存在しています。
では、seleniumコンテナから`chrome_driver`コンテナにアクセスして、先ほどと同様のスクレイピングを行いましょう。
ポートは2222に設定したので`localhost:2222`からJupyterLabにアクセスできます。
ここで注意です。先ほどはJupyterLabから`http://localhost:4444/wd/hub`にアクセスしましたが、今回は`chrome_driver:4444/wd/hub`もしくは`192.168.48.2:4444/wd/hub`にアクセスしてください。
なぜかというとここはローカルではなくDockerコンテナだからです。

上記でネットワークを指定してコンテナを起動させたことにより、`selenium`コンテナと`chrome_driver`コンテナは同じネットワークに属しています。よってIPアドレスもしくはコンテナ名を指定して通信をすることができます。
コンテナ名とIPアドレスは`$ docker network inspect mybridge`でmybridge内を確認した際に表示される上記内容を確認してください。
よってJupyterLab内で実行するのは下記コードになります。

```python
from selenium import webdriver

# Chrome のオプションを設定する
options = webdriver.ChromeOptions()

# Selenium Server に接続する
driver = webdriver.Remote(
    command_executor='chrome_driver:4444/wd/hub', # コンテナ名を指定
#     command_executor='192.168.48.2:4444/wd/hub', # もしくはIPアドレスを指定
    options=options,
)

# Selenium 経由でブラウザを操作する
driver.get('https://qiita.com')
print(driver.current_url)

# ブラウザを終了する
driver.quit()
```
`https://qiita.com/`と出力されれば接続ができています。
これで`selenium`コンテナからseleniumを使って`chrome_driver`コンテナを操作してスクレイピングを行うことができました。

## Composeを利用する
先ほどは`docker run --net {network_name}`でそれぞれのコンテナを起動しましたが、Composeを利用するとネットワーク名を指定しなくても、自動で同じネットワークにコンテナを配置してくれるので非常に便利です。
例えば下記のようなディレクトリ構成を考えます。

```
Desktop/
└ test/
  ├ Dockerfile
  ├ docker-compose.yml
  └ work/
```
docker-compose.ymlは下記のように定義します。

```docker-compose.yml
version: "3"
services:
  chrome_driver:
    image: selenium/standalone-chrome:4.0.0-beta-1-prerelease-20201208
    ports:
      - '4444:4444'
    volumes:
      - '/dev/shm:/dev/shm'
  selenium:
    build: .
    ports:
      - '2222:8888'
    volumes:
      - './work/:/work/'
```
先ほどと同じように名前を`chrome_driver`と`selenium`とします。
Dockerfileは先ほどと同じ。
workディレクトリは空です。(※存在するディレクトリを指定しないとちゃんとマウントしてくれません。)
では、testディレクトリで`$ docker-compose up --build`を実行します。
うまく起動できたら`$ docker network ls`を実行してネットワークを確認してみましょう。

```
$ docker network ls

NETWORK ID          NAME                     DRIVER              SCOPE
94d4705b1f33        bridge                   bridge              local
6ac0e9dbf238        host                     host                local
04954f265ec2        mybridge                 bridge              local
6c6c6ce2e790        none                     null                local
5c64b45dc7fc        test_default             bridge              local
```
一番下にtest_defaultというネットワークが作成されているのがわかります。
ネットワーク名は{ディレクトリ名_default}となります。
先ほどtestディレクトリでcomposeを動かしたのでtest_defaultというネットワークが作成されたのです。
ではこのネットワーク内を確認します。

```
$ docker network inspect test_default

[
    {
        "Name": "test_default",
        "Id": "5c64b45dc7fc744f546edbb13da4acc14b28aadffe51b9ed113f3911e8fb286f",
        "Created": "2020-12-11T06:58:17.366581Z",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "192.168.96.0/20",
                    "Gateway": "192.168.96.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": true,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {
            "07e16ac792bb3361f916f9f451931c81a7aa2624b17cf92fd04ac34003499b32": {
                "Name": "test_selenium_1",
                "EndpointID": "eca2c1373918979fa0f20d9e2767b5a05715cb615514acec64193eb260e7c7e3",
                "MacAddress": "02:42:c0:a8:60:02",
                "IPv4Address": "192.168.96.2/20",
                "IPv6Address": ""
            },
            "4d5d5b94087a10bbb19c4b37273e1d3486db98809ad4c1aa67b97269a130d441": {
                "Name": "test_chrome_driver_1",
                "EndpointID": "9f83480675bf95e74f90000d6bd175ecd6ea940dd2126b0200b7b50cb4b93acc",
                "MacAddress": "02:42:c0:a8:60:03",
                "IPv4Address": "192.168.96.3/20",
                "IPv6Address": ""
            }
        },
        "Options": {},
        "Labels": {
            "com.docker.compose.network": "default",
            "com.docker.compose.project": "test",
            "com.docker.compose.version": "1.27.4"
        }
    }
]

```
test_defaultネットワーク内に`test_selenium_1`と`test_chrome_driver_1`というコンテナが作成されました。
※)docker-compose.ymlファイルに`selenium`と`chrome_driver`という名前を指定し、testディレクトリでcomposeを動かしたのでこの名前になりました。

では同じように`localhost:2222`にアクセスして`test_selenium_1`コンテナーのJupyterLabに入りましょう。
そしてJupyterLab内で上記と同じように`test_chrome_driver_1`コンテナーへ接続を試みます。

```python
from selenium import webdriver

# Chrome のオプションを設定する
options = webdriver.ChromeOptions()

# Selenium Server に接続する
driver = webdriver.Remote(
    command_executor='test_chrome_driver_1:4444/wd/hub', # コンテナ名を指定
#     command_executor='192.168.96.3:4444/wd/hub', # もしくはIPアドレスを指定
    options=options,
)

# Selenium 経由でブラウザを操作する
driver.get('https://qiita.com')
print(driver.current_url)

# ブラウザを終了する
driver.quit()
```
`https://qiita.com/`と出力されれば接続ができています。

## Dockerコンテナでスクレイピングしている様子を動画で確認する
ここまでで、Dockerコンテナ上で作業することができるようになったと思います。
しかし、ローカルであれば自動でchromeが立ち上がって実際にスクレイピングされる様子を確認することができると思いますが、Dockerコンテナ上ではそれができません。
そこで[GitHub](https://github.com/SeleniumHQ/docker-selenium#video-recording-)で紹介されている方法を使ってスクレイピングされる様子をmp4形式の動画でダウンロードしてみましょう。
といっても作業は簡単で、紹介されている以下のコードを実行します。

```
$ docker network create grid
$ docker run -d -p 4444:4444 -p 6900:5900 --net grid --name selenium -v /dev/shm:/dev/shm selenium/standalone-chrome:4.0.0-beta-1-prerelease-20201208
$ docker run -d --net grid --name video -v /tmp/videos:/videos selenium/video:ffmpeg-4.3.1-20201208
# Run your tests
$ docker stop video && docker rm video
$ docker stop selenium && docker rm selenium
```

ここまで記事を読んでいただいた方であれば、内容は理解できると思いますが注意事項がありますのでご紹介します。

- 基本ですが、`grid`というネットワークを作成し、全てのコンテナをそこに配置します。
- `video`コンテナを起動させる際、マウントするディレクトリは添付の通り`/tmp/videos:/videos`でなければなりません。(他のディレクトリを指定すると、mp4ファイルは指定ディレクトリ内に取得できますが、なぜか動画を再生できません。)
- `# Run your tests`のところでは今回実装したDockerfileを使ってJupyterLabからアクセスする方法を実行することができます。もちろんネットワークは`grid`で指定してください。
- 最後の2行で`video`コンテナと`selenium`コンテナを削除していますが、これをしないとなぜかビデオが再生されません。

以上でDockerを用いたseleniumのご紹介をおわらせていただきます。
これでローカル環境に依存しない環境でスクレイピングを行うことができるようになりました。
そして、ビデオを取得できたことにより、さらにローカル環境に近い環境を実装することができました。

## 考察
まさかスクレイピングをするためにDockerのネットワーク機能を知ることになるとは思いませんでした。
非常に便利な機能だと思いますので、今後使う機会があれば積極的に使っていきたいと思います。

## 参照
- [selenium](https://www.selenium.dev/)
- [Pythonによるビジネスに役立つWebスクレイピング](https://www.udemy.com/course/python-web-scraping-with-beautifulsoup-selenium-requests/)
- [SeleniumHQ/docker-selenium](SeleniumHQ/docker-selenium)
- [10分で理解する Selenium](https://qiita.com/Chanmoro/items/9a3c86bb465c1cce738a)
- [Docker Compose入門 (3) ～ネットワークの理解を深める～](https://knowledge.sakura.ad.jp/23899/)