TECHSTEP

ITインフラ関連の記事を公開してます。

『入門 継続的デリバリー』の感想: CI/CDの概念から実戦まで含めた良書でした

今回はオライリー入門 継続的デリバリー を読んだので、その感想です。本書は2022年に出版されたManning社のGrokking Continuous Delivery を和訳したものですが、とても面白く読ませていただいたので取り上げました。今回も読んでいてよかったと思うポイントを3つ、プラス読書中のメモを載せています。

www.oreilly.co.jp

CI/CDの入門から実戦まで幅広くカバーしている

本書は大きく4つの部と13の章に分かれており、第1部では継続的デリバリー及びその周辺の基本的な用語や考え方、第2部ではContinuous Integrationの実戦的な内容、第3部はContinuous Deliveryの実戦的な内容、第4部はContinuous Deliveryの始め方やデザインといったさらに発展的な内容を扱っています。

例えば第2部のCIはバージョン管理からテストまで、第3部のCDはツールを使ったデプロイやその後の改善など、実際にCI/CDを進めるうえで必要なものが一通り学べるかと思います。本書の序文にあり、書籍の帯にもなった一文(「この本が、すべてのソフトウェアチームでオンボーディングにおける必読書になることを願う」)も、決して誇大広告ではないと読後に感じました。

なお本書ではGitHub Actionsを題材に一部コードを紹介していますが、GitHub Actionsに依存した内容にはなっていないので、幅広いツールに適用できるものだと思います。

各章で架空の企業を例に分かりやすく説明してくれる

本書では架空の企業を題材に、CI/CDの各要素がなぜ重要か、どう改善するのが良いかを説明してくれます。

例えば第3章ではバージョン管理を紹介しますが、あるスタートアップ企業の創業者である2人のエンジニアが開発をする中で、チェックインする前にテストするのを忘れたことが原因でmainブランチ上のコードが壊れる過程を紹介し、「バージョン管理をリリース可能な状態にする」というCIの原則をどうやって保つか、といった内容が記載されています。

また第3部ではCDを紹介する中で、開発からリリースまでの時間がかかりすぎるという課題に対応するため、DORAメトリクスによるプロジェクトの可視化を行い、いかにしてそれを改善するか、という過程が描かれています。

個人的な印象ですが、CI/CDを学んでいると、抽象的な概念が多く登場することもあり、その考えをどう使うのか、なぜそれが重要なのか、なかなかイメージするのが難しいと感じる場面に多く出くわします。CI/CDについては一通り理解していたつもりだったのですが、具体的な過程を読むことで、それぞれの要素を具体的にどう設定するか、どう改善に生かすか、など、自分の理解が足りない部分もたくさん見えてきました。そのため本書ではこういった課題に対して有効な書籍であると感じます。

Linterについて1章使って詳細に説明してくれる

本書では第4章でLintについて扱っています。Lintはソースコードに対する構文チェックなどを実行する静的解析の1つで、導入のしやすさや短時間で処理が済むことなどから、多くのプロジェクトで利用していると思います。私も個人の学習からプロジェクトまで様々な場面で導入・利用していますが、一方でLinterを導入したはいいものの、そこで得られた結果をどう生かすか悩むことも多いです。

例えば、とりあえずLinterをCI/CDに組み込んで見たけど、大量のエラーが検知されてパイプラインが失敗、いったんはLinterによる検知を無視してパイプラインを続行するようにして、そのまま忘れてしまった、ということもありました。

本書ではレガシーなプロジェクトを題材に、Linterを導入するだけでなく、そこで得られた結果をどうプロジェクトに生かすかも紹介されています。例えばLinter導入直後は数万もの指摘事項がある中で、Lintで得られる問題には大きく3種類あること(バグ、エラー、スタイル)、特に影響のあるバグから対応するほうがよいこと、などが紹介されています。

Linterについて、導入から活用まで詳細に扱っている書籍は初めて遭遇したため、本書で初めて得られる情報も多かったです。個人的にはこの章を読むためだけでも購入する価値はあると思います。

読書時のメモ

以下は読書時のメモです。

1部: 継続的デリバリーとは

