自動テストの実行環境をDockerでお気軽引っ越し

f:id:vasilyjp:20191224185016j:plain

どうも品質管理部のキムラリョーです。
Selenium & Pythonを利用した自動テストプロジェクトの再構築をDockerを使って簡単にしたい、という話です。

これまでの自動テスト

f:id:vasilyjp:20191213130118j:plain

実行までに必要な手順
1. リポジトリクローン
2. Pythonインストール
3. pipで必要なパッケージをインストール
4. Dockerインストール
5. 自動テスト実行

ターミナルからmainを実行すると、Selenium Gridのコンテナを起動した後にtestautoが実行されます。testautoはSelenium Gridに接続してブラウザを操作しながらテストを行います。
Selenium Gridだから起動時などの設定で様々な形に切り替える事ができます。Nodeを増やしたら並列も可能だし、ヘッドレスも使えるし、気軽にブラウザの設定内容を変えられます。

このプロジェクトは作成者である自分だけが実行していました。試運転と本番実行を行いながら、好きなタイミングで気軽に動かせるならよかったので、自分のPC上で直接Pythonを動かします。

テスト処理という意味ではこのままの形でも大きな問題はありません。
ただ長く利用しているとPCの買い替えや実行PCの追加、実行者が増えたりなど、頻度が多いわけでは無いですが忘れた頃にプログラムの実行環境を再構築する必要が出てきます。

引っ越しって何かと面倒な作業が発生するので、できる限り簡単にしたいと考えました。

問題1「Pythonのインストールが面倒」

プロジェクトをGitHubからクローンしてきて、テストを実行するまでの間に必要な準備が以下の3つです。
- Pythonのインストール
- pipで必要なパッケージをインストール
- Dockerのインストール

Dockerのインストールは特に問題ないと思うのですが、Pythonやpipに関しては簡単ではありません。

一番は、Pythonをよく知らない人にとってインストールは面倒すぎます。
Pythonを理解している人のPCで動かすにしても、localのPythonで動かすわけにはいかないですし。localがダメならpyenv? そうすると「Pythonをよく知らない人」にとってのハードルがどんどん上がります。プロジェクトを作成する自分としても把握が面倒ですし、Pythonそのものやpipインストールするパッケージのバージョン等を気にしたくないです。

テスト業務をメインとするメンバーはプログラミング言語に触れる機会が少ないですが、自動テストを作成する自分よりもテストへの理解度が高いです。Pythonを知らなくても実行できる状態にする事で役割分担を進めて行く事もできますので、無理にPythonを覚えるよりもメリットが大きいと思い、インストール手順をスキップできる方法で改善します。

DockerにはPython入りのコンテナがあるみたいなので、Python入りコンテナでプログラムが動くようにします。コンテナ起動時にはpipインストールも自動で行います。

問題1を改善した自動テスト

f:id:vasilyjp:20191213130204j:plain

実行までに必要な手順
1. リポジトリクローン
2. Dockerインストール
3. サブコンテナ起動
4. メインコンテナ起動
5. 自動テスト実行

問題2「コンテナの中からコンテナを起動したい」

Pythonとpipに関しては「メインコンテナ起動」にまとめる事ができましたが、 mainがコンテナに入った事でmainからSelenium Gridが入ったサブコンテナを起動できなくなりました。それによって必要な手順に「サブコンテナ起動」が増えました。

コンテナの中はlinux、ホストとは異なる環境、別物です。
linux「Docker? なにそれ?」
とクジラの上にいるペンギンが灯台下暗し的な事を言い出します。コンテナを立ち上げたい時はホスト上から起動する必要があります。

もしmainからコンテナを起動できると「mainがtestautoの実行時、テスト内容に合わせた設定のSelenium Gridを起動する」といった事もできるので、今のうちに対応したいところです。

調べて見るとコンテナの中にコンテナを立ち上げる方法と、あくまでもホスト上にコンテナを立ち上げる方法があるみたいです。
- DinD [Docker in Docker]
- DooD [Docker outside of Docker]

