Kesinの知見置き場

知見を共有していきたいじゃないですか

継続的デリバリーを2020年に読了した感想

業務で大規模なCI/CDパイプラインを構築することになったので、良書という話をよく耳にしていた継続的デリバリーを購入して読了しました。

普段は書籍を読んでもメモを残すことまではほとんどしないのですが、この本はかつて無いほど得るものが多かったので自分にしては珍しくメモを取りました。
自分と同じように大規模なパイプラインを設計することに迫られる人は世にそれほど多くないでしょうから、せっかくなので公開したいと思います。

全体まとめ

この本の構成は1章で重要なことをほぼ全て述べて、あとの章でそれらをさらに詳しく解説している。同じことを後の章で何度も繰り返すので、まさに「大事なことなのでN回言いました」を体現している。 そういうわけでこの本は1章に価値が詰まっている。言い換えると、2章以降は読まなくても何とかなる。ページ数が非常に多いので、自分が特に気になった事柄の章から読んでいけば良い。

この本の原著が書かれたのは2010年頃であり、紹介されているツールは2020年現在の今では名前も聞かなくなってしまったものが多い。時代としてはクラウドAWSが流行り始めたばかり、バージョン管理はGitよりまだSubversionの方が主流、VMは使われているがVagrantもまだ登場しておらず当然dockerも存在しない、構成管理のOSSはPuppetが主流の時代で、CI/CDもマネージドサービスはまだほとんど登場しておらずCluserControlやJenkinsが主流の時代(細かいところは微妙に違うかもしれない)。

それにも関わらず、この本で紹介されていることの多くは10年経った2020年においても非常に有用だと感じるものが多かった。当時は難しかった、あるいは高価だった方法も2020年現在ではクラウドOSSの発展により誰でも簡単かつ安価に実現できるようになったものも多い。10年も前にそれらを既に実用化させていた人たちがいたことには非常に驚いた。

自分が読み進めていて途中から感じたのは、この本で紹介している大規模なパイプラインにおいて重要ないくつかのことは2020年現在でも残念ながら多くのCI/CDサービスではサポートされていないこと。

ジョブ情報の可視化、デプロイはビルド済みのバイナリを使う、gated checkin、パイプラインの承認フローなど。自分の知る限り、これらの機能を全てフルセットで提供しているのはAzure Pipelineを除いて他にはない。Azure Pipelinesはあまり流行っている印象がないが、実は大規模なパイプラインにおいて必要なものを全方位でカバーしているすごいサービスだということを改めて発見した。

印象的だった項目

自分が読んでいて印象的だった項目のメモを残す。それぞれについて細かく解説はしないので、基本的にこの本を読んだことがある人 or 手元にある人を前提としています。

もしも自分のコメントで内容が気になった人がいたら、ぜひ購入してほしい。相当のページ数で分厚い本なので、個人的には電子書籍版を購入することをオススメする。

5.3.1 バイナリをビルドするのは1回限りとせよ

同じソースコードを再度ビルドして作られるバイナリは基本的に同一になるが(そうでなければならない)、デプロイのために再度コンパイルするのは時間がかかるし、それは厳密には受け入れテストを通ったバイナリとは異なるので異物が紛れ込むリスクを冒している。

バイナリはコミットステージでビルドされたものを保存しておき、再利用すればよい。


今日のCI/CDでこれを達成できている現場はどれぐらいあるだろうか?そもそもビルド→自動テストまでがせいぜい10分程度の小規模なパイプラインであれば、pull-reqとmasterにpushされたときの自動リリースもパイプラインはほとんどが共通で、最後にリリースをするかしないかの違いしかないことが多いと思う。

そもそも、現代のCIサービスで前にビルドした成果物を別のパイプラインで取り出して使うという工程をサポートしているものはほとんど無い気がする。これを公式にサポートしているのはAzure Pipelineのartifactsぐらいではないだろうか。

一方で、dockerやk8s界隈ではこの規則を守ったリリースフロー行っている話を聞くことがある。ビルドしたコンテナにコミットハッシュなど一意に特定できるtagを付けておき、そのコンテナをデプロイした検証環境で動作確認、確実に同じtagを指定して本番用のk8sにデプロイという流れ。

5.3.5 各変更は直ちにパイプライン全体を通り抜けなければならない

ビルドは1hごと、受け入れテストは夜中、キャパシティテストは週末のように異なるスケジュールを行ってはならないということ。

ビルドやユニットテストは短時間で終わる一方、受け入れテストは時間がかかるもの。そこで、ビルド1に対しての受け入れテストが終わるまでの間にビルド2,3,4まで終わっていた場合、次の受け入れテストはビルド4のものを使う。


