Upgrade to Pro — share decks privately, control downloads, hide ads and more …

JUnitテストをCI環境で並列で実行する方法とその速度, スケーラビリティ

JUnitテストをCI環境で並列で実行する方法とその速度, スケーラビリティ

プルリクエストを作ると自動的にCI環境で ./gradlew test が走り出す。しかし✅成功あるいは❌失敗のマークが表示されるまでジリジリと待つその時間が長過ぎる。gradleの"maxParallelForks"にも限界があり、テストが不安定になることもあった。Mockitoを使う、@SpringBootTestアノテーションを避ける、リポジトリ層のテストはmysqlではなくh2をインメモリで使う、そんな正攻法を取ろうにもすでに書いてしまった数十,数百KStepのテストコードを目の前にして途方にくれている。

あきらめたら、そこで試合終了ですよ。

このセッションでは、多数のJUnitテストクラスを複数の環境に分散させつつ実行することによって、最終的に✅か❌が出るまでジリジリと待つ時間を短縮する方法をお話します。テストコードには手をつけず、ビルドツール(gradle)とCI環境の工夫だけで、です。 サンプルのSpringBoot/Web+DBアプリケーションプロジェクト=テストクラス75個うち7個はselenium-javaによるe2eテスト=では、 CI環境での自動テスト処理時間の27%削減に成功しました。もっと大量のテストがあるプロジェクトなら並列度を上げれば処理時間削減の効果は大きくなるでしょう。富豪テストによる勝利の栄光をキミに!

Yu Watanabe

June 04, 2023
Tweet

More Decks by Yu Watanabe

Other Decks in Technology