1章: 「入門 継続的デリバリー」へようこそ

  • continuous deliveryの定義(本書)
    • プロフェッショナルな品質のソフトウェアを書く複数のソフトウェアエンジニアが、思い通りのソフトウェアを作成できるようにするために必要なプロセスの集合体
  • continuous deliveryの定義(CDF)
    • ソフトウェア開発において、チームがソフトウェアの変更を安全、迅速、かつ持続的にユーザーにリリースする手法であり、以下2つを実践している。
      • いつでも変更がリリース可能であることを立証している
      • リリースプロセスを自動化している
  • integration: コードの変更を既存のソフトウェアにインテグレートする
    • ソフトウェアインテグレーションとは、複数人によって変更したコードを組み合わせて、そのコードが意図したとおりに動くかどうかを検証する行為です。
  • continuous integrationの定義(本書)
    • 継続的インテグレーションとは、チェックインの際に各変更を検証したうえで、コードの変更を高い頻度で結合していくプロセスのことです。
  • deliveryする対象
    • ライブラリ / バイナリ / コンフィグ / イメージ / サービス(アプリケーション)
  • delivery: ビルド・リリース・デプロイのうちの一つ、またはすべてを指す
    • デプロイ: ソフトウェアを実行したい場所にコピーし、実行状態にすること
    • リリース: ユーザーがソフトウェアを利用可能になること
  • continuous deployment: コミットごとにユーザーへ自動的にリリースされます

2章: パイプラインの基本

  • タスク: 実行する個々の作業のこと、関数のようなもの
  • パイプライン: コードへのエントリポイントのようなもの、全ての関数(タスク)を適切なタイミングで適切な順序で呼び出す
  • CDパイプラインのタスク
    • lint / test / build / publish / deploy
    • CI: lint / testのみ、検証のためのもの
  • 原則: パイプラインが壊れているときは変更をプッシュしない
    • CDシステム自体で禁止する、もしくは壊れていることを通知する(7章)

2部: 常にデリバリー可能な状態に保つ

3章: バージョン管理は継続的デリバリーの成功に不可欠

  • 資金を得た直後のスタートアップ企業の例
  • バージョン管理: プレーンテキストの変更を追跡するためのソフトウェア
    • 全てを保存する中心となる場所。リポジトリ
    • 全ての変更の履歴。変更ごとに新しい一意なバージョンを作成
  • continuous deliveryを行うにはバージョン管理が必要
    • CDにはCIが必要、CIには「変更を結合する方法」「変更を保存する場所」が必要であり、それがバージョン管理である
  • バージョン管理をリリース可能な状態に保つ
    • 例: mainブランチにバグを含むコードをコミットし、mainブランチが壊れてしまった
    • テストコードも欠いていたが、コミット前にテストするのを忘れていた
    • 対策例: バージョン管理への変更をトリガーにする
      • テストは書いただけでは不十分で、テストを確実に実行する必要がある。バージョン管理への変更をトリガーにタスクを実行する
  • 自動化の裏切り
    • 例: デプロイサービスの設定を「設定ファイル」「UI」の2つで管理しており、設定ファイルのみを修正した結果、サービスが停止した
    • 2つのsource of truthが存在するために起こった問題として、設定ファイルもバージョン管理を唯一のsource of truthとした
      • これを実現するには、CDツールはバージョン管理に設定を保存できるものにする必要がある
  • 原則: ソフトウェアを構成するすべてのプレーンテキストデータをコードのように扱い、バージョン管理に保存する

