AI駆動開発の品質をどう守る?実案件で起きたCIの落とし穴と対策
生成AIを開発に本格的に取り入れると、開発スピードはかなり上がります。
実装だけでなく、テスト作成、リファクタリング、Pull Request作成、レビュー指摘への対応など、これまで人間が時間をかけていた部分をAIが高速に処理できるようになります。
一方で、実際の案件でAI駆動開発を続けていると、別の問題が見えてきました。
コードを書く速度が上がるほど、「何をマージしてよいのか」を判断する仕組みの重要性が上がる。
人間が1日に数本しかPull Requestを作らないのであれば、人間の目で確認する運用でもある程度回ります。
ところがAIエージェントを使い、複数の作業を並行させるようになると、短時間に多数のPull Requestが作られます。
そうなると、
- テストが本当に実行されたのか
- セキュリティチェックを通っているのか
- 他の変更によって既存機能が壊れていないか
- CIそのものが正しく機能しているのか
といったことを、人間の注意力だけで担保するのは難しくなります。
そこで弊社の開発では、AIにコードを書かせるだけではなく、AIが高速に開発しても品質を維持できる「開発ハーネス」そのものを設計することを重視しています。
今回は、実際の開発で引っかかった問題と、その対策をいくつか紹介します。
CIを作っただけでは、品質は守られなかった
最初に遭遇したのは、非常に基本的ですが見落としやすい問題でした。
GitHub Actionsにはテスト、Lint、静的解析などのCIを設定していました。
ところが調べてみると、GitHub側の設定ではそれらがRequired Status Checks(必須チェック)になっていませんでした。
つまり、
CIは動いている。しかしCIが失敗していてもマージできる。
という状態です。
実際、短時間に複数のPull Requestを連続でマージした結果、CIが一度も実行されていない状態でマージされたPull Requestが発生しました。
これは「テストを書いているから安全」という問題ではありません。
必要だったのは、
テストを実行する仕組み
と、
テストを通らなければマージできない仕組み
を分けて考えることでした。
そこでGitHub Ruleset側にRequired Status Checksを設定し、さらにmainブランチの最新版を取り込んだ状態でCIを通すことを要求する設定を加えました。
これによって、
「過去のmainに対してテストが成功したPull Request」
ではなく、
「今からmainに入る実際の組み合わせ」でテストが成功したPull Request
だけをマージできるようにしています。
「赤いPull Requestを止める」だけではテスト不足
ここでもう一つ面白い問題がありました。
Required Status Checksを設定すると、当然ながらCIが失敗したPull Requestはマージできなくなります。
では、
「意図的にテストを失敗させて、マージできないことを確認した」
だけで設定確認は十分でしょうか。
実はそうではありません。
Required Status Checksで指定する名前を間違えると、
GitHubが永遠にそのチェック結果を待ち続ける
という状態になります。
当然Pull Requestはマージできません。
一見すると、
「ちゃんと厳しく守られている」
ように見えます。
しかし実際にはCI設定が壊れています。
そのため現在は、
- CIが失敗したPull RequestがBLOCKされる
- CIが成功したPull RequestがCLEANになる
- mainが更新された場合、古いPull Requestが再検証対象になる
という両方向から確認しています。
これはAI駆動開発に限らない話ですが、
「失敗することを確認するテスト」と「成功できることを確認するテスト」は両方必要
という考え方です。
ローカルのGit hookだけでは守れない
次に問題になったのが、秘密情報の検知です。
APIキーやパスワードなどがGitリポジトリへ入り込まないように、ローカルではgitleaksを使った検査を行っていました。
pre-commit hookを使えば、コミットする前に秘密情報を検知できます。
一見すると十分に思えます。
ところが実際に経路を洗い出してみると、簡単に迂回できます。
たとえば、
git commit --no-verify
を使えばhookを飛ばせます。
新しくcloneした環境ではhook自体が設定されていないこともあります。
gitleaksがインストールされていない環境もあります。
GitHubのWeb UIから直接編集された場合は、そもそもローカルPCを通りません。
つまり、
ローカルhookは「早く気づく仕組み」であって、「最後の防御線」にはできない。
ということです。
そこで現在は、
ローカル
↓
pre-commit
に加えて、
GitHub
↓
CI
でも同じ秘密情報検査を実施しています。
ここで重要なのは、単純に同じ検査を二重に実行しているわけではないことです。
ローカルではコミット対象だけを高速にチェックし、CIではそのブランチが持ち込む履歴まで確認します。
速度を重視する層と、確実性を重視する層を分けています。
AI駆動開発では、この「多層防御」の考え方がかなり重要だと感じています。
CIを高速化したら、別の穴が開いた
AI駆動開発ではPull Requestの数が増えます。
そのためすべてのPull Requestで重いテストを実行すると、CIの待ち時間も実行コストも増えていきます。
そこで弊社では、
Quick CI
と
Full CI
を分けています。
Pull Requestでは高速なQuick CIを実行し、より重い検証は必要に応じてFull CIで実施する方式です。
ところが、この高速化によって別の問題が起きました。
Quick CIではUnit Testだけを実行していました。
一方で、
「ログイン試行回数を超えた場合にHTTP 429が返るか」
というセキュリティ上重要なテストはFeature Test側に存在していました。
結果として、
レート制限の実装そのものが消えても、必須CIがすべて緑になる
状態が生まれていました。
そこで重要なFeature Testだけをグループ化し、Quick CIからも実行するように変更しました。
さらに、
テスト対象が0件だった場合もCIを失敗させる
ようにしています。
なぜなら、
「テストが成功した」
ことと、
「テストが1件も実行されなかった」
ことは全く違うからです。
ところがCIの設定によっては、0件のテストでも正常終了します。
そのため弊社では、
0件チェックは失敗として扱う
というルールをいくつかの品質ゲートで採用しています。
AI駆動開発では「判定する側」を守る必要がある
もう一段複雑だったのが、UI移行プロジェクトでの品質管理です。
ある案件では、既存画面と新しい画面を比較し、
- 見た目
- 動作
- 計測結果
- 受入結果
を記録しながら、多数の画面を並行して移行しています。
ここで問題になるのが共通ファイルです。
例えば共通CSSを変更すると、すでに受入済みだった画面の見た目まで変わる可能性があります。
にもかかわらず、受入時の証跡はそのまま残ります。
つまり、
記録上は合格しているが、現在の画面はそのとき計測した状態ではない
という状況が発生します。
そこで、受入済み画面が依存するファイルをロックする「Parity Lock」という仕組みを作りました。
ところがさらに問題があります。
通常のGitHub ActionsはPull Request側のコードで動きます。
つまり、
ルールに違反しているか判定されるPull Request自身が、判定プログラムを書き換えることができる。
という構造になります。
これはセキュリティでいう「信頼境界」の問題です。
そこで、この品質ゲートだけはGitHub Actionsのpull_request_targetを利用し、
mainブランチ側に存在する信頼済みの判定器
でPull Requestを検証するように変更しました。
Pull Request側のコードは実行しません。
あくまで「検査されるデータ」として読み込みます。
こうすることで、
判定される側が判定器を書き換える
という構造を排除しています。
AIエージェントに大きな権限を与えて開発させる場合、この「何を信頼するか」という設計は特に重要になります。
厳しくしすぎた結果、今度は運用が壊れた
品質ゲートを増やしていくと、
「厳しくすればするほど安全になる」
と思いがちです。
ところが実際にはそうなりませんでした。
Parity Lockを強くしすぎた結果、
正当な再計測や再受入作業までCIが拒否する
状態になったのです。
しかも判定器自身もロックされていたため、
「一時的にロックを解除するPull Request」
すら通りません。
結果として一時期、
CIが赤いことを理解したうえでマージする
という運用が発生しました。
これは非常に危険です。
一度、
「この赤は無視してよい」
という運用ができると、
次にCIが赤くなったとき、
本当に危険なのか、それとも無視していいのか
人間が判断しなければならなくなります。
それでは自動化した意味がありません。
そこで、
正式な例外処理ルート
を設計しました。
ただし、単純な「CIを無効化するフラグ」にはしていません。
対象画面、Issue番号、変更可能なファイル、期限、再計測結果、レビュー結果などを記録し、
なぜその例外が許可されたのかをGitの差分として残す
仕組みにしています。
ここから得た教訓は、
エスケープハッチは意図的に不便にする。ただし、必ず用意する。
ということでした。
簡単に回避できるルールは意味がありません。
しかし絶対に回避できないルールも、最終的には人間に無視されます。
重要なのは、
例外を使った事実と理由が後から追跡できること
だと考えています。
AI駆動開発は「AIにコードを書かせること」ではない
こうした改善を続けていると、AI駆動開発に対する考え方も少し変わってきました。
AIにコードを書かせるだけなら、現在の生成AIを使えば比較的簡単にできます。
難しいのは、その先です。
AIが大量のコードを書く環境で、
- 間違った変更をどう検知するか
- セキュリティ上の問題をどう防ぐか
- 並行開発による影響をどう管理するか
- CIそのものが壊れていないことをどう確認するか
- AIが触ってよい範囲をどう定義するか
- 人間が判断すべき場所をどこに残すか
といった仕組みを設計する必要があります。
つまり、
AI駆動開発の生産性は、AIモデルの性能だけでは決まりません。
AIが安全に高速で動ける環境をどれだけ作れるか。
そのためのCI、テスト、GitHub Ruleset、静的解析、秘密情報検知、レビュー、信頼境界などを含めた「開発システム全体」の設計が重要になります。
品質ゲートは増やせばいいわけではない
もちろん、すべてをCIに載せればよいわけでもありません。
実際の案件でも、
- 人間が画面を見て判断するもの
- CIでは取得できない実行環境情報
- システム上検出できない依存関係
など、自動化できていない部分は残っています。
重要なのは、それを無理に自動化することではありません。
「何を自動検知していて、何を検知していないのか」を明確にすること。
弊社では品質ゲートを追加するとき、
「チェックできるものを全部追加する」
という考え方ではなく、
現在のCIが緑なのに、実際には問題が発生するケース
を優先的に塞ぐようにしています。
CIを増やせば、CI自体の保守コストも増えます。
開発速度、安全性、保守性のバランスを取りながら改善していく必要があります。
失敗からルールを作る
今回紹介した仕組みのほとんどは、最初から完璧に設計されていたものではありません。
実際には、
問題が起きる
↓
なぜ既存の仕組みで検知できなかったか調べる
↓
品質ゲートを追加する
↓
今度はゲートの副作用が起きる
↓
運用を含めて再設計する
という改善の積み重ねです。
現在、弊社で大切にしている考え方の一つが、
ルールには、それを作るきっかけになった事象をセットで残す
ことです。
理由の分からないルールは、時間が経つと形骸化します。
逆に、
「この問題を防ぐためにこのチェックがある」
と分かれば、そのルールが今も必要なのか、別の方法へ置き換えられるのかも判断できます。
AI駆動開発では、開発速度が上がるからこそ、こうした改善サイクルも高速に回す必要があります。
弊社では生成AIを単純なコーディングツールとして使うのではなく、
AIが高速に開発しても品質を維持できる開発プロセスそのものを設計する
ことを含めて、AI駆動開発だと考えています。
これからも実際の案件で得られた失敗や改善について、技術的な内容も含めて紹介していきたいと思います。