Transcript

  1. #jjug_ccc_b
    JUnitテストをCI環境で
    並列で実行する方法と
    その速度, スケーラビリティ
    Yu Watanabe @nabedge
    JJUG-CCC 2023 Spring
    2023-06-04 Shinjuku, Tokyo

    View full-size slide

  2. #jjug_ccc_b
    登壇する前に資料を公開しちゃう
    メソッドが発動中
    ツイッターをフォロー!!
    @nabedge
    2

    View full-size slide

  3. #jjug_ccc_b
    (手元 ストップウォッチ スタート 確認)

    View full-size slide

  4. #jjug_ccc_b 4
    https://www.slideshare.net/RecruitLifestyle/ci-4510

    View full-size slide

  5. #jjug_ccc_b 5
    ✅ か ❌ が出るまでジリジリと待つ時間!!

    View full-size slide

  6. #jjug_ccc_b
    ✅ か ❌ が出るまで、イライラせず待てる時間は?
    6
    3分間待ってやる
    5分なら許す
    10分までだ
    15分

    View full-size slide

  7. #jjug_ccc_b
    自己紹介
    7
    ● 渡辺 祐
    ● Twitter: @nabedge
    ● https://www.linkedin.com/in/nabedge/
    ● https://speakerdeck.com/nabedge
    ● https://nabedge.mixer2.org/about
    イライラせず待てる時間 = 5分

    View full-size slide

  8. #jjug_ccc_b
    今日 話したいコト
    8

    View full-size slide

  9. #jjug_ccc_b
    1. CI環境で、
    2. JUnitのテストを複数のマシンで
    自動的に手分けして実行することで、
    3. CI待ちの時間を削減する、
    4. 2023年5月現在のポピュラーな技術による、
    5. 現実的かつシンプルな方法。
    9
    今日 話したいコト

    View full-size slide

  10. #jjug_ccc_b
    答え
    (たぶん皆さんが一番聞きたいコト)
    10

    View full-size slide

  11. #jjug_ccc_b 11

    View full-size slide

  12. #jjug_ccc_b
    Github Actionsでこれができるようにすればいい
    Runner01 ./gradlew test -Pshard=1/3
    Runner02 ./gradlew test -Pshard=2/3
    Runner03 ./gradlew test -Pshard=3/3
    12

    View full-size slide

  13. #jjug_ccc_b
    1. まず、すべてのテストクラスの
    リストをファイルに書き出しておく
    2. 各マシンは自分の担当テストを
    リストから抽出して実行する
    13
    ざっくりとした流れ

    View full-size slide

  14. #jjug_ccc_b
    すべてのテストクラスのFQCNのリストを作る
    14
    app-lib/src/test/kotlin/com/foo/lib/TestA.kt
    app-lib/src/test/kotlin/com/foo/lib/TestB.kt
    app-web/src/test/kotlin/com/foo/web/TestC.kt
    app-web/src/test/kotlin/com/foo/web/TestD.kt
    app-web/src/test/kotlin/com/foo/web/TestE.kt
    com.foo.lib.TestA
    com.foo.lib.TestB
    com.foo.web.TestC
    com.foo.web.TestD
    com.foo.web.TestE

    View full-size slide

  15. #jjug_ccc_b 15
    import org.apache.commons.io.FileUtils
    import org.gradle.api.Project
    import org.gradle.api.tasks.SourceSetContainer
    import java.io.File
    fun createList(testClassFqcnListFile: File, subprojects: Set) {
    val testClassFqcnList = subprojects
    .flatMap { pj ->
    val sourceSetContainer = pj.properties["sourceSets"] as SourceSetContainer
    val files = sourceSetContainer.getByName("test").allSource.files
    files.filter { it.absolutePath.endsWith(".kt") }
    }
    .filter { file ->
    val sourceCode = FileUtils.readFileToString(file, "UTF-8")
    sourceCode.contains("@Test") ||
    sourceCode.contains("@ParameterizedTest") ||
    sourceCode.contains("import io.kotest.core.spec.style.")
    }
    .map { file ->
    file.absolutePath
    .removeSuffix(".kt")
    .split("/src/test/kotlin/")[1]
    .replace("/", ".")
    }
    .sorted()
    FileUtils.writeLines(testClassFqcnListFile, testClassFqcnList)
    }

    View full-size slide

  16. #jjug_ccc_b
    FQCNのリストから自分の担当テストクラスを選ぶ
    16
    1. com.foo.lib.TestA
    2. com.foo.lib.TestB
    3. com.foo.web.TestC
    4. com.foo.web.TestD
    5. com.foo.web.TestE
    shard=1/3 のマシンの担当
    shard=2/3
    shard=3/3
    shard=1/3
    shard=2/3

    View full-size slide

  17. #jjug_ccc_b
    build.gradle.kts
    import org.apache.commons.io.FileUtils
    tasks.withType {
    useJUnitPlatform()
    filter {
    isFailOnNoMatchingTests = false
    val shard: String = project.properties["shard"] as String
    val match = "(\\d+)/(\\d+)".toRegex().find(shard)
    val shardIndex = match.groups[1]!!.value.toInt()
    val shardCount = match.groups[2]!!.value.toInt()
    return FileUtils
    .readLines(testClassFqcnListFile, "UTF-8")
    // 自分のテスト対象としたい
    FQCNをピックアップ。トランプ配り方式。
    .filterIndexed { index, _ -> (index + 1) % shardCount == shardCount - shardIndex }
    .forEach { fqcn -> this.includeTestsMatching(fqcn) }
    }
    }
    17

    View full-size slide

  18. #jjug_ccc_b
    build.gradle.kts
    tasks.withType {
    useJUnitPlatform()
    filter {
    includeTestsMatching(“com.foo.lib.TestA”)
    includeTestsMatching(“com.foo.lib.TestD”)
    ….
    }
    18
    ここが動的に変化する
    ようにプログラミング

    View full-size slide

  19. #jjug_ccc_b
    Github Actionsのyamlを書く
    runs-on: ubuntu-latest
    strategy:
    matrix:
    shard: [1/3, 2/3, 3/3]
    steps:
    - name: createTestClassFqcnList
    run: ./gradlew createTestClassFqcnList
    - name: Test execute by gradle
    run: ./gradlew test -Pshard=${{ matrix.shard }}
    19

    View full-size slide

  20. #jjug_ccc_b
    Ready.
    準備完了
    20

    View full-size slide

  21. #jjug_ccc_b 21
    shard: [1/1]

    View full-size slide

  22. #jjug_ccc_b 22
    shard: [1/2, 2/2]
    max値 =「CI待ち」時間

    View full-size slide

  23. #jjug_ccc_b 23
    shard: [1/4, 2/4, 3/4, 4/4]

    View full-size slide

  24. #jjug_ccc_b
    Result
    結果
    24

    View full-size slide

  25. #jjug_ccc_b 25
    処理時間がかえって
    増加、というより、
    安定しなくなった。
    4並列まではいい感じ

    View full-size slide

  26. #jjug_ccc_b
    8並列のCI一発で100円超えそうな課金
    26
    Billable time
    1h 28m
    1min = 0.008USD
    1h 28m = 97円
    (139 YEN/USD)

    View full-size slide

  27. #jjug_ccc_b
    カネがかかるわりに
    大して速くなってない。
    並列数を上げても無駄そう。
    27

    View full-size slide

  28. #jjug_ccc_b
    1. 水を飲む.
    2. 時間を確認する. 15分くらい?
    28
    ここからは別の試合が始まるぞ...
    ハーフタイム

    View full-size slide

  29. #jjug_ccc_b
    サンプルプロジェクトの構造
    ※個人的な習作のWeb/DBアプリケーション
    29

    View full-size slide

  30. #jjug_ccc_b 30
    Kotlin
    Spring Boot
    Gradle
    Flyway
    Thymeleaf
    Vue.js
    webpack
    Gradle plugin for Node
    MySQL
    AWS-S3
    AWS-SQS
    LocalStack

    View full-size slide

  31. #jjug_ccc_b 31
    ● マルチモジュール構成
    ○ foo-web
    ○ foo-lib
    ○ …
    ● テストクラスが全体で合計80個しかない
    ● うち10個はselenium-javaによるe2eテスト
    ● テスト並列化のサンプルにしては小規模すぎ...

    View full-size slide

  32. #jjug_ccc_b
    テスト実行の最低限の流れ
    32
    1. ソースコードをcheckout
    2. docker compose up -d
    3. flywayMigrate + α
    4. ./gradlew test
    ※ seleniumによるe2eテストもろとも実行される
    CI環境ではこのへんで
    ./gradlew createTestClassFqcnList

    View full-size slide

  33. #jjug_ccc_b
    大して速くならなかった
    速度が安定しなかった
    その原因
    33

    View full-size slide

  34. #jjug_ccc_b
    原因その1 小規模すぎる
    テストクラスが80個しかないんじゃ...
    34

    View full-size slide

  35. #jjug_ccc_b
    原因その2 Springの起動処理は重い
    35
    10sec 1 1 1
    Spring Start TestA,B,C
    13 sec.
    10sec 1 TestA
    10sec 1 TestB
    10sec 1 TestC
    11 sec.
    Serial Test 3-Parallel Test
    ※Springのテストコンテキストは
    複数のテストクラス間で
    可能な限り使い回される

    View full-size slide

  36. #jjug_ccc_b
    SpringやめてQuarkusに乗り換えるしか...
    36

    View full-size slide

  37. #jjug_ccc_b
    原因その3 やはりキャッシュ
    ~/.gradle/*
    node_modules/*
    docker cache
    37

    View full-size slide

  38. #jjug_ccc_b
    自分の手元のMacBookが最速な理由=キャッシュ
    % time docker compose up -d
    docker compose up -d 0.07s user 0.04s system 14% cpu 0.762 total
    % time ./gradlew :db-migration:run # flywayMigrateしている
    ./gradlew :db-migration:run 1.33s user 0.13s system 8% cpu 17.531 total
    % time ./gradlew test
    ./gradlew test 1.83s user 0.23s system 1% cpu 2:38.16 total
    38

    View full-size slide

  39. #jjug_ccc_b
    Github Actionsのcache保存機能
    39
    ● yaml上の設定がめんどくさすぎる
    ● キャッシュの退避&warm upの時間
    ● 少しでもcache keyが違うと
    全てが無かったことになる
    ● 正直アテにしていない(※個人の感想)

    View full-size slide

  40. #jjug_ccc_b
    そもそも勝負にならない
    キャッシュが常にある環境
    VS
    キャッシュが常に消えていて、
    どこかに退避させておいたキャッシュを
    いちいち書き戻す環境
    40

    View full-size slide

  41. #jjug_ccc_b 41
    jobs:
    test:
    runs-on: ubuntu-latest
    この環境でCIを高速化
    しようとするのが無理スジ

    View full-size slide

  42. #jjug_ccc_b 42
    jobs:
    test:
    runs-on: self-hosted

    View full-size slide

  43. #jjug_ccc_b 43
    オンプレミス
    Self Hosted Runner
    mini-PC x 3
    密輸品
    ちゃぶ台
    スイッチングハブ
    ニトリのカゴ

    View full-size slide

  44. #jjug_ccc_b
    PC02, 03...
    44
    Ubuntu
    GHActions
    Runner
    on
    Ubuntu
    Oracle Virutal Box
    GHActions
    Runner
    on
    Ubuntu
    GHActions
    Runner
    on
    Ubuntu
    PC01

    View full-size slide

  45. #jjug_ccc_b
    Runner用 仮想環境の中身
    45
    Ubuntu
    GHActions Runner
    source code
    JDK
    Docker Engine
    MySQL
    Redis
    LocalStack

    View full-size slide

  46. #jjug_ccc_b
    オンプレミスSelf Hosted Runner群
    の構築と監視、メンテナンスの
    自動化
    46

    View full-size slide

  47. #jjug_ccc_b
    PC02, PC03…
    ホストマシンのプロビジョニング
    47
    MacBook上でansible-playbook
    PC01
    1. apt-get install curl jq virtualbox vagrant
    2. Vagrantfileの設置
    3. OS起動時のsystemdサービスでvagrant up
    (後述)

    View full-size slide

  48. #jjug_ccc_b
    Vagrantfile
    48
    CLUSTER = {
    "pc01" => {
    "self-runner01" => { :cpus => 2, :mem => 8192 },
    "self-runner02" => { :cpus => 2, :mem => 8192 },
    "self-runner03" => { :cpus => 2, :mem => 8192 }
    },
    "pc02" => {
    "self-runner04" => { :cpus => 2, :mem => 8192 },
    "self-runner05" => { :cpus => 2, :mem => 8192 },
    "self-runner06" => { :cpus => 2, :mem => 8192 }
    }, …(snip)...
    }
    Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
    config.vm.box = "ubuntu/jammy64"
    ……..
    8コア32GメモリのPCの中で
    3つの仮想環境に
    2コア8Gずつわりあてる

    View full-size slide

  49. #jjug_ccc_b
    Vagrantfile
    49
    CLUSTER[ENV['VG_HOST_NAME']].each do |runner_name, info|
    config.vm.define runner_name do |cfg|
    cfg.vm.provider :virtualbox do |vb, override|
    override.vm.hostname = runner_name
    vb.name = runner_name
    vb.cpus = info[:cpus]
    vb.memory = info[:mem]
    vb.gui = false
    end # end provider
    cfg.vm.provision :root_user, type:"shell", path: "scripts/provision.sh", env: {
    前ページで作った
    CLUSTERという
    連想配列をループして
    複数の仮想環境を構築

    View full-size slide

  50. #jjug_ccc_b
    script/provision.sh
    1. apt-get install \
    git corretto11 corretto17 google-chrome docker-ce
    etc……
    2. jenvをインストール、JDKを登録
    3. self-hosted-runner.tar.gz を
    ダウンロード、展開、起動
    50

    View full-size slide

  51. #jjug_ccc_b
    systemdでPC起動時にvagrant up
    # /etc/systemd/system/gha-self-runner-up.service
    [Service]
    Type=oneshot
    WorkingDirectory=/home/foo/ghactions-self-runners-vagrant
    ExecStart=/home/foo/ghactions-self-runners-vagrant/vagrant-up.sh
    RemainAfterExit=yes
    Restart=no
    User=foo
    Group=foo
    51
    vagrant destroy -f
    && vagrant up
    (説明は割愛すること)

    View full-size slide

  52. #jjug_ccc_b
    1. 手元のMacからansible-playbook を実行
    2. すべてのPCの電源ボタンで再起動
    3. GithubのRunnersのページを見てみると...
    52

    View full-size slide

  53. #jjug_ccc_b 53
    yamlの runs-on: self-hosted
    として使うタグ
    死活監視

    View full-size slide

  54. #jjug_ccc_b
    SHRマシン群のメンテナンス
    ● テストが増えたのでマシンも増やそう
    ● docker system prune しなきゃ
    ● selenium-java用のGoogle Chromeのversion up
    ● githubのfine grained tokenがexpire
    ● ブレーカー飛んだ or 点検で停電
    54

    View full-size slide

  55. #jjug_ccc_b
    1. ansibleを流して
    2. PCの電源を入れ直すだけで
    仮想OS破壊(vagrant destroy)
    からの
    再構築(vagrant up)
    が完了
    55

    View full-size slide

  56. #jjug_ccc_b
    完成したSHR群を使ってテスト並列化のリベンジ
    56
    runs-on: ubuntu-latest self-hosted
    strategy:
    matrix:
    shard: [1/3, 2/3, 3/3……]
    steps:
    - name: createTestClassFqcnList
    run: ./gradlew createTestClassFqcnList
    - name: Test execute by gradle
    run: ./gradlew test -Pshard=${{ matrix.shard }}

    View full-size slide

  57. #jjug_ccc_b
    Result
    結果
    57

    View full-size slide

  58. #jjug_ccc_b
    1. 水を飲む.
    2. 時間を確認する. 35分くらい?
    58
    まだあわてるような時間じゃない
    タイムアウト

    View full-size slide

  59. #jjug_ccc_b 59
    5m50s
    8m51s

    View full-size slide

  60. #jjug_ccc_b
    SHRならgithubの課金はゼロ円
    60
    (キリトリ)

    View full-size slide

  61. #jjug_ccc_b
    ● CI待ち時間 5分台 を達成
    🎉
    ○ 並列なし→8並列で35%Down
    ● ランニングコストは電気代だけ
    ● テストが増えたらマシンを増やして
    shardの設定値を変えるだけ。
    61

    View full-size slide

  62. #jjug_ccc_b
    review
    まとめ
    62

    View full-size slide

  63. #jjug_ccc_b 63
    (再)今日 話したいコト
    1. CI環境で、
    2. JUnitのテストを複数のマシンで
    自動的に手分けして実行することで、
    3. CI待ちの時間を削減する、
    4. 2023年5月現在のポピュラーな技術による、
    5. 現実的かつシンプルな方法。

    View full-size slide

  64. #jjug_ccc_b
    ● JUnitテストを複数マシンで
    分散、並列実行するのは意外とカンタン。
    ● gradleなら。
    64
    ./gradlew test -Pshard=m/n

    View full-size slide

  65. #jjug_ccc_b
    1. groovy or kotlin プログラミング on build.gradle(.kts)
    2.
    65
    # Github Actionsのyaml定義
    strategy:
    matrix:
    shard: [1/3, 2/3, 3/3]
    steps:
    - name: createTestClassFqcnList
    run: ./gradlew createTestClassFqcnList
    - name: Test execute by gradle
    run: ./gradlew test -Pshard=${{ matrix.shard }}

    View full-size slide

  66. #jjug_ccc_b
    ✅or❌を待つ時間が大して短縮されない原因
    66
    ? TestA TestB TestC
    Serial
    ? TestA
    ? TestB
    ? TestC
    3-Parallel

    View full-size slide

  67. #jjug_ccc_b
    ?の正体
    1. Springの起動処理
    2. Cache warm-up
    67

    View full-size slide

  68. #jjug_ccc_b
    Springの起動を速くするには?
    1. テストのコンテキストをできるだけ揃える
    2. SpringをやめてQuarkusに移行する
    68

    View full-size slide

  69. #jjug_ccc_b
    キャッシュとラクにつきあうには?
    69
    jobs:
    test:
    runs-on: self-hosted

    View full-size slide

  70. #jjug_ccc_b
    SHRはお金次第
    ● githubのnormal runner(ubuntu-latest)のコスパ
    ○ 0.008USD/minute
    ○ CPU = 2 core, Memory = 7 GByte
    ■ https://docs.github.com/ja/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources
    ● aws-ec2でやるなら t3.large に相当
    ● オンプレミスなら初期費用+電気代のみ
    70

    View full-size slide

  71. #jjug_ccc_b
    SHRの構築/運用の自動化は必須
    1. スケールアウト=数の勝負=手作業では無理
    2. オンプレミスの場合 OracleVirtualBox, ansible,
    vagrant, shell script で十分な自動化が可能
    ○ 電源ボタンだけで再構築できるようにする
    ○ もっと数が多い場合にはホストOSの
    PXEブート&autoinstall / kickstartを
    検討すべき
    71

    View full-size slide

  72. #jjug_ccc_b
    Cost performance
    72
    ● エンジニアの人数, 時給
    ● 1日あたりPRの数
    ● PRに追加されるコミット数=
    テストの実行回数
    ● ✅or❌を待つイライラ時間
    ● そのプロダクト全体のQCD
    ● Runnerの
    必要数
    ● Runnerの
    構築,運用コスト
    vs

    View full-size slide

  73. #jjug_ccc_b
    このセッションで使ったサンプルは小規模すぎる
    73
    test, test…
    prepare for test
    test, test, test, test…
    prepare for test
    Small PJ
    Large PJ
    複数マシンで並列化
    できるのはここだけ

    View full-size slide

  74. #jjug_ccc_b
    「CI待ちn時間」の皆さんのプロジェクトは
    どの富豪スケールアウトにしますか?
    A. Default Runner
    B. Self Hosted Runner on On-premise
    C. Self Hosted Runner on EC2,GCP,etc
    74

    View full-size slide

  75. #jjug_ccc_b
    ご相談ください
    @nabedge
    Yu Watanabe
    75

    View full-size slide

  76. #jjug_ccc_b
    ご清聴ありがとうございました
    富豪テストによる
    勝利の栄光をキミに!
    76

    View full-size slide