4章: リントを効果的に使う

  • Pythonゲームのライブラリを提供するゲーム機の開発会社の例
    • ゲームには多くのバグがあり、コンパイルすらできないものもある。そうでないものも未使用の変数を含むなどの問題がある
    • ゲームごとのコードが異なり、一貫性のなさがデバッグを難しくする
    • 対策: リントを導入しこれらのバグに対応する
  • レガシーシステムにlintを導入して時間を有効に使うアプローチ★
    • 既存コードベースにリントを適用すると、膨大な数の問題点を指摘される。「全てを解決する必要はない」「新たな問題の侵入を防ぐだけで状況は改善されている」という2つをポイントに、以下のようなステップでアプローチする
      • Lintの(導入でなく)設定をする。Lintツールの初期設定はプロジェクトにあってないかもしれないので、プロジェクトに適した設定にする (例: コーディング規約)。
      • ベースラインを計測して計測を続ける。全ての問題を解決する必要はなく、時間とともに問題の数が減っていることを確認できれば有意義なことである
        • 報告される問題の数をカウントするスクリプト、カウント結果の可視化ツールなどが必要
      • 計測できるようになれば、新しい変更を加えるたびに計測し、新しい問題を追加する場合はコミットを中止することで、数値の増加を防ぐ
      • Lintの導入と設定・計測を始めれば、これ以上事態は悪化しなくなる。こうなってから、既存の問題に取り組む
  • Lintによる見返りとリスク
見返り リスク
バグを見つけられる 変更による新しいバグの混入の可能性
邪悪なエラーを取り除くのに役立つ Lintの問題の修正は時間がかかる
一貫性のあるコードは保守が容易になる Lintの問題の修正は時間がかかる
  • 隔離:全てを修正すべきではない。
    • 例:
      • 見返り①: 誰もバグを報告しないのであれば投資対効果は小さいかも。
      • 見返り②③: 二度と触れないコードに対し、なぜ時間をかけて新しいバグを引き起こすリスクを冒すのか?修正する対象は、変更が加えられるコードにすべき
    • 具体的な対策例:
      • 隔離: 長期間変更の入っていないコードは frozen というディレクトリに移動し、Lintの対象からも外す
      • 隔離の強制: 隔離したコードに変更が入らないよう、パイプラインなどシステムで強制する
  • どの問題から着手するか?
    • lintの問題の種類: bug / error / style
      • bug: 未初期化の変数や変数の書式の不一致など、望ましくない動作につながるもの
      • error: 未使用の変数など、動作には影響しないがパフォーマンスや保守性に影響するもの
      • style: コードスタイルに一貫性がないこと
    • bug > error > styleの順に優先度が変わるので、bugから着手する
  • linterを開発プロセスに組み込む
    • 開発者が簡単にLinterを使えるようにする
      • Lintの設定ファイルをコードと一緒にコミットし、CDパイプラインと全く同じ設定を使えるようにする
      • 作業中にLinterを実行する。IDEはLinterを統合しているものが多いのでそれを使う。

5章: ノイズの多いテストに対処する

  • アイスクリーム配達会社の例
    • ユーザーが各地域のアイスクリーム業者に注文・配達するため、各アイスクリーム業者の独自のAPIに接続するサービスを提供
    • テストのノイズが多い(頻繁にテストが失敗しエンジニアが失敗を無視する状態)という問題があり、大規模障害を引き起こした
      • ノイズ: 情報を妨げるもの。成功すべきでないが成功したもの、失敗が新しい情報を提供しないものはノイズである
  • どのようにノイズになるか
    • 最初にテストが失敗した時は新しい情報を提供するが、テストの失敗を無視することで(失敗することは既に知っているので)その失敗がノイズに変わる。特に失敗の原因が分からない場合はよく見られる
  • どうやって改善するか: できるだけ早くテストが常に成功する状態を実現し、テストの失敗を新しい情報をもたらすもの(シグナル)とする
    • テストの失敗は全てバグとして扱い、十分に調査する
  • テストが失敗したとき、どうやって修正するか
    • 実際に修正する: テストの失敗する原因を調査し、バグを修正するか、間違ったテストを更新する
    • テストを削除する: 何の価値ももたらさないテストを削除する
    • テストを無効にする: シグナルを隠す一時的な手段、できるだけ早く調査して対応する
    • テストを再試行する: シグナルを隠す一時的な手段、フレーキーテストに対してやりがちな対応。
  • 原則: テストを成功させることがゴールではない
    • コードの実際の動作との不整合を理解し、適切な場所に修正を加えることがゴール

