Selenium Grid from Concourse on Kubernetes

[Concourse CI/CD Meetup Tokyo #6](https://www.meetup.com/ja-JP/Concourse-CI-Tokyo-Meetup/events/239869851/)の登壇資料です。

---

# アジェンダ

* Selenium Grid
* Selenium Grid from Concourse
* Selenium Grid from Concourse on Kubernetes
* やってみよう
* Future Work: Selenium Grid from Concourse on Kubernetes on AWS

---

# <i class="fa fa-th" style="font-size:1em;"></i> Selenium Grid

[複数のマシンやコンテナにSeleniumのテストを分散並列実行させるためのプロキシ](https://seleniumhq.github.io/docs/grid.html#selenium_grid)

## 用語

**WebDriver** *プログラムしたとおりにブラウザを動かすドライバ。プログラムから見れば、ドライバ=ブラウザ。同じマシン・コンテナ内で実際のブラウザの起動やコントロールを行う*
[**RemoteWebDriver**](https://seleniumhq.github.io/docs/remote.html) *別プロセスで動いているWebDriverを動かすWebDriver*
**Selenium Node** *RemoteWebDriver Serverが実行されているマシン、コンテナ*

---

# <img src="https://qiita-image-store.s3.amazonaws.com/0/5912/85e13977-a419-f523-2305-0e5f763039a5.png" width="28"> Selenium Grid from Concourse

ConcourseタスクからSeleniumテストの分散・並列実行

## メリット

<i class="fa fa-check-square-o" style="font-size:1em;"></i> テストの所要時間短縮
<i class="fa fa-check-square-o" style="font-size:1em;"></i> 複数ブラウザを対象にテスト
<i class="fa fa-check-square-o" style="font-size:1em;"></i> BrowserStack、SauceLabsなどの有償サービスを使わなくてもできる)

## デメリット

<i class="fa fa-square-o" style="font-size:1em;"></i> ConcourseとSelenium Gridのクラスタをそれぞれセットアップする手間
<i class="fa fa-square-o" style="font-size:1em;"></i> Selenium GridのURLがローカルと本番で異なる

---

# <i class="fa" style="font-size:1em;">⎈</i> Selenium Grid from Concourse on Kubernetes

Kubernetes上でConcourseとSelenium Gridをホストする

## メリット

<i class="fa fa-check-square-o" style="font-size:1em;"></i> ConcourseとSelenium Gridのクラスタをそれぞれセットアップ
<i class="fa fa-check-square-o" style="font-size:1em;"></i> Selenium GridのURLがローカルと本番で一緒 - ローカル環境でも本番環境でも全く同じパイプライン・タスクでSeleniumテストの分散・並列実行ができる

---

# やってみよう

* ローカルKubernetes(K8S)クラスタ作成
* K8SにSelenium Gridをインストール
* ChromeノードにVNCで接続
* Pythonのreplでテストコードを書く
* pytestでテスト実行
* K8SにConcourseをインストール
* Concourse Job/Taskからテスト実行
* テスト実行の様子を観察
* プロセス並列で実行
* 上級編: いちいちgit commit&pushせずにテストしたい

---

# やってみよう

* ローカルKubernetes(K8S)クラスタ作成
* K8SにSelenium Gridをインストール
* ChromeノードにVNCで接続
* Pythonのreplでテストコードを書く
* pytestでテスト実行
* K8SにConcourseをインストール
* Concourse Job/Taskからテスト実行 ←Concourseの話
* テスト実行の様子を観察
* プロセス並列で実行
* 上級編: いちいちgit commit&pushせずにテストしたい ←Concourseの話

---

# ローカルK8Sクラスタ作成

`minikube start --cpus 4 --memory 6144`

* CPUは4コア(Concourse Web/Workerでそれぞれ1コア+その他で2コア)
* メモリは多めに6GB確保
  * 　minikubeVMのデフォルトメモリサイズは1024M
  * 　MySQL, Redis, Selenium Grid Hub, Selenium Node 
  Firefox/Chromeを起動するとだいたい4GBくらい
  * Concourse Web/Workerでだいたい1GBくらい

---

# Selenium Gridインストール

```
helm install stable/selenium \
  --set chromeDebug.enabled=true \
  --set firefoxDebug.enabled=true
  --name selenium-grid
```

* ただし、2017/06/05現在下記PRのマージが必要
  * https://github.com/kubernetes/charts/pull/1239
  * それまでは自分でHelm Chartをビルド&インストール
  * `helm package . && helm install selenium-0.1.1.tgz --set chromeDebug.enabled=true --set firefoxDebug.enabled=true --name selenium-grid`

---

# ChromeノードにVNCで接続

```
kubectl port-forward --namespace default \
  $(kubectl get pods --namespace default \
    -l app=selenium-grid-selenium-chrome-debug \
    -o jsonpath='{ .items[0].metadata.name }') 5900
```

macOS標準装備のVNCクライアントで接続

```
open vnc://127.0.0.1:5900
```

<img src="https://qiita-image-store.s3.amazonaws.com/0/5912/ce63ced7-98b8-fba1-8a7f-36c53059c6ff.png" width="320">

---

# SeleniumのRemoteWebDriver実行用コンテナ作成

```
kubectl run selenium-python --image=google/python-hello
```

```
export PODNAME=`kubectl get pods --selector="run=selenium-python" --output=template --template="{{with index .items 0}}{{.metadata.name}}{{end}}"`

# 初回はdocker pullに時間がかかるので数分待ってから・・・
kubectl exec --stdin=true --tty=true $PODNAME bash
```

```
pip install selenium
python
```

---

# Pythonのreplでテストコードを書く

```
from selenium import webdriver
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities

driver = webdriver.Remote(
  command_executor='http://selenium-grid-selenium-hub:4444/wd/hub',
  desired_capabilities=getattr(DesiredCapabilities, "CHROME")
)
driver.get("http://google.com")
```

<img src="https://qiita-image-store.s3.amazonaws.com/0/5912/9d845ba1-de28-57d5-25e8-698bf7727ccc.png" width="320">

```
assert "google" in driver.page_source
driver.close()
```

---

# pytestでテスト実行

```
pip install -U pytest
```

```python
import time

from selenium import webdriver
from selenium.webdriver.common.desired_capabilities im
port DesiredCapabilities

def test_chrome():
        driver = webdriver.Remote(
          command_executor='http://selenium-grid-selen
ium-hub:4444/wd/hub',
          desired_capabilities=getattr(DesiredCapabili
ties, "CHROME")
        )
        driver.get("http://google.com")
        time.sleep(5)
        assert "google" in driver.page_source
        driver.close()
```

```
pytest
```

---

# K8SにConcourseをインストール

```
helm install stable/concourse --name concourse
```

```
export POD_NAME=$(kubectl get pods --namespace default -l "app=concourse-web" -o jsonpath="{.items[0].metadata.name}")
    echo "Visit http://127.0.0.1:8080 to use Concourse"
    kubectl port-forward --namespace default $POD_NAME 8080:8080
```

```
open http://127.0.0.1:8080/
```

![image.png](https://qiita-image-store.s3.amazonaws.com/0/5912/3ff41b25-0754-39d5-8b93-9f72f24269e2.png)


---

# Concourseパイプラインをつくる

```yaml
jobs:
- name: e2e
  plan:
  - task: pytest-selenium
    config:
      platform: linux
      image_resource:
        type: docker-image
        source: {repository: google/python-hello}
      run:
        path: bash
        args:
        - -exc
        - |
          apt-get update -y
          apt-get install curl -y
          pip install selenium
          pip install -U pytest
          cat << EOS > e2e_test.py
          import time
          
          from selenium import webdriver
          from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
          
          def test_chrome():
          	driver = webdriver.Remote(
          	  command_executor='http://selenium-grid-selenium-hub:4444/wd/hub',
          	  desired_capabilities=getattr(DesiredCapabilities, "CHROME")
          	)
          	driver.get("http://google.com")
          	time.sleep(5)
          	assert "google" in driver.page_source
          	driver.close()
          
          EOS
          pytest
```

```
$ fly login -t k8s -c http://127.0.0.1:8080 -u concourse -p concourse
$ fly -t k8s set-pipeline -p e2e -c e2e.yaml
$ fly -t k8s unpause-pipeline -p e2e
```

---

# パイプライン感なし

![image.png](https://qiita-image-store.s3.amazonaws.com/0/5912/2b77e03d-5ec3-3a4c-1c2b-de19e8cf0458.png)

---

# Concourse Job/Taskからテスト実行

[+]ボタンをクリックするか、`fly -t k8s trigger-job -j e2e/e2e`を実行

<img src="https://qiita-image-store.s3.amazonaws.com/0/5912/481b2e1e-11b0-a764-ec85-e0512b2c57f0.png" width="320">

---

# Concourse Job/Taskからテスト実行

pytest実行中。RemoteWebDriver経由でSelenium NodeのChromeが動いているはず。

<img src="https://qiita-image-store.s3.amazonaws.com/0/5912/3e05fcec-d6c3-8348-92f9-0fbad8ffeb0a.png" width="320">

---

# テスト実行の様子を観察

実際、VNCクライアントで見てみるとChromeが動いていることがわかる。

<img src="https://qiita-image-store.s3.amazonaws.com/0/5912/5ebd7ad6-c3f8-9586-1a61-4379d9d68bd2.png" width="320">

しばらく待つとテストが通る。

<img src="https://qiita-image-store.s3.amazonaws.com/0/5912/027655de-12e6-e523-9a94-705e36893db5.png" width="320">

---

# プロセス並列で実行

[pytest-xdist](https://docs.pytest.org/en/3.0.1/xdist.html)を使う。

```
pip install --upgrade setuptools
pip install pytest-xdist

...

pytest -n <プロセス数>
```

※ [setuptoolsをアップグレードしないと-nオプションが認識されない。pythonあるある?](https://stackoverflow.com/a/41066450)

---

# 上級編: いちいちgit commit&pushせずにテストしたい

```yaml
resource_types:
- name: kube-broker
  type: docker-image
  source:
    # Replace the url
    repository: mumoshu/concourse-kube-broker-resource
resources:
- name: test-sources
  type: kube-broker
  source:
    configmap: foobar
    path: /app
    k8s_ca: {{k8s_ca_base64}}
    k8s_service_account_token: {{k8s_service_account_token_base64}}
jobs:
- name: e2e
  plan:
  - get: test-sources
  - task: pytest-selenium
    config:
      inputs:
      - name: test-sources
      platform: linux
      image_resource:
        type: docker-image
        source: {repository: google/python-hello}
      run:
        path: bash
        args:
        - -exc
        - |
          apt-get update -y
          apt-get install curl -y
          pip install selenium
          pip install -U pytest
          cd test-sources
          pytest
```

以下のようなconfigmapで、resourceのinputをとってくる元podを指定

```
$ kubectl get configmap foobar -o yaml
apiVersion: v1
data:
  kubernetes.pod.name: selenium-python-2632905994-7ck0z
kind: ConfigMap
metadata:
  creationTimestamp: 2017-06-06T04:31:48Z
  name: foobar
  namespace: default
  resourceVersion: "75860"
  selfLink: /api/v1/namespaces/default/configmaps/foobar
  uid: 0e436817-4a71-11e7-affc-080027d5f559
```

Concourse ResourceからK8S APIにアクセスするために利用するトークンを取得

```
token_name=$(kubectl get secret | grep default| awk '{ print $1 }')
SERVICE_ACCOUNT_TOKEN=$(kubectl get secret $token_name -o jsonpath={.data.token})
```

トークンとクラスタのCAをパラメータにパイプラインを作成

```
fly -t k8s set-pipeline -p e2e-2 -c e2e-2.yml -v k8s_ca_base64=$(cat $HOME/.minikube/ca.crt | base64) -v k8s_service_account_token_base64=$SERVICE_ACCOUNT_TOKEN
```

```
fly -t k8s unpause-pipeline -p e2e-2
```

---

# kube-broker-resource

[concourse-smuggler-resource](https://github.com/redfactorlabs/concourse-smuggler-resource)というadhocなconcourseリソースつくるメタなConcourseリソースをベースに作成

smuggler.yml

```
#filter_raw_request: true
commands:
  check: |
    KUBE_URL=${SMUGGLER_k8s_api_endpoint_url:-https://kubernetes}
    NAMESPACE=${SMUGGLER_k8s_namespace:-default}
    KUBECTL="/usr/local/bin/kubectl --server=$KUBE_URL --namespace=$NAMESPACE"

    # configure SSL Certs if available
    if [[ "$KUBE_URL" =~ https.* ]]; then
      KUBE_CA_BASE64="${SMUGGLER_k8s_ca}"
      KUBE_SERVICE_ACCOUNT_TOKEN_BASE64=${SMUGGLER_k8s_service_account_token}
      CA_PATH="/root/.kube/ca.pem"
      
      mkdir -p /root/.kube
      echo "$KUBE_CA_BASE64" | base64 -d > $CA_PATH
      KUBE_SERVICE_ACCOUNT_TOKEN=$(echo "$KUBE_SERVICE_ACCOUNT_TOKEN_BASE64" | base64 -d)
      
      KUBECTL="$KUBECTL --certificate-authority=$CA_PATH --token=$KUBE_SERVICE_ACCOUNT_TOKEN"
    fi

    $KUBECTL get configmap ${SMUGGLER_configmap} >/dev/null

    $KUBECTL get configmaps ${SMUGGLER_configmap} -o json \
      | jq -r '.data["kubernetes.pod.name"]' > kubernetes-pod-name

    kube_pod_name=$(cat kubernetes-pod-name)

    if [ "$kube_pod_name" == "" ]; then
      echo No kubernetes pod named $kube_pod_name exists 1>&2
      exit 1
    fi

    mkdir -p /copied
    $KUBECTL cp $kube_pod_name:${SMUGGLER_path} /copied${SMUGGLER_path} 1>&2

    find /copied${SMUGGLER_path} -type f -print0 | sort -z | xargs -0 sha1sum | sha1sum | cut -d' ' -f 1 > current-version

    current_version=$(cat current-version)

    if [ "$current_version" != "${SMUGGLER_VERSION_ID:-}" ]; then
      echo "$current_version" >> ${SMUGGLER_OUTPUT_DIR}/versions
    fi

  in: |
    KUBE_URL=${SMUGGLER_k8s_api_endpoint_url:-https://kubernetes}
    NAMESPACE=${SMUGGLER_k8s_namespace:-default}
    KUBECTL="/usr/local/bin/kubectl --server=$KUBE_URL --namespace=$NAMESPACE"

    # configure SSL Certs if available
    if [[ "$KUBE_URL" =~ https.* ]]; then
      KUBE_CA_BASE64="${SMUGGLER_k8s_ca}"
      KUBE_SERVICE_ACCOUNT_TOKEN_BASE64=${SMUGGLER_k8s_service_account_token}
      CA_PATH="/root/.kube/ca.pem"
      
      mkdir -p /root/.kube
      echo "$KUBE_CA_BASE64" | base64 -d > $CA_PATH
      KUBE_SERVICE_ACCOUNT_TOKEN=$(echo "$KUBE_SERVICE_ACCOUNT_TOKEN_BASE64" | base64 -d)
      
      KUBECTL="$KUBECTL --certificate-authority=$CA_PATH --token=$KUBE_SERVICE_ACCOUNT_TOKEN"
    fi

    $KUBECTL get configmap ${SMUGGLER_configmap} >/dev/null

    $KUBECTL get configmaps ${SMUGGLER_configmap} -o json \
      | jq -r '.data["kubernetes.pod.name"]' > kubernetes-pod-name

    kube_pod_name=$(cat kubernetes-pod-name)

    if [ "$kube_pod_name" == "" ]; then
      echo No kubernetes pod named $kube_pod_name exists 1>&2
      exit 1
    fi

    $KUBECTL cp $kube_pod_name:${SMUGGLER_path} ${SMUGGLER_DESTINATION_DIR} 1>&2
```

---

# Future Work: Selenium from Concourse on Kubernetes on AWS

- AWS Device Farm --> Browser --> Temporary Endpoint --> (VPN -->) Target Webapp
- **AWS Device Farm** iOS、Androidデバイスをリモートで時間貸ししてくれるサービス
    - ただし、VPCにはつなげられない、VPNも使えないという制限がある。リモートで借りたデバイスはインターネットにしかアクセスできないということ。
    - **課題** 「まだインターネットに公開したくない、開発中Webアプリ」をDevice Farmでどうやって安全にテストする?
- 案: テスト対象のWebアプリに、インターネットからアクセス可能な一時URLを発行する
    - テスト対象のWebアプリがローカルマシンにある場合は、VPNを使ってインターネットからのアクセスをローカルK8S内で動いているWebアプリにフォワードする
    - **構成** Device --> 一時URL --> テスト環境K8S --> kube-openvpn --> ローカル環境K8S --> テスト対象Webアプリ
- **メリット** スマフォからのE2Eテスト用パイプラインさえもローカルで動かせる! DevProd Parity!

---

# 参考リンク

* [SeleniumのHelm Chart](https://github.com/kubernetes/charts/tree/master/stable/selenium)
* [SeleniumのK8S 
 Example](https://github.com/kubernetes/kubernetes/tree/master/examples/selenium)

---

# まとめ

* ConcourseでSeleniumの分散・並列テスト実行
* ローカルマシンでも本番サーバでも全く同じパイプラインでテストできる
* Concourse on K8Sならコードをいちいちpushしなくてもパイプラインをテストできる

---

## 自己紹介 & おわり

twitter/github/slack.k8s.io: @mumoshu (むもしゅ)
Primary maintainer of [kubernetes-incubator/kube-aws](https://github.com/kubernetes-incubator/kube-aws)

Fin :bow: :clap: