Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

#jjug_ccc_b 今日 話したいコト 8

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

#jjug_ccc_b 11

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

#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

Slide 15

Slide 15 text

#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) }

Slide 16

Slide 16 text

#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

Slide 17

Slide 17 text

#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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

#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

Slide 20

Slide 20 text

#jjug_ccc_b Ready. 準備完了 20

Slide 21

Slide 21 text

#jjug_ccc_b 21 shard: [1/1]

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

#jjug_ccc_b Result 結果 24

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

#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のテストコンテキストは 複数のテストクラス間で 可能な限り使い回される

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

#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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

#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 (後述)

Slide 48

Slide 48 text

#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ずつわりあてる

Slide 49

Slide 49 text

#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という 連想配列をループして 複数の仮想環境を構築

Slide 50

Slide 50 text

#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

Slide 51

Slide 51 text

#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 (説明は割愛すること)

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

#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 }}

Slide 57

Slide 57 text

#jjug_ccc_b Result 結果 57

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

#jjug_ccc_b 59 5m50s 8m51s

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

#jjug_ccc_b review まとめ 62

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

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

Slide 65

Slide 65 text

#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 }}

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

#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

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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

Slide 76

Slide 76 text

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