これは図がないと説明が難しいが、今日ではCircleCIやBitriseなどで同ブランチへのpushがキューに積まれた場合、次回動くときに最新のコミット以外はキューに積まれたビルド予定が破棄されるというオプションが最も近い挙動。

これが目指すところは、イテレーションサイクルをなるべく短く保つことで、受け入れテストが失敗したときにどのビルドが原因だったのかを突き止めやすくすることが目的である。

Jenkinsでもあると便利そうなのだけど、これを実現するようなJenkinsプラグインの話を聞いたことがないので、簡単に実現することはできなさそう。

6.5.2 環境設定をテストする

ビルド環境やデプロイ先の環境が正しく設定されているかもテストする必要があるので、簡単なスモークテストなどを用意しましょうということ。

6.6.1 常に相対パスを使え

特に語る必要がない。絶対パスにするとビルドマシンのユーザー名などにも影響されてしまうし、Jenkinsにおいてはジョブはworkspaceのディレクトリ毎に隔離されているだけなので、相対パスを使わないと簡単に破綻してしまう。

6.6.5 テストが失敗してもビルドは続けよ

ビルドシステムはタスクが失敗した時点で全てを終了させるのがデフォルト挙動だが、これはもったいないのでそのタスクが失敗したという事実を記録して後段のタスクも実行しましょうということ。

例えば、ユニットテスト、インテグレーションテスト、スモークテストが存在した場合、ユニットテストが失敗した場合に、次にコミットされるまでインテグレーションテストが実行されないのは時間の無駄である。


というのが本の主張なのだけど、Lintはともかくユニットテストが落ちていたら後段のインテグレーションテストも落ちるはずなので実行するのはかえってビルドマシンの無駄使いな気もするが?

7.2.2 どういうときにコミットステージを止めるべきか?

コンパイルエラーやテストが失敗したときにビルドがfailになるのは当然だが、コード品質やテストのカバレッジが低い場合でもそれを通すのか?という問題。

例えばテストカバレッジが60%未満ならコミットステージとしてはfailさせ、80%未満なら通過はさせるがステータスは黄色にしておくという戦略がある。ただし、そのような理由でfailさせる場合には、チームの合意がちゃんと得られていないとかえって継続的インテグレーションが破綻してしまうことに注意。


今日ではGitHubと連携できるDangerや、Codecovのようなカバレッジサービスを使うことで簡単に実現できるようになっている。

7.3.1 成果物リポジトリ

テストレポートやバイナリといったコミットステージのアウトプットは後で再利用できるようにどこかに格納しておく。gitなどにコミットする方法もあるが、基本的に同じソースコードでビルドした場合には同じ成果物が得られるはずなのでわざわざコミットするのは無駄。


Jenkinsなどのインテグレーションサーバーは自前で成果物リポジトリの機能を提供しているので、基本的にはそれを使えばよい。Jenkinsにはfingerprintという機能があるので、1つのパイプライン上で前段の成果物を後段で間違いなく取り出すのは難しくなかったはず。

もう少し発展させると、Jenkinsを使う使わないに限らずS3やGCSなどのクラウドオブジェクトサービスを成果物リポジトリとして使うことも可能。Jenkinsに成果物を置くとディスクスペースを圧迫するので、容量に対して従量課金制のサービスを使うほうが現代では便利である。

もしくは、2018年頃からGitHub, Azure, GCP, AWSなどがこぞって各言語のプライベートパッケージリポジトリサービスを提供するようになったので、これらを活用するとさらに簡単に実現できるかもしれない。

8.5.2 プロセス境界・カプセル化・テスト

テストのためだけのバックドアはなるべく作らないべきであり、代わりにテストではスタブなどのテストダブルに置き換えるべき。

バックドアやテスト用コンポーネントはこまめなメンテが必要になってしまうし、本番にデプロイされないように注意する必要があるというデメリットがあるため。

8.5.4 テストダブルを利用する。

受け入れテストでも外部システムを使う部分などはなるべくテストダブルに置き換えるべき。自動受け入れテストはユーザー受け入れテストとは違う。

外部システムは「制御可能」なものでないので、テストは不安定になりがちでで外部システムに負荷をかけすぎてしまうこともある。あらかじめ、外部システムとの接合点をテストダブルに置き換えられるような設計とするべきである。

8.6.0 受け入れテストステージ

受け入れテストが失敗したらそれはデプロイしてはならない。受け入れテストが失敗した場合はすぐに原因を突き止めて修復する必要がある。

これを少し楽にする方法として、テスト実行中の画面を録画しておき、テストレポートと一緒に成果物として保存しておくと役に立つ。


今日ではSeleniumやモバイルのE2Eテスト基盤を提供するサービスの多くがテスト中の録画を提供してくれているので、そういったサービスを選択すればお手軽に達成できる。

