見出し画像

【登壇報告】JJUG CCC 2022 Spring で語りきれなかった技術的なお話

こんにちは、エンジニアチームの大橋と申します。

この度は JJUG CCC 2022 Spring に登壇いたしました。ご視聴いただいた皆様ありがとうございます。(登壇時の動画はこちら↓↓↓)

本稿では登壇時間内に説明しきれなかった詳細部分について、開発メンバーとまとめてみました。登壇をご視聴されていない方にもわかるように概要からまとめておりますので、ぜひご一読頂けたらと思います。

【発表の経緯】
当社のある開発プロジェクトにおいて開発ワークフローの改善を行いました。そのプロジェクトでは GitHub Actions で CI/CD を構築しています。開発初期に構築した CI/CD の仕組みを数ヶ月間運用して、いくつかの課題がみえてきました。運用してわかってきた課題に対して、どのようなアプローチで改善したか、改善後に実際の運用がどのように変わったかについてまとめます。

2022年3月7日に古い開発ワークフローから新しいものに切り替えました。その後、実際に運用してみて問題が起こっていないことから、ケーススタディの1つとして JJUG イベントで発表しようと考えた次第です。

■開発プロジェクトの概要

本稿で対象とする開発プロジェクトの概要を紹介します。GitHub Actions を用いたビルドやデプロイのワークフローを説明する際に、Java のパッケージ管理や Docker イメージ管理のリポジトリなども関係するのでまとめておきます。

  • 開発方法論: スクラム

    • メンバー: 9人 (プロダクトオーナー、スクラムマスター、開発者)

      • 開発者は6人

  • 開発対象アプリケーションのリポジトリ: 3つ

  • その他

    • 本番環境へのリリースは週1回

    • 開発者は大半はフルリモートワーク

■変更前の CI/CD

まず変更前の開発ワークフローについて簡単に説明します。おそらく一般的な Java アプリケーションの開発方法に近いものではないかと思われます。
変更前の CI/CD のシーケンス図を簡潔に表したものが次になります。

画像
変更前の CI/CD はブランチ戦略とワークフローが密接

バックエンドのインフラは EKS (Kubernetes) で運用しているため、デプロイするには Docker イメージをビルドする必要があります。そのため、Docker イメージのレジストリとして ECR も必要になります。
Java のソースコードをビルドしたときに生成される JAR ファイルを GitHub Packages にデプロイしてライブラリの依存関係を管理しています。
私たちの開発ワークフローにおいてビルドは2種類あります。

  • JAR ビルド

  • Docker イメージビルド

ビルドして生成された成果物の違いにより、アプリケーションをデプロイするためのパッケージを置く場所も2種類あります。

  • GitHub Packages: JAR をアップロード

  • ECR: Docker イメージをアップロード

最終的に Kubernetes の Pod としてアプリケーションがデプロイされるため、ECR にアップロードされている Docker イメージがデプロイに必要な成果物と言えます。

そして、成果物のビルドやアップロードとは別の観点から、リポジトリのブランチ戦略と GitHub Actions のワークフローが密接に関連していることも CI/CD の複雑さを増してしまっていました。

変更前は Git リポジトリで3つのブランチを管理していました。

  • dev: 開発ブランチ

  • test: テスト環境へデプロイするためのブランチ

  • main: 本番環境へデプロイするためのブランチ

基本的に日々の開発でコミットしたソースコードは dev → test → main の順番にマージしていく必要がありました。このことがワークフローの自由度や柔軟性を制限することにも繋がっていました。

典型的な運用としては、日々の開発の成果を dev ブランチにマージし、テスト環境にデプロイしたいときに test ブランチにマージします。開発期間中は dev と test ブランチへのマージが頻繁に発生します。そして、週に1回の本番リリースのときのみ test ブランチから main ブランチにマージします。

■変更前の CI/CD の課題

数ヶ月間、運用してみて次のような課題がわかってきました。

ワークフロー上の課題

  • テスト環境にデプロイしている SNAPSHOT JAR のリビジョンを管理していないため、テスト環境にデプロイしたアプリケーション (Docker イメージ) は再現不可能でした

  • 本番環境にデプロイするためにはバージョン番号を SNAPSHOT から正式バージョンに更新する必要がありました

  • バージョン番号を変更するだけの軽微な修正も dev ブランチでビルド・デプロイ、test ブランチでビルド・デプロイと順番に実行していかなければなりませんでした