メリットやデメリットなどの詳細については、上記の名称で検索するといくつか出てくると思います。
DinDの場合は、ホストで動いているDockerとはまた別のDockerが存在する事になってややこしい気がしました。
DooDの場合は、コンテナが全てホスト上に立ち上がるのでコンテナ同士の関係性が一切見えません。今回のプロジェクト以外でも同ホスト上にコンテナを立ち上げている場合は管理できなさそうです。
プロジェクトによってはセキュリティ面の確認は必須です、Docker imageも公式のimage限定にするとか。自分もまだ試してませんが、Rootlessモードにしてみるとか。

とりあえず今回はコンテナの管理に気を回す必要はなく、利用者も部内のみなので、DooD [Docker outside of Docker]を選択する事にしました。

自動テストでエラーを検知した際、その内容を確認し必要であれば手動テストも合わせて、検知したエラーが「自動テストの問題」か「テスト対象サイトの問題」かを判断しなければなりません。
この原因解明時に一番困るのが、短時間で原因を特定できず再現もできないエラーです。同PC内の別のプロジェクトによってなんらかの影響を受けた事が原因だった場合などは一番手間取るように思います。たとえ単純な原因だったとしても、別プロジェクトによるモノだと見落としやすくなりますし。

基本的には自動テスト専用PCにする、基本を崩す時は自身でなんとかする。という事でDooDにしました。

問題2を改善した自動テスト

f:id:vasilyjp:20191213130240j:plain

実行までに必要な手順
1. リポジトリクローン
2. Dockerインストール
3. メインコンテナ起動
4. 自動テスト実行

mainとtestautoも切り離した方が綺麗だと思ったのでコンテナを分けました。

必要なコードを書いてみる

問題が解決した事でとりあえず起動までは進めそうなので、簡易的ですがコードを書いていきます。


ファイル構成

TestAutomation
|_main
|  |_main.py
|
|_testauto
|  |_testauto.py
|  |_Dockerfile
|  |_requirements.txt
|
|_docker-compose.yml
|_Dockerfile
|_requirements.txt

./docker-compose.yml

version: '3'
services:
  main:
    build: .
    container_name: 'main'
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./main:/main
      - ./testauto/Dockerfile:/main/testauto/Dockerfile:ro
      - ./testauto/requirements.txt:/main/testauto/requirements.txt:ro
    tty: true
    environment:
      - HOST_ROOT_PATH=${PWD}
    command: python ./main.py

mainコンテナを起動する為のdocker-composeファイルです。
docker.sockをマウントしてDockerの情報をホストとコンテナで共有します。
mainがtestautoコンテナを起動する時の為にtestautoのDockerfileとrequirements.txtをマウントしておきます。別にマウントせずにmainディレクトリの中に入れておいても良いんですが、この2つのファイルがtestautoの為の物なんだという事が一目でわかると思ったのでこの配置です。
environmentでホストから見たこのディレクトリのパスをコンテナ内の環境変数に設定します。
commandにmain実行を書く事で、メインコンテナ起動と同時に自動テスト実行も行うようにします。


./Dockerfile & ./testauto/Dockerfile

FROM python:3.7
ENV PYTHONUNBUFFERED 1
RUN mkdir /main か testauto
WORKDIR /main か testauto
COPY ./requirements.txt /main か testauto/
RUN pip install --upgrade pip
RUN pip install -r requirements.txt

mainとtestautoのdocker imageを構築する為のファイルです。
とりあえず簡単な内容なので2つぶんまとめて。コンテナ名になる部分だけをそれぞれ変更します。


./requirements.txt

docker==4.0.2

./testauto/requirements.txt

selenium==3.141.0

Dockerfileから参照するpip installの対象パッケージを書きます。
必要な物を追加。それぞれrequirements毎にコンテナが異なるので、同じパッケージが必要ならどちらにも書く。


./testauto/testauto.py

import time

print('start')
for i in range(30):
    time.sleep(1)
    print(i)
print('end')

とりあえず実行されるかがわかればいいので、適当に書いておきます。


./main/main.py

import docker
from docker.errors import ContainerError
import os
import re

# docker-compose.ymlで指定した環境変数を使います
HOST_ROOT_PATH = os.environ.get('HOST_ROOT_PATH')


