この記事は クラスター Advent Calendar 2020 の23日目です。 昨日は @AyukaTakayanagi のアバター面接で表現されるクラスターらしさとダイバーシティの可能性について でした。
クラスター株式会社 サーバー班の t-hara です。
名古屋ではなく愛知県瀬戸市からリモートワークで働いています。
クラスター Advent Calendarは(意外なことに)社の話が多いですが、あまり気にせずに書きたいことを書きます。
Release Engineeringとは
自分がRelease Engineeringという用語を初めて知ったのはSRE本の8章 だった。非常に興味深かかったのだが、SRE本で唯一Site Reliability Engineerとは異なるRelease Engineerという他のロールに関する記載であったのと、Release EngineeringとGoogleにおけるRelease Engineerの責務が混在しており、さらに事例がGAFAレベルの問題への解決であったため、雲を掴むようであったことを覚えている。
この記事では、Release Engineeringをより身近に感じてもらうために、現在のソフトウェア開発組織が直面するであろう問題を挙げそれに自分なりの解決案を提示したい。
なお、この記事ではRelease Engineeringを以下のように定義する。
ビジネス上の価値を持続的に提供するために、多様なビルド成果物と複雑なリリースプロセスを管理するための手法・方法論
また、この記事で扱う問題を2020年のスタートアップや中小規模のソフトウェア開発組織に限定する。解決策は自社プロダクト開発を想定して書かれていることに注意してほしい。
気分が乗ったらクラスターでの事例を書くかもしれないし、書かないかもしれない。が、あくまでも過去に自分が直面した課題であって、必ずしもクラスターで直面している課題について述べているわけではないことを念頭においていただきたい。
コントロールしづらいプラットフォームへの配信
現在では、数多くのソフトウェア配信プラットフォームが存在する。身近なもので言うと、Google PlayStore、Apple AppStoreだろうか。他にもMicrosoft Storeや事業領域によってはSteamなどのゲーム配信プラットフォームも含まれるかもしれない。
本質的なクロスプラットフォーム対応の難しさもさることながら、各プラットフォームへの配信にはそれぞれ独特の制約が存在する。
多くの開発者を悩ませているのは、Android/iOSアプリの審査待ちだろう。ひと昔はAndroidアプリの審査は短時間で済んでいたが、今はその前提を置けない。
この審査待ちのステータスはユーザーがアプリをいつ更新可能になるかのスケジュールを審査完了前に確定できないなどの事業的な問題点もあるが、Release Engineeringの観点では以下のような点で非常に扱いづらい。
- バックエンドAPIはクライアントの新旧バージョンの互換性を保たなければならない
- hotfix時に特別なプロトコル(Expedited Review)を必要とする
- Canary Releaseの戦略がプラットフォームに依存する
それぞれについて、解決策を見ていく。
バックエンドAPIはクライアントの新旧バージョンの互換性を保たなければならない
前提として、APIである以上ある程度の互換性を保つことが望ましい。 その一方で、データ整合上の問題などでどうしてもバックエンドのAPIサーバのバージョンとクライアントのバージョンをfixしなければならないケースがある。
その場合には、以下の方法がある。
- アプリの強制アップデート機能
- モバイルアプリでは 必ず と言っていいほど必要な機能になる
- これを新規リリース時に忘れると、古いアプリバージョンの互換性を保ち続けなければならない
- = Release Engineeringの重要性
- 強制アップデート前後の対応OSバージョンの違いに注意
- 古い端末で使い続けていたユーザーが強制アップデートによって新しいOSバージョンを強制されるが、その古い端末では新しいOSにアップグレードできないケース
- Requestベースのfeature flag
- HTTPリクエストのヘッダーにfeature flagを指定して、バックエンドAPIの振る舞いを変える
- ユーザー自身にアプリバージョン更新の選択権を委ねたい場合や、データマイグレーションなどの都合でgracefulに対応していきたい場合
- 乱用するとカオスになるので、ここぞというときに使いたい
hotfix時に特別なプロトコル(Expedited Review)を必要とする
諦めよう。
そもそも、如何にhotfixに持ち込まないかを頑張る方が良い。
それでもhotfixが出る時は出るもの。Expedited Reviewがパスしない可能性もあるし、審査が単純に長引くこともあることを事業上のリスクとして認識しておく。
Canary Releaseの戦略がプラットフォームに依存する
Google PlayStoreには 段階的な公開 (staged rollouts) が、Apple AppStoreには 自動アップデート用の段階的リリース (Phased release for Automatic Updates) によって、Canary Releaseを行うことができる。
用語の違いから分かるが、それぞれ全く別物になっている。
Google PlayStoreは、ストアに公開されるバージョンの割合や国をコントロール可能で、非常に柔軟だ。ただし、ユーザーが強制的に最新のバージョンにアップデートする手段がない。これは、プレスリリースのタイミングを決めかねたり、カスタマーサポートなどの負荷を高めるかもしれない。 まぁ、弊社の場合はカスタマーサポートへの負荷は全く問題ない とは思うが。
Apple AppStoreの場合、7日間の期間とそれぞれの割合が完全に固定で、さらに自動アップデートを有効にしているユーザーにしか効果がない。その代わり、Google PlayStoreと異なり、ユーザーは必要であれば最新のバージョンに更新できる。上述のプレスリリースタイミングやカスタマーサポートへの負荷なども気にならない。
いずれも、それぞれの特徴を把握した上で適切に扱いたい。プレスリリースしたい変更を含むバージョンをCanary Releaseしないとか、Canary Releaseの目的を明確にした上でそれを最小限に実装したバージョンのみをCanary Release対象にするとか。 特に、それぞれのCanary Releaseの中止と再開の仕方も事前に把握しておく。
ところで、React Native製のモバイルアプリならば、このプラットフォーム依存のCanary Releaseを回避できるかもしれない。が、それはそれで全く別の懸念があるので一概にそれが正解とは言えない。
ビッグ・バン リリース
昨今は、CI as a Serviceが台頭しており、ひと昔より容易にCI/CDの環境を整えることができる。が、どうしてもビッグ・バン リリースを行いたいようなケースも出てくる。
それは「アプリのバージョンアップがマーケティングと密結合している場合」だ。 特にゲームなどでありがち。というか、あった。
この場合、develop/v2.0
みたいな長生きする開発用ブランチが生まれ、リリース前に既存バージョンのバグフィックスとのマージコンフリクトに悩まされ、最悪の場合にはデグレする。
これへの解決は明確で、Trunk Based Development を採用すること、だ。
Trunk Based Developmentは、簡単に言うと、開発者がtopic branchを切る元は常にtrunk(Gitでいうmaster、いや今はmainか) にすることだ。
リリースする時はリリースブランチを切り、hotfixはtrunkを経由してリリースブランチにcherry-pickされる。(リリースブランチにfixを入れてtrunkにbackportする流派もある)
長期間に渡って公開したくない機能がある場合は、feature flagを用いる。そもそもバイナリにさえ含めたくない場合は、ビルドプロセスでそのfeature flagを参照し、コンパイル対象やアセットを含めるか否かを決定すると良い。
もちろんコードやビルドスクリプトの複雑さは増す。 が、Release Engineeringの観点だとマージという俗人的でレビューもできない再現性の低いプロセスに依存するよりは、コードの複雑さを飲み込んでエンジニアリングの世界に持ち込める方がよっぽどマシである。
feature flagにはいくつかの種類がある ので、解決したい問題によってどのflagを使うかを吟味した方が良い。
バックエンドやマイクロサービスなどへの依存と成果物の一貫性
現代では、一つのプロダクトを複数の成果物が構成するのが当たり前だが、それが問題を起こすケースがある。
例えばQA。
クライアントアプリとバックエンドのAPIサーバーがあるとして、受け入れテストを担うQAプロセスではもちろんクライアントアプリを操作してテストを行うが、クライアントアプリとバックエンドAPIサーバとではそもそもリリースサイクルが異なっており、実際にQAしているクライアントアプリのバージョンに対応するバックエンドAPIサーバのバージョンを本質的に固定できない。QAしたクライアントアプリのバージョンをそのまま公開しても、バックエンドAPIサーバが意図せずデプロイされていれば、それはもはやQA対象の成果物ではないのでは・・・?
これに関しては、バックエンドAPIサーバのリリースサイクルを妨げずにQAそのものを機能させることを念頭に、以下のような対策が考えられる。
- APIにおけるユースケースベースのテストを厚くして不正に互換性が破壊されないことを保証する
- QAを行ったクライアントアプリのバージョンと、その際にデプロイされていたサーバーのバージョンを可視化する
- QA対象のクライアントアプリのリビジョンから向き先だけが異なるビルド成果物を生成し、その際にバックエンドのバージョンをクライアントアプリが記憶する。
- そのクライアントアプリを使用しているうちに記憶したバージョンとバックエンドAPIサーバのバージョンが異なることを検知したら、ビルド成果物の再生成を促す
- 強制アップデートの仕組みが使えるかもしれない
- クライアントアプリとバックエンドAPIのバージョンを参照可能にしておく
- そのクライアントアプリを使用しているうちに記憶したバージョンとバックエンドAPIサーバのバージョンが異なることを検知したら、ビルド成果物の再生成を促す
- クライアントアプリとは別の設定アプリから、バックエンドAPIサーバの向き先を指定し、バックエンドAPIサーバのバージョンを確認可能にする
- QA対象のクライアントアプリのリビジョンから向き先だけが異なるビルド成果物を生成し、その際にバックエンドのバージョンをクライアントアプリが記憶する。
もう一つの大きな問題が、マイクロサービス。 (ここでのマイクロサービスは、組織論的な観点を排した、単に異なるサーバーで稼働するプログラムと思って欲しい)
ある特定の時点で、それぞれのマイクロサービスがどのバージョンであるかをすぐに判断できないと、何か問題があった際に影響範囲の把握や原因追及までに手間取ることがある。
例えば、マイクロサービスにアクセスするのにAPI Gatewayを使用して、そこに特定の時点のマイクロサービスのバージョンをログしておく、というのはどうだろう。定期的なヘルスチェックにバージョン情報を混ぜて収集する、のも一つの手かもしれない。 しかし、必ずしもAPI Gatewayを介すマイクロサービスだけが稼働しているとは限らない。
ここで弊社の事例を挙げると 全てのサービスのデプロイタイミングを同じにする だ。
弊社には、自分を含めてサーバー班が3人しかいない。 しかも、うち1人はAndroidとの二足の草鞋状態である。 サーバー班3人それぞれに、班の構成人数以上存在するマイクロサービスをそれぞれ専任するわけにもいかない。こうなったときに、この問題をシンプルにする解決策が 全てのサービスのデプロイタイミングを同じにする だった。
他にも以下のような効果がある。
- 変更頻度が少ないマイクロサービスもデプロイすることによって、自動化されたデプロイ手順が常に動作することを保証できる
- 週一で作成されるセキュリティパッチを含んだイメージでデプロイできる
特に後者のセキュリティパッチの週一適用は本番環境のみならずステージング環境や開発環境も含むもので、この規模でここまでちゃんとしている開発組織に出会ったことがなかったので入社後にそれを知った時はちょっと感動した。
全てのサービスのデプロイタイミングを同じにする という戦略は一見乱暴だが、開発メンバーが少ないうちはメリットもあるので検討に値する、とお勧めしておく。
RDBのスキーママイグレーション
いきなり問題が特定の技術に寄った感はあるが、現に困るケースに多々遭遇してきたので取り上げないわけにはいかない。
現代ではRDBを使用したWebアプリケーションのデプロイ時にはスキーママイグレーション(以下マイグレーション)を合わせて行うことが多いと思うが、大規模になってくるとDDLを発行すること自体のリスクが上がってくる。ステージング環境などで本番環境と同様のデータを持つDBに対してマイグレーションを行って本番適用前に検証する、などは当たり前として、ここではアプリケーションからのSQL実行方法とマイグレーションの関係について考えてみる。 なお、マイグレーションのためにサービスメンテナンスに入るケースはここでは対象外とする。
まず、Ruby on RailsのActive Recordのようにマイグレーションスクリプトを記述し、アプリケーション起動時にDBスキーマを読み込んでDBエンティティの構造を動的に構成するタイプ。
この場合、意図したDBエンティティを構築するためにアプリケーションのデプロイの前にマイグレーションを行うことが鉄則だが、列を削除したい際には予めフレームワークから対象の列を読み込まないようにマーク(Railsだとignored_columnsに指定)しておかないと、アプリケーション起動時に読み込んだスキーマ情報のままクエリを発行してしまい実行エラーになってしまう。
また、Djangoのように、ソースコードでDBエンティティを宣言的に記述しそれから生成されたマイグレーションスクリプトを用いるタイプの場合もほぼ同様だが、マイグレーションの実行とソースコード上のDBエンティティの反映タイミングをいくらかコントロール可能だ。列追加時には予めマイグレーションを実行し、列削除時には先にデプロイをする、というプロセスを踏むことで問題なくスキーマを反映できる。が、このような変更内容に応じてリリースプロセスが変化する運用はRelease Engineeringにおけるプロセスの一貫性を欠くもので、推奨はできない。
O/Rマッパーとマイグレーションツールが完全に独立しているケースは、ライブラリの仕様如何なのでなんとも言えないが、使用しているプログラミング言語が動的言語か否か、使用しているO/Rマッパーのマッピング機能がどのぐらい柔軟性があるかが焦点になるだろう。
動的言語の場合、テーブルの列を追加されたとしてもオブジェクトに動的にフィールドが追加されるだけで副作用はほぼないと言えるかもしれない。(個人的にはそれでも不安だが)
静的型付け言語で、かつO/Rマッパーのマッピング機能がマッピング対象のオブジェクトに完全に一致することを求める場合、マイグレーションの難易度が高まる。まずソースコード上でマッピング対象の型を変更して、SELECTにdummyの列を追加して読み込ませるような実装をデプロイしてから実際のマイグレーションを行う、などの工夫がいる。 大抵は、そこまで厳格なマッピングを求められることはない(と思う)ので、利用するO/Rマッパーのマッピング機能がどの程度の柔軟性を持っているかを選定時に適切に把握しておいた方が良い。
注意点として、SQLクエリビルダーを用いずに生のSQLをプログラム上で組み立てており、かつO/Rマッパーのマッピングが追加の列を許容しない、さらに
SELECT *
を用いている場合、マイグレーションのタイミングはデプロイ後でなければならない。
先に列追加のマイグレーションを実行してしまうと、 SELECT *
にマッピング対象のオブジェクトが認識できない列が含まれてしまう。列削除時も同様で、O/Rマッパーによっては問題になるケースが出てくるかもしれない。
「SQLクエリビルダーを用いずに」という前提を置いたのは、大抵のSQLクエリビルダーで
SELECT *
相当のDSLを使用しても実際に生成されるSQLでSELECT *
は用いられず列を羅列している実装になっていることが多い(と自分は思っている。明確なエビデンスはない)からだ。これもSQLクエリビルダーの実装に依存するので、使用しているSQLクエリビルダーがどうなのか把握しておきたい。
SQLクエリビルダーの比較において SELECT *
はあまり表に出てこないが、マイグレーションの難易度はそのままRelease
Engineeringの難易度にも大きく影響するので、採用する際はよく吟味したい。
ちなみに、上記は SELECT
を中心に展開したが、INSERT
, UPDATE
,
DELETE
も同様に考慮が必要だ。が、正直以下のゲーム実況が面白すぎて全く集中して書けませんでした。多分考慮漏れで溢れているとおもいます、すみません。
#ClusterGAMEJAM 2020 in WINTER 実況プレイイベントを開催🎉
— cluster公式∞GAMEJAM 26日結果発表! (@cluster_jp) December 17, 2020
インサイドちゃん姉妹が皆さんによるゲームジャム投稿作品を実況します!
📅12/22(火)21:00 START!
▼YouTubehttps://t.co/dSAQeChAf7
▼clusterhttps://t.co/xmEPEar0cM
ステートフルなアプリケーションの更新
昨今はFunction as a Serviceが当たり前となり、もはやサーバーサイドアプリケーションを構築する際にはステートレスが前提となっているように思える。
が、もちろん要件によってはステートフルなアプリケーションを構築せざるを得ないケースがある。
はい、まさに今がそうです。
ステートフル、と一口に言ってもどこにどのような状態を持つかは要件によって様々で、知識を共有しづらい。ここでは、2つの限定したケースでどのようにバージョンを更新していけばよいかに触れたい。
まずは、オンメモリに状態を持つ場合。アプリケーション自体のプロセス上にオンメモリで状態を持つと、当然のようにデプロイで更新するとプロセス再起動時に状態が失われる。どうしてもデプロイ前後で状態を保ちたい場合、一時的にファイルや異なるプロセス・外部のデータストアに退避したり、そもそも先述の保存先に定期的にバックアップを取る、などの方法がある。いずれも、いつオンメモリの状態が破棄されたとしても復元可能な状態にしておくことが肝要だ。
次に、同一サーバー上に軽量のデータストア(よく使われるのはSQLiteやmemcached、Redisあたりだろうか)のプロセスを動かし、そこに状態を保存する場合。この場合は、前述のオンメモリの時のようなデプロイ前後で状態が失われることはないが、デプロイ対象とは異なることでデータマイグレーションの問題が発生する可能性がある。アプリケーションのデプロイ前後で読み書き可能なフォーマットに互換性がない場合、デプロイした直後にデータストアからの読み込みに失敗し、どうしようもなくなる危険性がある。この場合、読み書き可能なフォーマット自体に互換性を担保する仕組みを入れる必要がある。現在だと、 Protocol buffers がエコシステムも成熟しており採用しやすい。
終わりに
Release Engineeringは実際の開発組織で多かれ少なかれ実施されているが、その重要度が叫ばれながらもSite Reliability Engineeringほど知識が共有されていないことに課題を感じている。
相当の乱文ではあり、個人の経験に依拠するものが多くエビデンスに欠けている(というか出せない)が、この記事を通じてRelease Engineeringへの取り組みへの興味を持っていただけたら幸いである。
明日は @halmiran の「Hello, Cluster !!」です。
おそらくこの記事よりは、圧倒的に読みやすいでしょう。お楽しみに。
せっかくの会社のAdvent Calendarなので開発組織のことがわかる参考情報もついでに載せておきます。
↓の動画はエージェント向けとされていますが、我々がどのような開発組織なのかとか、どのようなソフトウェアエンジニアを求めているのかとかがよくわかると思います。 (ちょうどいいところからはじまるようにしてます)
clusterを支える技術(と組織)
ついでに求人情報も。Wantedlyが嫌な方はDMしてください。