運用上の課題

  • 本番リリースは手作業で行っていて最低1時間はかかっていました

    • ちょっとしたミスがあると3時間ぐらいかかっていました

    • バージョン番号を設定するために手作業で行う必要がありました

  • 本番リリース直後の一時期でないと、ホットフィックス を作成するのが難しく、開発・バージョン管理の運用から事実上はホットフィックスを提供できない状態でした

  • 本番リリースが毎週あるため、1週間以上かかる機能の開発をテスト環境へデプロイして検証することが難しくなっていました

    • 本番リリース前に dev と test からその機能を revert しないといけない

  • テスト環境と本番環境で別々にビルドしているという漠然とした不安がありました

■変更後の CI/CD

前節の課題を改善するために次の設計方針としました。

  • ビルドとデプロイのワークフローを完全に分離する

  • テスト環境で検証した Docker イメージを本番環境にデプロイする

  • Git リポジトリのブランチを main のみにする

開発のワークフローをシンプル、且つ効率化するためにブランチ戦略と GitHub Actoins のワークフローの依存管理を整理するところを出発点にして考えました。

その過程で大きな決定事項として、maven のバージョン管理をやめ、Git リポジトリのリビジョンでアプリケーションの成果物を管理することに決めました。つまり、maven のパッケージ管理としては 1.0.0-SNAPSHOT というバージョンを固定にしたまま、開発を続けるということです。

念のため、ここで言う maven のバージョン管理をやめるというのは、私たちの開発している内製アプリケーションのみです。モジュール数にすると、12個です。サードパーティのライブラリなどは依然として maven でバージョン管理しています。

maven のバージョン管理をやめる代わりに、JAR と Docker イメージのメタデータに Git リポジトリのコミットのリビジョンを保持するようにしました。

ビルドのワークフロー

JAR File Specification によると、任意のメタデータを保持する仕組みが提供されていて、Git リポジトリのリビジョンを含められます。git-commit-id-maven-plugin というプラグインを使って設定しています。次の例では Git-Commit-Id になります。

$ unzip myapp-1.0.0-SNAPSHOT.jar 
$ cat META-INF/MANIFEST.MF 
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.2.2
Build-Jdk-Spec: 11
Build-Tool: Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Build-Jdk: 11.0.15 (Private Build)
Build-Os: Linux (5.13.0-44-generic; amd64)
Specification-Title: myapp
Specification-Version: 1.0
Implementation-Title: myapp
Implementation-Version: 1.0.0-SNAPSHOT
Artifact-Id: myapp
Build-Time: 2022-06-14T09:03:51Z
Git-Branch: main
Git-Commit-Id: 9cb756d
Git-Commit-Time: 2022-06-14T10:32:43+0900
Git-Commit-User: Masaya Ohashi
Main-Class: org.springframework.boot.loader.JarLauncher
Spring-Boot-Version: 2.5.12
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx

ちょっとした工夫としては、Docker イメージに含める JAR の Main のエントリーポイントに version という引数を指定したときに JSON でビルド情報を返すようにしました。自分たちが開発しているアプリケーションの、myapp という JAR ファイルが依存するライブラリのリビジョンをすべて表示します。

$ java -jar myapp-1.0.0-SNAPSHOT.jar version | jq .
[
  {
    "buildOs": "Linux (5.13.0-44-generic; amd64)",
    "buildTime": "2022-06-14T09:03:51Z",
    "buildTool": "Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)",
    "buildJdk": "11.0.15 (Private Build)",
    "artifactId": "my-client",
    "gitCommitId": "9cb756d",
    "gitCommitUser": "Masaya Ohashi",
    "gitBranch": "main",
    "gitCommitTime": "2022-06-14T10:32:43+0900"
  },
  {
    "buildOs": "Linux (5.13.0-44-generic; amd64)",
    "buildTime": "2022-06-14T09:03:51Z",
    "buildTool": "Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)",
    "buildJdk": "11.0.15 (Private Build)",
    "artifactId": "my-api",
    "gitCommitId": "9cb756d",
    "gitCommitUser": "Masaya Ohashi",
    "gitBranch": "main",
    "gitCommitTime": "2022-06-14T10:32:43+0900"
  },
  {
    "buildOs": "Linux (5.13.0-44-generic; amd64)",
    "buildTime": "2022-06-14T00:06:00Z",
    "buildTool": "Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)",
    "buildJdk": "11.0.15 (Private Build)",
    "artifactId": "my-core",
    "gitCommitId": "1d3b8ac",
    "gitCommitUser": "Masaya Ohashi",
    "gitBranch": "main",
    "gitCommitTime": "2022-06-13T16:42:54+0900"
  }
]