6章: 遅いテストスイートを早くする

  • シンプルなアーキテクチャWebサービスにもかかわらず、新機能を追加するのに数か月かかっている企業の例
    • シンプルなパイプラインの中で、すべてのテストを一度に実行している
    • テストは1日1回、夜間に行われ、問題があるのは翌日にならないとわからない
    • 対策:テストピラミッドに従い、早いテストから先に実行する
      • 一番早いテストを単独で実行できるようにし、そのテストを他のテストよりも先に実行するようにする。例えテストスイート全体がこれまでと同じように遅いとしても、シグナルをある程度早く得られるようになる
  • テストの比率を調整する:単体テストの割合を多く、E2Eテストの割合を少なくするために何ができるか
  • テスト比率を向上する手順
    • 単体テストでカバーされていない行を探す
    • 発見したコードに対し、その行をカバーする単体テストを追加する
    • 遅いテストの中から、追加した単体テストと同じロジックをカバーするテストを見つけ、それらを更新・削除する
  • テストを並列実行することで結合テストの速度を向上する
    • 並列実行できるテストの条件
      • テストはたがいに依存していない
      • テストはどんな順序でも実行可能
      • テストは互いに干渉しない
  • シャーディング:複数のマシンにまたがってテストを並列化し、E2Eテストの速度を向上する

7章: 適切なタイミングで適切なシグナルを送る

  • CIの基礎を全て押さえているにもかかわらずバグや障害に直面している企業
    • PRを作成したタイミングでCIを実行している
  • 変更をプッシュした直後にCIを始める事の欠点
    • 問題がコードベースに追加された後で初めて気づくことになる。なのでコードベースはリリースするのに安全でない状態になる可能性がある
    • CIが壊れたときに変更のプッシュを止めると、全員の作業進捗に影響する
  • マージする前(本章の企業のやり方)にCIを実行する事のメリット
    • 問題が既に追加された後に発見するのでなく、問題がmainブランチに追加されるのを阻止する
    • 悪い変更があったときに全員をブロックすることを避けられる
  • この企業において変更のバグが発生する可能性のある場所
開発作業の時系列 起こりうるバグ
ローカルで変更に取り組み、何度も更新する エラー
フレーキーテスト
mainブランチとの分岐
変更のコミット mainブランチとの分岐
PRの作成 エラー
フレーキーテスト
mainブランチとの分岐
mainブランチにマージ 分岐の統合
本番用のアーティファクトをビルド 依存関係
非決定的なビルド
  • マージ前のCIだけではバグを見逃す
    • mainブランチとの統合: mainブランチへの新しい変更が考慮されない
    • 依存関係の変更: CI実行時と異なるバージョンを使っているかも
    • 非決定性
  • バージョン管理システムによる競合の検出は機能しない場合もある
    • 殆どのバージョン管理システムは、マージするとき、まったく同じ行が変更されていれば間違いに気づくが、それ以上のことはできない
  • PRによるトリガーではまだバグが紛れ込む
    • mainブランチに変更が統合されない時間が延びるほど、競合する変更が混入する
  • mainブランチとの統合に対する解決案: マージ後のCIを導入する
    • マージ後のCIの選択肢
      • mainブランチ上でCIを定期的に実行する: mainブランチに取り込まれて初めてエラーを発見できるので、mainブランチが壊れた状態になる可能性がある
      • mainブランチにマージする前にブランチが最新であることを要求する: mainブランチの更新のたびに他のすべてのPRを更新する必要があり、実際の運用で大きな負担となる場合がある
      • 自動化を使って、マージする前に最新のmainブランチで変更のCIを再実行してからマージする(マージキュー): バージョン管理システムが備えていれば有効だが、自分で実装する場合は複雑になる
  • その他バグの要因①: フレーキーテスト
    • 対策案: 定期的なテストの実行
      • 無関係な作業を妨げることなく、コードやテストにおける非決定的な動作を特定して修正することに役立つ
  • その他バグの要因②: 依存関係の変更
    • 対策案: 全ての環境で同じロジックでビルドとデプロイを行う

3部: デリバリーの簡略化