8.6.2 デプロイメントテスト

受け入れテストを行う環境は疑似本番環境になるので、デプロイ戦略が正しく動作するかも確認することが可能になる。受け入れテストは一般的に実行時間が相当かかるので、このデプロイ確認もデプロイテストとしておき、デプロイテストが失敗した場合は受け入れテストの完了を待たずに終了させることで時間を節約できる。


これは一般的なwebサーバーなら可能な話。今日ではVMやDockerなど仮想化技術がかなり進んだので、デプロイテストを行うのも格段に楽になっているはず。

11.3.1 基盤へのアクセスを制御する

テスト環境も本番環境も、何か起きたときに誰がどういう変更を行ったのか追跡できないと原因の特定が難しいという話。もちろんテスト環境の変更については本番よりも簡単に申請が通るようになっているべきである。


この話題はJenkinsのビルドマシンあるある話。申請が必要なほどガチガチに固める必要はないと思うが、せめてどういう作業をしたのか記録は残してほしい。

11.3.2 基盤へ変更を加える

変更はチケットで管理して、変更内容もあとからログで監査できるようにしておく。デプロイの記録は残し、まずはテスト用の疑似本番環境でテストしてから壊れないことを確認する。

変更はPuppetなどのツールで行い、変更のためのスクリプトはバージョン管理をするべき。


ビルドマシンへのAnsibleの適用でどのマシンにどこまでの変更を適用したかは問題になることがたまにあるが、頭で覚えていてなんとか運用している状態に近いので耳が痛い。そもそもAnsibleはTerraformなどのように状態に収束させるものではなくてあくまで単なるスクリプトなので、「設定を入れる」場合は普通に書けばいいが、「設定を外す」場合も外すための処理をわざわざ記述してから実行する必要があるので本当にめんどくさい。

やはりデプロイ先の環境は毎回まっさらな状態からプロビジョニングを行う、あるいはVMやコンテナなどの仮想環境である方が圧倒的に楽。

11.4.2 進行中のサーバー管理

新しいマシンが来たら構成を勝手に変えられることがないように、まず管理者以外のログインを制限してから自動化スクリプトでセットアップを行う。あとはPuppetなどのツールが各マシンを管理下において変更が適用済みの状態かどうかを監視してくれる。


自分は使ったことがないが、現代だとAnsible Towerなど中央集権的にスクリプトを実行したり監視の役割もしてくれるのかも?自前でやるのであれば、Serverspecみたいなマシンの状態をテストするためのスクリプトを用意してデプロイ後やcronなどで定期的に各マシンがちゃんとスクリプトで定義された意図した状態になっているかどうかの確認を行う方法ができそう。

11.5.4 設定APIを探せ

DBなどのミドルウェアをテスト用に自動で設定する方法の選択肢の文脈。理想のミドルウェアはバイナリではないテキストフォーマットの設定ファイルで全てが設定できるタイプだが、それができない場合の妥協案として設定APIさえ存在するのであれば設定DSLを自前で作ることにより、設定ファイルによる構成管理を自分の手元に取り戻すことができる。


今日ではAnsibleやTerraformなどがこの手のアプローチによってAWSGCPの構成管理をそれぞれのDSLで書くことができるようになっている。

11.7.1 仮想環境を管理する

仮想マシンのイメージが単一ファイルで扱えることの利点の話。稼働中のVMを簡単にコピーできることだけが利点ではなく、ツールによっては物理マシンのスナップショットをディスクイメージに変換することも可能なので実際の本番環境のスナップショットをVM化することで継続的インテグレーションやテストに使うことができる。

他にもいくつか利点がある。

  • 新しい環境のプロビジョニングとしてVMをそのまま使える
  • VMに対して自動でソフトウェアのインストールや設定をした後の状態をさらにテンプレートVMとして保存して使い回せる
  • 手動で設定されてしまっている環境をまずはVM化することで、少しずつ自動構築にシフトする過程の中でまずは動くことが保証されている状態からスタートできる
  • インストールや設定が自動化できないソフトウェアもVM化することで、設定済みの状態を複製することが可能になる

今日では、GitHub ActionsBitriseなどのCIサービスが実行環境として提供しているVMがこの類の話を上手く活用している。様々な言語が使用するツールやSDKが全部入りの状態で保存されたVMを提供してくれているので、ユーザー側はたいていの場合において各ツールのインストールから行う必要はない。

特にBitriseにおけるmacOSのVM作成アプローチはとても効率的で、macOSVMにおそらくは手動でXcodeをインストールした状態をベースイメージとして保存しておき、そのVMに対してAnsibleでその他のツールをインストールした完成形イメージをユーザーに提供している。XcodeAppleが公式のインストールツールを提供していないせいで自動インストールを行うのは大変な労力を伴うので、そこだけ手動でインストールを行うのは完全に理にかなっている。