この仕組みを使えば、Docker イメージのメタデータにリビジョンを設定するのも容易です。Docker object labels によると、任意のキーバリューを Docker イメージのメタデータに含められます。Docker イメージをビルドするときに JAR ファイルから取得したリビジョンをメタデータに付与します。docker inspect というコマンドを使うと次のように確認できます。

$ docker pull ${accountId}.dkr.ecr.ap-northeast-1.amazonaws.com/myapp:9cb756d
$ docker inspect ${accountId}.dkr.ecr.ap-northeast-1.amazonaws.com/myapp:9cb756d | jq '.[].Config.Labels'
{
  "version.my-core": "1d3b8ac",
  "version.my-api": "9cb756d",
  "version.my-client": "9cb756d"
}

このようにビルド処理はすべて Git のリビジョンで管理できるようにしてしまい、GitHub Packages にアップロードされる JAR ファイルは SNAPSHOT で常に上書きされますが、そこにある JAR ファイルから Git リポジトリのリビジョンはわかるようになります。

そして、JAR ファイルをビルドしてアップロードする同じタイミングで Docker イメージも生成してしまいます。ここまでの流れをシーケンス図にまとめると次になります。

GitHub Packages にアップロードされる JAR ファイルは SNAPSHOT であるので常に上書きされてしまうものの、デプロイに必要な Docker イメージはリビジョンごとに ECR に永続化されます。

実際にアプリケーションをデプロイする先は EKS であるため、Docker イメージさえバージョン管理できればよいという考え方になります。このやり方なら、過去にビルドした Docker イメージのリビジョンに戻すことも容易にできます。

デプロイのワークフロー

理屈の上では、ビルドのワークフローは変更前の CI/CD よりも高コストになります。変更前は dev ブランチにマージしたタイミングでは JAR ビルドと GitHub Packages へのアップロードしか行っていませんでした。変更後のビルド処理は必ず JAR ファイルと Docker イメージをビルドするように変更しました。

その最大のメリットとして、Docker イメージがすでにビルド済みなのでデプロイのワークフローは至ってシンプルになりました。

テスト環境へデプロイするときは、テスト環境の ECR から docker pull したものをテスト環境の EKS へデプロイするだけです。

本番環境へデプロイするときは、テスト環境の ECR から docker pull したものを本番環境の ECR に docker push した上で、本番環境の EKS へデプロイするだけです。Docker イメージをテストと本番の環境間で移動しているものの、同じイメージを使っているので本番リリースのためにビルドする必要はありません。

ECR に登録した Docker イメージはタグとしてリビジョン番号をセットしています。デプロイするときにリビジョン番号を指定することで過去にビルドした任意の Docker イメージをデプロイすることもできます。指定しないときは main ブランチの最新リビジョンが使われます。

余談ですが、GitHub Actions で Jenkins でいうところのパラメーター付きビルドができることをご存知でしょうか?2020年7月に workflow_dispatch イベントが追加された ことにより、任意のパラメーターを入力として GitHub Actions のワークフローを実行できます。この機能は GitHub の Web 画面とも連動していてちょっとした管理画面として使えます。

■CI/CD 変更によるメリットとデメリット

メリット

設計方針としてあげた項目を達成したことで意図した改善を実現しました。

  • maven のバージョン管理のための作業やワークフローを削減しました

  • ブランチが main のみなので開発者にとってブランチ操作がシンプルになりました

  • 本番リリース (デプロイ) の作業時間を大幅に短縮できました

    • 改善前: GitHub Actions の総実行時間 29分35秒

    • 改善後: GitHub Actions の総実行時間 5分22秒

    • 約72%の総実行時間の短縮、約5.5倍速くなりました

  • 任意のリビジョンの Docker イメージをテスト・本番環境へデプロイできるようになりました

  • いつでもホットフィックスをリリースできるようになりました

デメリット

この設計には大きなトレードオフがあります。

  • maven を使ったアプリケーションのバージョン管理ができない

    • 任意のバージョンを指定して古いパッケージに戻すことはできません

    • 外部のプロジェクトから本プロジェクトの成果物を再利用できません

私たちのプロジェクトでは最終的な成果物である Docker イメージのバージョンさえ管理できればよいという制約からこのアプローチが成り立ちます。必ずしもすべての開発プロジェクトの Java アプリケーションの開発ワークフローとして成り立つものではありません。

私たちの運用の特徴

デプロイ後に致命的な不具合があったときは Kubernetes の ロールバック機能 を使います。実際にはこの処理も GitHub Actions のワークフローとして定義してあります。GitHub の Actions の画面から環境を選択してワークフローを実行するだけです。

致命的な不具合以外は、その影響度によりホットフィックスを提供するか、次のリリースで修正するかを決めます。私たちのプロジェクトでは原則として、数世代前の過去バージョンに戻すという運用は行いません。不具合があった場合は最新バージョンに修正を行い、常に最新バージョンをデプロイします。

私たちの運用においては maven のバージョン管理を必要としなかったことがわかりました。

■変更後の実際の運用とコスト

開発ワークフローの改善とは直接関係ないので本稿では詳細を説明しませんが、spring-boot:build-image で生成していた Docker イメージのビルド処理を、jib を使ったビルド処理に置き換えました。その際に JDK から JRE への変更やマルチステージビルドの中間生成物をビルドキャッシュとして再利用するようにも変更しました。

この変更により、Docker イメージのビルド処理の実行時間を大幅に高速化できました。後述する月間の総実行時間においてビルド処理が速くなったようにみえてしまっているのですが、それは開発ワークフロー自体の改善によるものではなく、jib を使ったマルチステージビルドのビルドキャッシュによる効率化になります。

2022年3月7日にワークフローの切り替えを行いました。改善による成果を確認する指標の1つとして、その前後における GitHub Actions の総実行時間を比較してみました。

次の実行時間は、それぞれのリポジトリの1回当たりの平均実行時間を月間で算出して同じ回数に揃えて計算し直すといったスケーリングを施しています。厳密ではありませんが、単純比較するよりは正しい比較になっているはずです。

ビルドキャッシュの効率化が含まれているため、公正な比較ではありませんが、総実行時間は次のように 30-35% 程度削減できました。

  • (変更後) 4月: 15,433秒 ≒ 4時間17分

  • (変更前) 2月: 21,788秒 ≒ 6時間 3分

  • (変更前) 1月: 23,699秒 ≒ 6時間35分

リポジトリAのビルド実行時間が2月より4月の方が速くなっているのが先ほど説明した jib のマルチステージビルドのビルドキャッシュによる効果が大きいと考えられます。その点が厳密な比較にはなっていませんが、デプロイ時間が大幅に短縮できているのは意図した成果になります。

この効率化された時間を使って単体テストを必須とする運用変更も同時に行いました。

余談ですが、GitHub Actions には3つの時間があります。例えば、1分かかるジョブを並行で3つ実行するアクションを例として3つの時間を説明します。

  • 課金時間 (Billable time): 課金対象として数えられる時間

    • 3つのジョブの時間が課金対象なので3分

  • 経過時間 (Total duration): アクションの開始から終了までにかかった時間

    • 3つのジョブは並行ジョブなのでインスタンスの起動から終了までのオーバーヘッドなどを含めると、1分15秒

  • 実行時間 (Execution time): ジョブやステップが実際に実行された時間

    • ジョブ単体の時間は1分

先のスライドでの「総実行時間」とは「経過時間」を使って算出しています。

■まとめ

開発ワークフローの改善内容について簡潔にまとめます。

  • 数ヶ月間の実運用から効率のよくないところを洗い出し、
    ワークフローや運用の視点から課題として整理しました

  • 整理した課題を解決するための設計を行い、
    シンプルなワークフローへ作り変えることに挑戦しました

  • 私たちの開発プロジェクトでは maven のバージョン管理をやめました

本稿でも繰り返し述べましたが、Java のアプリケーション開発として一般的なやり方ではありません。一方で私たちの開発スタイルや運用に適した開発ワークフローとしては十分なものでした。自分たちの課題を明確にして、自分たちで作っていくことが重要だと私たちは考えています。

今後もより効率的な開発ワークフローを目指して改善を続けていきたいと思います。長文でしたが、最後までお読みいただきありがとうございました。

リファレンス


星野リゾートでは一緒に働く仲間を募集しています。


この記事が気に入ったらサポートをしてみませんか?