8章: 簡単なデリバリーはバージョン管理から始まる

  • リリース速度に悩みを抱える企業の例
    • 会社が大きくなるにつれデプロイがリスキーな作業となった
    • 現在は2か月に1度のリリース、リリースの1週間前はコードベースを凍結
  • 解決案①: DORAメトリクスの利用
    • DORAメトリクス: ソフトウェア開発チームのパフォーマンスを評価する4つのキーメトリクスから成り立つ
    • ベロシティに関するメトリクス:
      • デプロイの頻度: 組織が本番環境へのリリースを成功させる頻度
      • 変更のリードタイム: コミットした内容が本番環境へリリースされるまでにかかる時間
    • 安定性に関するメトリクス: サービス復旧時間、変更に伴う障害発生率
  • 解決案②: Trunkベースの開発
    • 変更を早期に取り込み、早期かつ継続的に統合を進められる
    • デプロイメントを改善するには、多くの場合、最初にCIを改善する必要がある
  • より頻繁にコミットするコツ★
    • 作業をどのように分割するとすぐにコミットできるか事前に時間をかけて考え、これをサポートするために必要となる、小規模で自己完結型のPRを作成することに時間をかける
      • 簡単でリスクの低い作業に関するPOCから始める
      • 作業を個別のタスクに分割し、それぞれ数時間~1日以内に完結できるものにする
      • リファクタリングは別のPRで実行しすぐマージする
      • 1つの大きなfeatureブランチでの作業を避けられない場合、コミットバック(ロールバック?)できる部分に注目し、それらの個別のPRを作成・マージする
      • featureフラグ、ビルドフラグの利用

9章: 安全かつ信頼性のあるビルド

  • ビルド担当者が転職してしまった企業の例
    • ビルドプロセスはドキュメントとして定義されている
    • この機会にビルドプロセスを改善したい
  • 安全で信頼性の高いビルドの特徴 (SLSAに基づく)
    • 常にリリース可能: ソースコードは常にリリース可能な状態
    • 自動ビルド: ビルドの実行は自動化されている
    • コードとしてビルドする: ビルド構成をコードのように扱い、バージョン管理システムに保存
    • CDサービスを利用する: 開発者のワークステーションなどだけでなくCDサービスを介して実行される
    • 一時的なビルド環境: ビルドごとに作成・破棄される一時的な環境で実行される
  • 常にリリース可能: CIを駆使してリリース可能な状態に保つ
  • 自動ビルドの2つの要件
  • CDサービスの利用: どんなCDシステムを使うべきか
    • 可能であれば、タスクを分離して実行する手段のデファクトスタンダードになりつつある、コンテナベースの実行をサポートするCDシステムを選択する
  • リリースのバージョン管理を行わないと問題が発生する
    • サービスリリースにおける影響度の違いを区別できない
    • あるチームがどのバージョンのリリースを使うか制御する方法がない
    • リリース間でどんな変更があったか自動的に伝達する方法がない
  • セマンティックバージョニング
    • Major/Minor/Patchを使い分けることで、サービスのリリースにおける影響度の違いを区別できる
    • スクリプトでバージョン情報を使うため、現在のバージョンをリポジトリ内のファイルに保存し、ユーザー向けの変更が発生した場合は、バージョンの値を上げる。更新がないときはパイプラインを失敗させる

10章: 自信を持ってデプロイを行う

  • 定期的な障害に悩まされる企業の例
    • DBとモノリシックサービスというシンプルなアーキテクチャ
    • デプロイ直後に障害が発生する
  • 安定性に関するDORAメトリクス
    • サービス復旧時間: 組織が本番環境で発生した障害から回復するまでにかかる時間
    • 変更に伴う障害発生率: 本番環境で失敗を引き起こすデプロイの割合
  • デプロイ頻度を増やすと各デプロイのリスク量は減少する。各デプロイに含まれる変更量が少なくなるため、本番環境で障害を引き起こす変更がデプロイに含まれる可能性は低くなる
  • デプロイ頻度を上げるためのステップ
    • 変更前:毎週木曜日の午後にデプロイを開始、ローリングアップデート
    • 変更前の課題:
      • 問題を修正するのに数日程度の長い時間が必要である
    • 解決案①: 問題が修正されるまで時間をかけず、問題を軽減する方法を見つける
    • 解決案②: continuous deploymentの採用
      • プロジェクトが満たすべき条件
        • サービスへのリクエストの一定の割合が失敗することを許容する
        • 規制要件を妨げていない
        • リリース前に探索的テストが要求されない
        • リリース前に明示的な承認が要求されない
        • ソフトウェアのリリースに伴ってハードウェアの変更が要求されない