12.4.2 アプリケーションのデプロイをデータベースのマイグレーションから分離する

アプリケーションのバージョンを上げる際に、DBスキーマと深く関係していると両方を同時に更新する必要があるがそうすると両方を共にロールバックさせるのが難しくなるのでその対策の話。

方法としては、アプリケーションのコード側で新旧のDBスキーマ両方で動作するように対応をしておき、アプリケーション→DBスキーマの順番でデプロイをするというもの。アプリケーションをデプロイした時点でなにか問題があればアプリケーションだけをロールバックさせればよい。アプリケーションが安定したと判断できればDBをデプロイする。

アプリケーションとDBのデプロイを分割する方法は、ノーメンテでアプリケーションのバージョンを上げようとするときにも使われる方法かもしれない。自分が過去に経験したプロジェクトでは逆順のDB→アプリケーションでだったような記憶があるが、やりたいことや思想は同じはず。

12.5.3 テストの分離

個々のテストがお互いに影響を与えないように保証する、これをDBを使う場合でも実現する方法。RDBMSを使っているのであれば、トランザクション機能を使うのがもっとも簡単。

テスト開始時点でトランザクションを作成し、テスト中のDB操作は全てトランザクション内で行い、テストが終わったときにトランザクションロールバックする。これだけで他のテストがお互いのDBに影響を与えないようにできる。

別の方法としては、何からかの機能や規則を使ってテスト毎のデータを分離すること。


これは、この手のテストDBの分離に関する発想があるかどうかとそれを見越したアプリケーション設計になっているか次第。

RDBMSを使っていても1リクエスト中で複数回トランザクションをしている場合は何らかの工夫が必要だろう。そもそもトランザクションをサポートしていないNoSQLなどを使っている場合はデータベースそのもののインスタンスを分離するとか、テーブル名で分離できるようなコード設計になっているかなどアプリの初期設計から考慮できていないと実現は難しい。

13.2.3 抽象化によるブランチ

ブランチを切るということは本質的に継続的インテグレーションを難しくするという文脈において、ブランチを使わないアプローチの話。

ブランチを切って大規模にコードを改変させる例としてリファクタリングがある。ブランチを切らずに行う場合は、まず最初に抽象レイヤを作成し、その機能を呼び出す別のコードを抽象に依存させる。そしてリファクタリングを施す新しい実装を裏で作成しておき、完成したときに抽象レイヤを書き換えて新しい実装を使うように変更するという方法。


今日ではこの話はいわゆる「feature flag」や「feature toggle」の考え方に使われているように思う。この手の方法を実現するために最も簡単なものはIF分岐だが、よりスマートな方法を考えると自然にこの抽象レイヤの手法にたどり着くと思う。

「feature flag」が必要になってきた理由も、ブランチを切って大規模開発を行うとマージ作業のコストや検証コストが上がるためいっそ1ブランチで運用するというものだったと記憶しているので、まさにこの本に書かれているブランチを切るコストの話と一致している。

13.5.4 慎重な楽天主義

あるビルドの依存物は、それもまたビルドが必要でなにかに依存している。つまりA → B → Cのような依存グラフを作ることができる。それぞれの依存物のバージョンが固定可能だとして、どうやって安定したビルドとバージョンアップを両立させるかという戦略の話。

依存関係にstaticとfluidというラベルを分ける。staticは常にバージョンが固定されている。一方でfluidはバージョンが流動的なので常に変わる。A(static v1) & B(fluid v2) → Cという関係でビルドされるとき、仮にBがv3に上がったトリガーでビルドされた場合にビルドが失敗するとBは"guarded"というラベルに変化し、v2で固定される。guardedは前のバージョンであればビルドに成功するが、新しいバージョンだと何かが原因でビルドに失敗するということなので早急な調査が必要という意味になる。


概念としてはややこしいが、今日では割と一般的な概念になっている気がする。各言語のパッケージ管理ツールでは一般的に依存のバージョンを"=", "≥"などの等号・不等号で固定化したり流動的に変えたりしている。あるいはgitのsubmoduleで依存リポジトリのコミットハッシュを固定するか、submoduleを使わないなどの方法で常に最新のコミットを取ってくるかなど。

ただ、CIと連動して"guarded"の概念まで実現できているツールやサービスは見たことがない。強いて言えば、DependabotRenovateのように依存パッケージを自動でメンテしてくれるツールと、package-lock.jsonのように言語側で依存バージョンを固定するツールが提供されていると"gurarded"の概念に近いことは達成できているように思う。