Unity プロジェクトの Jenkins を再構築した話

[TECOTEC Advent Calendar 2017](https://qiita.com/advent-calendar/2017/tecotec) の 15 日目の記事です。

現在、 Unity を使ったモバイル向けゲーム開発をおこなっています。
リポジトリは GitHub のプライベートリポジトリを使い PullRequest ベースの開発をしています。
わりと初期の段階から、 Jenkins を使って PullRequest をビルドしてチェックをおこなっていましたが、
プロジェクトが大きくなるにつれて、人数が増える、画像などのアセットのみの変更が増えるなどして、
PullRequest の数が多くなり、 Jenkins が Job を捌ききれなくなってきました。
そこで、 Jenkins の Job を再構築して PullRequest のチェックにかかる時間を短縮することにしました。

# これまで
## リポジトリ
- Unity のプロジェクトを GitHub のプライベートリポジトリにホスティング
- テクスチャなどのアセットは Git LFS を使ってリポジトリを軽量化
- アセット類を別リポジトリに分ける事などはせず、基本、全てをひとつのリポジトリに入れいている
- Jenkins の Job を定義した Jenkinsfile も、このリポジトリにある

## Jenkins
- 社内の MacPro に Jenkins をインストール
- [GitHub pull request builder plugin](https://wiki.jenkins.io/display/JENKINS/GitHub+pull+request+builder+plugin) を使い、1 min 毎に polling して PullRequest がオープンされたり、追加コミットが Push される度に Job を走らせる
- Job の定義はできるだけ Pipeline を使って、上記リポジトリ内の Jenkinsfile に記述する
- Android 用のビルドと iOS 用のビルドをそれぞれ並行して走らせ、ビルドが成功したら GitHub のステータスを SUCCESS に変更する

## Unity 
- Android の ビルドは IL2CPP を使ってできるだけ本番環境に近づける
- iOS のビルドは Unity のビルドまでで、 XCode のビルドは行わない
- パッケージに入れるアセット類も基本的にすべてビルド

# Jenkins の Job が遅くなる要因

1. Jenkinsfile が Unity プロジェクトのリポジトリに含まれている
    - そのため、Jenkins が Job の定義を取得するために大きなアセットを含む、大きなリポジトリをチェックアウトする必要がある
2. Polling で PullRequest の変更を監視するため、PullRequest をオープンしてからテストが実行されるまでに多少のタイムラグが発生する
3. Jenkins の Git Plugin を使ってソースコードをチェックアウトしている
    - チェックアウト時に LFS で管理しているアセットも同時にダウンロードされるため、アセットの変更が含まれていると、チェックアウトに時間がかかってしまう
4. アセットが多くなると Unity のビルドが遅くなる
5. IL2CPP でのビルドが遅い

ざっと、以上のような点が Jenkins の Job を遅くている要因と考えられます。
ちなみに、修正前は一回のチェックに 30 分程度時間かかっていました。

# 今回やったこと
## Jenkinsfile を置くリポジトリを別にする
Jenkinsfile を Git リポジトリ上で定義した場合、その定義を Jenkins のワークスペースにチェックアウトするのですが、そのチェックアウト処理に Git Plugin が使われます。
そのため、 Jenkins ファイルのリポジトリは小さいほうがチェックアウトにかかる時間を短縮できます。

## Jenkins Job のトリガーとして GitHub の WebHook を使う
Job 自体の時間短縮には関係ないですが、 Job が起動するまでにタイムラグがあるのがなんとなく嫌だったので、 WebHook に変更しました。

実現したいことは 2 つで、

1. PullRequest がオープンしたり、新しいコミットがプッシュされたりしたら Jenkins の Job が実行される
2. GitHub pull request builder plugin と同じように "test this please" とコメントしたら Jenkins の Job が実行される

です。 Jenkins 側と GitHub 側の作業が必要になります。

### PullRequest のオープンとコミットのプッシュで実行する Job

#### GitHub 側
Payload URL を `http://[user]:[token]@[Jenkins server]/job/[jobname]/buildWithParameters` とし、トリガーイベントを `Pull request` に設定します。

#### Jenkins 側
ビルドのパラメータ化にチェックを入れて、 テキスト形式の `payload` を受け取るようにします。

以下のような Jenkinsfile を作成します。

```
import groovy.json.JsonSlurper

node
{
    stage("Test Pull Request")
    {
        def json = URLDecoder.decode("${payload}", "UTF-8");
        def data = new JsonSlurper().parseText("${json}")

        if ("${data.action}" == "opened" || "${data.action}" == "synchronize")
        {
            build job: 'pull-request-android', parameters: [
                text(name: 'head_sha', value: "${data.pull_request.head.sha}"),
                text(name: 'base_ref', value: "${data.pull_request.base.ref}")
            ], wait: false

            build job: 'pull-request-ios', parameters: [
                text(name: 'head_sha', value: "${data.pull_request.head.sha}"),
                text(name: 'base_ref', value: "${data.pull_request.base.ref}")
            ], wait: false
        }

    }
}
```

Payload をパースして、下流の Job に、 PullRequest の HEAD のコミットハッシュとベースのブランチ名を渡しています。

### "test this please" で再テストする Job
#### GitHub 側
トリガーイベントを `Issue comment` に設定します。

#### Jenkins 側
ビルドのパラメータ化にチェックを入れて、 テキスト形式の `payload` を受け取るようにします。

以下のような Jenkinsfile を作成します。

```
import groovy.json.JsonSlurper

node
{
    def pull_request_number = 0

    stage("Check Pull Request Commnt")
    {
        def json = URLDecoder.decode("${payload}", "UTF-8")
        def data = new JsonSlurper().parseText("${json}")

        if ("${data.action}" != "created")
        {
            return
        }

        if ("${data.comment.body}" != "test this please")
        {
            return
        }

        pull_request_number = "${data.issue.number}"
    }
    stage("Test Pull Request")
    {
        if (pull_request_number == 0)
        {
            return
        }

        def response = httpRequest customHeaders: [[maskValue: false, name: 'Authorization', value: "token ${GITHUB_ACCESS_TOKEN}"]], url: "https://api.github.com/repos/foo/bar/pulls/${pull_request_number}"
        def content = new JsonSlurper().parseText("${response.content}")

        build job: 'pull-request-android', parameters: [
            text(name: 'head_sha', value: "${content.head.sha}"),
            text(name: 'base_ref', value: "${content.base.ref}")
        ], wait: false

        build job: 'pull-request-ios', parameters: [
            text(name: 'head_sha', value: "${content.head.sha}"),
            text(name: 'base_ref', value: "${content.base.ref}")
        ], wait: false

    }
}
```

## Git Plugin の代わりに、直接 Git コマンドを書く
Jenkinsfile に以下のようなかたちで直接 Git コマンドを書きます。

```
pipeline {
    agent any
    stages {

        .
        .
        .

        stage('Download Source Code') {
            steps {

                sh returnStatus: true, script: """

                    #!/bin/bash

                    set -eu

                    PATH=/usr/local/sbin:$PATH
                    PATH=/usr/local/bin:$PATH

                    if [ ! -d foo ]; then
                        git lfs clone "${git_repo}"
                    fi

                    git -C foo fetch --prune
                    git -C foo reset --hard
                    git -C foo clean -df

                    git -C foo checkout "origin/${base_ref}"
                    git -C foo reset --hard
                    git -C foo clean -df

                    git -C foo merge --no-commit "${head_sha}"

                """
            }
        }

```

ポイントは `git clone` を使わずに `git lfs clone` を使うことです。

## ビルドの前にアセットファイルを削除する

この Job の目的は、「各プラットフォームでソースコードが正常にコンパイルできること」を確認することなので、全てのアセットを含めてビルドする必要はありません。
なので、思い切って、アセットが格納されているディレクトリごと削除してしまいます。

今回やったことの中では、これが最も効果的でした。


## Mono ビルドに変更する
Android の場合、スクリプティングバックエンドに Mono と IL2CPP を選べます。
本番用では IL2CPP にするので、これまでは IL2CPP でビルドをしていましたが、今回ビルド時間短縮のため Mono でビルドするように変更しました。

# 結果
1 回の PullRequest のチェックに 30 分程度かかっていましたが、 Jenkins Job の再構築後はおおよそ 4 分弱にまで時間を短縮することができました。

これで、チェックが終わらずなかなかマージできない、という状況は改善できたと思います。

# まとめ
Unity のプロジェクトの PullRequest 時のチェックにかかる時間を短縮するために、 Jenkins の Job を再構築しました。
ポイントは、

- Jenkins の Pipeline Job の定義を記述する Jenkinsfile は独立のリポジトリにする
    - Jenkinsfile をチェックアウトする時間が短縮できます。
- Jenkins Job のトリガーとして GitHub の WebHook を使う
    - Job が実行されるまでのタイムラグがなくなります。
- Git Plugin の代わりに、直接 Git コマンドを書く
    - 特に LFS で管理しているアセットが多いと効果的です。
- ビルドの前にアセットファイルを削除する
    - アセットのビルドが不要な場合に限りますが、Unity のビルド時間が大幅に短縮できます。
- Mono ビルドに変更する
    - IL2CPP より Mono ビルドの方がビルド時間を短縮できます。

Unity など、クライアント側で CI をするには、まだまだ Jenkins が使われると思いますが、 Job を組み立てる際の参考にしていただければと思います。

# 参考
https://qiita.com/bonotake/items/37fb3194c33f3ae3bbf0