4部: 継続的デリバリーのデザイン

11章: 継続的デリバリーを始める

  • まっさらな状態のプロジェクトに継続的デリバリーを導入するときの順序例
  • レガシープロジェクトの場合
    • 段階的な目標の設定:
      • 何かが壊れたときにそれを検知できるようにする
        • ソースコードがビルドできているかを知るために十分な自動化を追加する
        • 改善したい部分とそうでない部分を分離する
        • テストを追加してカバレッジを計測する
      • より頻繁に、より早くリリースできるようにする
        • 既存プロセスの自動化に集中するか、ゼロから構築するか決める
        • 既存プロセスの場合は1か所ずつ段階的に取り組む
        • ゼロから構築する場合は既存プロセスからの移行の影響を押さえて安全に移行する手法を設計する
    • 課題に焦点を当てることで、取り組むべき順番を整理できる場合がある
    • レガシープロジェクトに継続的デリバリーを導入していくときは、全てを完璧に仕上げようとするのではなく、必要な形を模索して受け入れることが重要

12章: スクリプトはコードでもある

  • CDパイプラインで最近トラブルの発生した企業の例
    • パイプラインが失敗したときに原因を調べるのに時間がかかる
    • スクリプトの中身が理解が難しく、変更を加えるとどうなるかわからない
  • 巨大なスクリプトのリスク:
    • スクリプトが複数のタスクを含む巨大なものだと、「パイプライン全体が失敗したか、成功したか」という1つのシグナルしか得られない。
    • 複数の個別のシグナルが得られる状態にするため、「パイプラインから得たい個々のシグナルごとに、それぞれタスクを切り分けていく」
  • 上手く設計されたタスクの特徴
    • まとまりがある: 1つのことをしっかりこなす
    • 疎結合である: 再利用可能で、他のタスクと組み合わせることができる
    • 意図と定義が明確なインターフェイスを持っている: インプットとアウトプット
    • 必要十分である: 少なすぎず多すぎない
  • 1つのタスクの処理が多すぎるときにあらわれる兆候: これらの処理はパイプラインで処理するほうが良い
    • 他のタスクと重複している部分がある
    • 複数の処理を管理して調整するロジックがタスクに組み込まれている
  • タスクはまとまりがあって疎結合であり、パイプラインはそのロジックを組み合わせて機能させるのがあるべき姿
  • bashが継続的デリバリーに使われるのが最適でない兆候

13章: パイプラインのデザイン

  • パイプラインの実行に時間がかかり、CD自動化への印象が悪くなりつつある企業の例
    • 3つのパイプライン(CI / E2Eテスト / リリース)
    • CIには満足しているが残り2つはそうではない
      • E2E: 夜間に1回実行されるので、結果がすぐにわからない、リリース可能な状態か確信を持てない。完了まで1時間以上かかる。
      • リリース: リリースの準備ができたときだけ実行されるので、何か問題があるとそちらに奔走してリリースが頻繁に中断される
  • CDパイプラインの問題カテゴリ
    • エラー: パイプラインが本来の役割を果たせない
    • スピード: パイプラインが遅いことは、チームが必要な時にパイプラインを実行することを妨げる
    • シグナル: 遅すぎるシグナルなど
  • CDシステムが備えていた方が良い機能
    • タスクとパイプラインのサポート
    • アウトプット: 他のタスクが利用可能な結果を出力する
    • インプット: タスク・パイプラインが入力を利用できる
    • 条件付きの実行
    • Finallyの挙動: 常時実行させるタスク
    • 並列実行
    • マトリックスベースの実行
    • パイプラインから他のパイプラインへの呼び出し: パイプライン自体が再利用可能である