##################################################
# testauto container
#
def run_container_testauto():
    client = docker.from_env()
    testauto_dir = f'{HOST_ROOT_PATH}/testauto/'
    name = 'testauto'
    docker_setting = {
        'image': name,
        'name': name,
        'volumes': {
            testauto_dir: {'bind': '/testauto', 'mode': 'rw'},
        },
        'environment': [
            # 環境設定にhostPCのIPが入ります
            'SELENIUM_GRID_HUB_IP=host.docker.internal',
        ],
        'command': f'python ./testauto.py'
    }

    # imageを探す、なければDockerfileのpathを渡して作成する
    if not client.images.list(name):
        client.images.build(path='./testauto', tag=name)

    try:
        client.containers.run(**docker_setting)
    except ContainerError:
        print('run_container_testauto error')

    con = client.containers.get(name)
    logs = con.logs()

    file_path = f'logs/testauto.log'
    dir_path = os.path.dirname(file_path)
    if not os.path.exists(dir_path):
        os.makedirs(dir_path)
    with open(file_path, 'w') as f:
        f.write(logs.decode('utf-8'))


##################################################
# selenium grid container
#
def run_container_grid_hub(network_name):
    client = docker.from_env()
    docker_setting = {
        'image': 'selenium/hub:3.141.59-iron',
        'name': 'selenium-hub',
        'detach': True,
        'tty': True,
        'network': network_name,
        'ports': {'4444': 4444, },
        'environment': [
            'TZ=Asia/Tokyo',
        ]
    }
    client.containers.run(**docker_setting)


def run_container_grid_node(network_name, number, browser='chrome'):
    client = docker.from_env()
    docker_setting = {
        'image': f'selenium/node-{browser}-debug:3.141.59-iron',
        'name': f'selenium-node-{number}',
        'detach': True,
        'tty': True,
        'network': network_name,
        'ports': {'5900': 55550 + (number * 10), '5555': 5555 + number},
        'environment': [
            'TZ=Asia/Tokyo',
            'NODE_MAX_INSTANCES=5',
            'NODE_MAX_SESSION=5',
            'HUB_PORT_4444_TCP_ADDR=selenium-hub',
            'HUB_PORT_4444_TCP_PORT=4444'
        ],
    }

    client.containers.run(**docker_setting)


def run_selenium_grid(node_count=1):
    network_name = 'grid'
    client = docker.from_env()

    # docker networkを探して、なければ作成する
    if not [v for v in client.networks.list() if network_name == v.name]
        client.networks.create(network_name)

    containers = client.containers.list()
    if [v for v in containers if 'selenium-hub' in v.name]:
        print('hub あります')
    else:
        run_container_grid_hub(network_name=network_name)

    container_names = [v.name for v in client.containers.list()]
    for number in range(node_count):
        name = f'selenium-node-{number}'
        if name not in container_names:
            run_container_grid_node(network_name=network_name, number=number)


def remove_container_grid():
    client = docker.from_env()
    for v in client.containers.list():
        if re.match(r'selenium-(hub|node)(-[0-9]+)?', v.name):
            v.stop()
            v.remove()


##################################################
# run testauto
#
def run_testauto():
    client = docker.from_env()

    run_selenium_grid()
    run_container_testauto()
    remove_container_grid()

    client.containers.prune()
    client.networks.prune()
    client.images.prune()


if __name__ == '__main__':
    run_testauto()

サブコンテナを起動する処理です。流れは以下です。
1. Selenium Grid Hubコンテナを起動
2. Selenium Grid Nodeコンテナを起動
3. testautoコンテナを起動しテストが実行される
4. testautoコンテナが終了したら各コンテナの停止

https://pypi.org/project/docker/
https://docker-py.readthedocs.io/en/stable/
ここではコンテナ管理にDocker SDK for Pythonを利用します。Dockerの基本的な利用方法をPythonに置き換えるだけであればややこしい部分はないので、公式ドキュメントを巡ったらなんとなく使えると思います。

コンテナ起動時に実行されるcommandを設定したので、3ステップでtestautoが実行される状態になりました。
1. リポジトリクローン
2. Dockerインストール
3. メインコンテナ起動

試しに全て揃った状態でプロジェクトディレクトリに移動して
docker-compose up -d --build を打ってみます。
testautoが合計30秒のtime.sleepで終了するまでに、起動コンテナの docker ps -a と、testautoコンテナのログ docker logs testauto を確認します。

一応上記コードは内容に間違いがなければ、testautoコンテナ終了時にそのログを書き出しておく処理をつけました。main/logsディレクトリ内にテキストファイルが保存されるかと思います。

諸々、確認ができたら完成です。

問題3「mainコンテナの起動に時間がかかる」

一件落着と思ったんですが、実際のプロジェクトで同じ対応を行った時、mainコンテナの起動にやたらと時間がかかっていました。

コンテナが起動するまでのDockerの動きを調べたのですが、コンテナのイメージを構築するタイミングで、Dockerfile以下の階層にあるファイルを全てコピーするらしいです。
今回だとmainコンテナ起動時には使わないファイル、一番大きくなるtestautoディレクトリをコピーしてしまうので、これに極端に時間がかかっているような気がしました。

そこでコピーするファイルを一部除外できるdockerignoreというのがある事を知りました。使い方はgitignoreと同じです。除外したいファイルを指定する形です。
gitignoreと書き方は同じですが、処理の流れが違うようなので、本当に必要な情報だけを書くようにしておくのが良いみたいです。試してはいないですが、ignoreの書き方によっては遅くなるとかなんとか。

dockerignoreを設定した結果、1分くらいかかっていた起動が、数秒に短縮されました。

問題4「何もしてないのにSeleniumのWebDriverが落ちた」

引っ越ししてからある程度時間が経った頃、突然よくわからないタイミングでテスト実行中にWebDriverを掴めなくなる事がありました。
エラーログ等をいろいろ調べていくと、スクリーンショットで失敗した後にWebDriverを掴めなくなっているようでした。さらにエラーが出たタイミングをみると、大きな画面を撮影しているテストでのみ落ちていました。ただ、撮影失敗した後でもWebDriverをちゃんと掴めているケースの方が多かったです。

そうなるとスクリーンショットではなくメモリとかの問題かと思いコンテナを確認していたら、shm_sizeの変更、もしくはdev/shmのマウントがされていない事に気づきました。shm_sizeはコンテナが使用するメモリのサイズです。

上記のコード「main.py」で指定しているdocker_settingに設定を追加します。
サイズ変更なら shmsize: 256 、マウントなら 'volumes': {'/dev/shm': {'bind': '/dev/shm', 'mode': 'rw'}}, です。

今回の改善でコンテナの起動方法がdocker-composeからPythonのDockerに変わりました。コンテナ内からdocker-composeでコンテナを立ち上げる方法がわからなかったのでこうなったのですが、書き移すような時は抜けがないか注意しないと気づきにくいです。

ありがちな凡ミスだと思ったので、ここに書き残しておきます。

おわり

自動テストの最終的な形ですが、せっかくなので外部の環境も記載しました。

f:id:vasilyjp:20191213130215j:plain

最近はAppiumでのアプリテストも追加しようと拡張中です。
Appiumは実機で動かすのであればDockerに入れるのは難しい気がしているので、localで立ち上げておく必要があります。物理的に端末を用意してPCに接続しておかなければならないですし。
Appiumの環境を整えるというのもなかなか手間がかかるので、AppiumやSeleniumの専用PCを用意してip経由で接続というのも良いと思います。

今回の変更によって実行までに必要な手順は5ステップから3ステップに減りました。

変更前
1. リポジトリクローン
2. Pythonインストール
3. pipで必要なパッケージをインストール
4. Dockerインストール
5. 自動テスト実行

変更後
1. リポジトリクローン
2. Dockerインストール
3. メインコンテナ起動

単純に3/5になったというのではなく、そもそもPythonとpipの部分は他のステップと比べても特に時間がかかる部分なので、大きな削減になりました。気持ち的には1/5くらいになったのかな、と思っています。

今回はDinD [Docker in Docker]ではなくDooD [Docker outside of Docker]を利用しましたが、DinDについてももう少し試していこうと思っています。仕組みとしてもこれが正解という訳では無いですし。

ZOZOテクノロジーズでは、一緒にサービスを作り上げてくれる方を募集中です。
ご興味のある方は、以下のリンクからぜひご応募ください。

tech.zozo.com

カテゴリー