アップデートに伴う後方互換性

GitLabのデプロイは多くのコンポーネントに分解することができます。GitLabのアップデートはアトミックではありません。そのため、多くのコンポーネントは後方互換性がなければなりません

よくある不具合

ある意味、これらのシナリオはすべて一過性の状態です。しかし、本番環境では数時間持続することがよくあります。したがって、永続的な状態と同じように注意深く扱わなければなりません。

Sidekiqワーカーを変更する場合

例えば、引数を変更する場合:

  • ジョブが古いシグネチャでエンキューされ、新しい月次リリースで実行されても問題ありませんか?
  • ジョブは新しいシグネチャでキューイングされていますが、以前の月次リリースで実行されても問題ありませんか?

新しいSidekiqワーカーを追加する場合

Sidekiqノードがまだ更新されていないため、これらのジョブが数時間実行されなくても大丈夫ですか?

JavaScriptを修正する場合

ブラウザには新しい JavaScript コードがあるのに、Rails コードでは以前の月次リリースが実行されているのは問題ないのでしょうか:

  • REST APIで実行されている場合は大丈夫ですか?
  • GraphQL API?
  • コントローラの内部API?

デプロイ前のマイグレーションを追加する場合

デプロイ前マイグレーションが実行されましたが、Web、Sidekiq、APIノードが以前のリリースを実行していても問題ありませんか?

デプロイ後のマイグレーションを追加する場合

すべての GitLab ノードが更新されたのに、デプロイ後のマイグレーションが数日後まで実行されないのは大丈夫ですか?

バックグラウンドマイグレーションを追加するとき

すべてのノードが更新された後、デプロイ後のマイグレーションが数日後に実行され、バックグラウンドマイグレーションが終了するのに1週間かかるのですが、大丈夫でしょうか?

Railsのような依存関係をアップグレードする場合

一部のノードには新しいRailsのバージョンがあり、一部のノードには古いRailsのバージョンがあるのは問題ないでしょうか?

アップデートのウォークスルー

アップデート中の後方互換性の問題は、しばしば非常に微妙です。そのため、よく理解しておく価値があります:

これらの問題がどのように発生するのかを説明するために、この例を見てみましょう:

  • 新しいバージョン
  • 旧バージョン

この例では、毎月1回のリリースで更新していると想像できます。しかし、コードはいつまで後方互換でなければならないかを参照してください。

更新ステップPostgreSQL DBウェブノードAPIノードSidekiqノード互換性の問題
初期状態🙂🙂🙂🙂 
デプロイ前のマイグレーションの実行デプロイ後のマイグレーションを除く 🚢 ウェブノードの更新🙂🙂🙂のRailsコードはᙂにDBコールをかけています。
ウェブノードの更新デプロイ後のマイグレーションを除く 🚢 ウェブノードの更新🚢🙂🙂のJavaScriptはᙂにAPIコールを行います。のRailsコードは、ᙂのSidekiqノードによって実行されるジョブをエンキューしています。
APIとSidekiqノードの更新デプロイ後のマイグレーションを除く 🚢 ウェブノードの更新🚢🚢🚢のRailsコードは、デプロイ後のマイグレーションやバックグラウンドマイグレーションなしでDBを呼び出しています。
デプロイ後のマイグレーションを実行🚢🚢🚢🚢のRailsコードはバックグラウンドマイグレーションなしでDBを呼び出しています。
バックグラウンドマイグレーション終了🚢🚢🚢🚢 

この例は網羅的なものではありません。GitLabは様々な方法でデプロイできます。各更新ステップもアトミックではありません。例えば、ローリングデプロイでは、グループ内のノードは一時的に異なるバージョンになります。更新ステップの間には多くの時間が経過すると考えるべきです。これはGitLab.comではよくあることです。

コードの後方互換性はいつまで保たなければなりませんか?

ゼロダウンタイムアップデートの指示に従うユーザーの場合、答えは毎月1回のリリースです。例えば

  • 13.11 => 13.12
  • 13.12 => 14.0
  • 14.0 => 14.1

GitLab.comでは、1日に複数の小さなバージョン更新があり得るので、GitLab.comはどこまでの変更が後方互換でなければならないかを制限しません。

例えば、多くのユーザーは毎月のリリースをスキップしています:

  • 13.0 => 13.12

このようなユーザーは、アップデート中に多少のダウンタイムを受け入れることになります。残念ながら、このケースを完全に無視することはできません。例えば、13.12は13.0のSidekiqジョブを実行する可能性があり、これはメジャーリリースまでジョブから引数を削除することを避ける理由を示しています。主な疑問は、アップデートが完了した後にデプロイが良好な状態になるかどうかです。

GitLabはどのようなコンポーネントに分解できるでしょうか?

50,000のリファレンスアーキテクチャでは、48以上のノードでGitLabが稼働しています。GitLab.comはそれよりも大きく、さらにインフラの一部はKubernetes上で動いていて、さらにアップデートを最初に受け取る “カナリア “ステージがあります。

しかし、問題はノードが多いことだけではありません。もっと大きな問題は、デプロイが異なるコンテキストに分けられることです。そして、これをやっているのはGitLab.comだけではありません。いくつかの可能な分割:

  • “カナリアウェブアプリノード”:一部のユーザーからのAPI以外のリクエストを処理します。
  • 「Gitアプリノード”:Git リクエストを処理
  • 「Webアプリノード」:Webリクエストを処理
  • 「APIアプリノード」:APIリクエストを処理
  • “Sidekiqアプリノード”:Sidekiqジョブの処理
  • “PostgreSQLデータベース”:PostgreSQL内部呼び出しの処理
  • “Redisデータベース”:Redis内部呼び出しの処理
  • 「Gitalyノード”:Gitaly内部呼び出しの処理

アップデートの間、2つの異なるバージョンのGitLabが異なるコンテキストで実行されます。例えば、Webノードは古いSidekiqノードで実行されるジョブをエンキューするかもしれません

アップデートの順番は重要ではないのですか?

はい!ゼロダウンタイムの更新について特別な指示を出しているのは、互換性のいくつかの組み合わせを無視できるからです。Railsコードが古いPostgreSQLデータベーススキーマに対してDBコールを行うことを心配しないのはこのためです。

潜在的な後方互換性の問題を特定しました。

調整

RailsまたはPumaのメジャーバージョンまたはマイナーバージョンの更新:

  • MR を徹底的にテストするために品質チームに参加してください。
  • マージする前に、MRについて@gitlab-org/release/managers に通知します。

フィーチャーフラグ

機能フラグは後方互換性の問題を扱うためのツールであり、戦略ではありません。

たとえば、フロントエンドとAPIの変更がデフォルトで無効になっている場合、フロントエンドとAPIの変更を伴う新機能の追加は安全です。これは複数のマージリクエストで行うことができ、任意の順番でマージできます。すべての変更が GitLab.com にデプロイされた後、ChatOps で機能を有効にし、GitLab.com で検証することができます。

しかし、デフォルトで機能を有効にするのは必ずしも安全ではありません。コードがマージされた同じリリースで機能フラグが削除されたり、デフォルトが有効になっていたりすると、ゼロダウンタイムのアップデートを行う顧客が、以前のリリースのAPIに対して新しいフロントエンドコードを実行することになってしまいます。

すべての変更を一度に有効にしても安全かどうかわからない場合は、現在のリリースでAPIを有効にして、次のリリースでフロントエンドの変更を有効にするという方法もあります。これはExpand and contractパターンの例です。

あるいは、前のリリースのAPIに対してフロントエンドが潔くデグレードするように修正することで、リリースの遅れを回避できるかもしれません。

グレースフルデグレード

例として、フロントエンドとAPIを変更して新機能を追加するとき、新機能が古いAPIレスポンスに対してグレースフル・デグレードするようにフロントエンドを書くことができるかもしれません。これにより、3つのリリースに渡る変更を避けることができます。

拡張と縮小のパターン

オンプレミスインスタンスのダウンタイムなしのアップデートを保証する1つの方法は、拡張と縮小のパターンに従うことです。

つまり、すべての変更は、拡張、マイグレーション、縮小の3つのフェーズに分けられます。

  1. expand:ソフトウェアの後方互換性を維持したまま、ブレークチェンジが導入されます。
  2. マイグレーション:すべてのコンシューマが新しい実装を使用するように更新されます。
  3. contract: 後方互換性が削除されます。

これらの3つの段階は、ダウンタイムなしのアップデートを可能にするために、異なるマイルストーンに含まれなければなりません。

機能のサポートレベルによっては、契約フェーズを次のメジャーリリースまで遅らせることもできます。

拡大と縮小の例

ルートの変更、Sidekiqワーカーパラメータの変更、データベースのマイグレーションはすべて、変更を壊す完璧な例です。これらを安全に処理する方法を見てみましょう。

ルート変更

ルーティングを変更するときは、新しいバージョンで生成されたルートが古いバージョンで使えるかどうか、またその逆かどうかを確認することに注意を払うべきです。お分かりのように、これを行わないと障害につながる可能性があります。この種の変更は、2つの実装の間をすぐに切り替えるように見えるかもしれません。しかし、特にカナリアステージでは、両方のバージョンのコードが運用環境で共存する期間が長くなります。

  1. 拡張: 新しいルートが追加され、古いルートと同じコントローラを指しています。しかし、アプリケーションの何も新しいルートへのリンクを生成しません。
  2. migrate: フリート内のすべてのマシンが新しいルートを理解できるようになったので、新しいルーティングでリンクを生成できます。
  3. contract:古いルートは安全に削除できます。(リポジトリファイルへのリンクのように、古いルートが広く共有される可能性がある場合、リダイレクトを追加し、古いルートを長い期間維持したいかもしれません)

Sidekiq ワーカーのパラメータ変更

このトピックは、Sidekiq Compatibility across Updatesで詳しく説明されています。

Sidekiqワーカークラスに新しいパラメータを追加する必要がある場合、以下のステップに分けることができます:

  1. expand:ワーカークラスはデフォルト値で新しいパラメータを追加します。
  2. マイグレーション: Worker のすべての呼び出しに新しいパラメータを追加します。
  3. contract: デフォルト値を削除します。

一見すると、エキスパンドとマイグレーションを1つのマイルストーンにバンドルしても安全なように思えますが、これはSidekiqより先にPumaが再起動した場合に障害が発生します。Pumaは、古いSidekiqが処理できない余分なパラメータを持つジョブをエンキューします。

データベースのマイグレーション

次のグラフはデプロイを簡略化して視覚的に表したもので、マイグレーション戦略において拡張と縮小がどのように実装されているかを理解するためのガイドとなります。

ここで特別に考慮すべきことがあります。デプロイ後のマイグレーションフレームワークを使用することで、3つのフェーズを1つのマイルストーンにまとめることができます。

gantt title Deployment dateFormat HH:mm section Deploy box Run migrations :done, migr, after schemaA, 2m Run post-deployment migrations :postmigr, after mcvn , 2m section Database Schema A :done, schemaA, 00:00 , 1h Schema B :crit, schemaB, after migr, 58m Schema C. : schemaC, after postmigr, 1h section Machine A Version N :done, mavn, 00:00 , 75m Version N+1 : after mavn, 105m section Machine B Version N :done, mbvn, 00:00 , 105m Version N+1 : mbdone, after mbvn, 75m section Machine C Version N :done, mcvn, 00:00 , 2h Version N+1 : mbcdone, after mcvn, 1h

データベースの観点からこのスキーマを見ると、2つのデプロイが1つのGitLabデプロイに組み込まれていることがわかります:

  1. Schema A からSchema B
  2. Schema B からSchema C

そして、これらのデプロイはアプリケーションの変更と完全に一致しています。

  1. 冒頭では、Version N Schema A
  2. その後、Version NVersion N+1 の両方がSchema B上にある_長い_移行期があります。
  3. Schema B 上にVersion N+1 しかない場合、スキーマは再び変化します。
  4. 最後に、Schema C 上にVersion N+1 があります。

これらの詳細を念頭に置いて、クエリを置き換える必要があり、このクエリにはそれをサポートするインデックスがあると想像してみましょう。

  1. expand: これはSchema A からSchema B へのデプロイです。新しいインデックスを追加しますが、アプリケーションは今のところこれを無視します。
  2. マイグレーション: これはVersion N からVersion N+1 へのアプリケーションのデプロイです。新しいコードがデプロイされ、この時点では新しいクエリのみが実行されます。
  3. contract:Schema B からSchema C へ(デプロイ後のマイグレーション)。古いインデックスはもう何も使っていないので、安全に削除できます。

これはほんの一例です。より複雑なマイグレーション、特にバックグラウンドマイグレーションが必要な場合、複数のマイルストーンが必要になるかもしれません。詳細はマイグレーションスタイルガイドを参照してください。

過去のインシデントの例

MRのルートを移動した際、新しいサーバーのユーザーは新しいURLにリダイレクトされました。これらのユーザーがこれらの新しいURLをMarkdown(または他の場所)で共有した場合、旧サーバーのユーザーにとってはリンク切れになりました。

詳細は関連するイシューをご覧ください。

イシューやマージリクエストの説明やコメントに古いキャッシュがある場合

私たちはMarkdownキャッシュのバージョンを上げ、ユーザが異なるMarkdownキャッシュバージョンから生成された説明やコメントを編集したときにバグを発見しました。キャッシュされたHTMLは保存後に適切に生成されませんでした。ほとんどの場合、ユーザは編集を選択する前にMarkdownを閲覧し、Markdownキャッシュが更新されるため、このようなことは起こりませんでした。しかし、私たちはバージョンが混在しているため、このようなことが起こりやすいのです。異なるバージョンの別のユーザーが同じページを閲覧し、裏でキャッシュを他のバージョンにリフレッシュする可能性があります。

詳細は関連するイシューをご覧ください。

プロジェクトサービスのテンプレートが正しくコピーされません。

サービスがテンプレートであるかどうかを示すカラムを変更しました。サービスを作成する際、テンプレートから属性をコピーし、このカラムをfalse に設定します。古いサーバーは古いカラムを更新していましたが、古いカラムから新しいカラムを更新するDBトリガーがあったため、問題ありませんでした。しかし、新しいサーバでは、新しいカラムのみが更新され、同じトリガが動作し、間違った値に設定されていました。

詳細は関連するイシューをご覧ください。

あるGraphQLフィールドのデータ型を変更しました。ユーザーが新しいサーバーからイシューページを開き、GraphQL AJAX リクエストが古いサーバーに送られると、型の不一致が発生し、JavaScript エラーが発生してサイドバーがロードされませんでした。

詳細は関連するイシューをご覧ください。

CIアーティファクトのアップロードに失敗しました。

カラムにNOT NULL 制約を追加し、NOT VALID 制約としてマークして、既存の行に強制しないようにしました。しかし、それでも、古いサーバーでは新しい行がNULL値で挿入されていたため、これはまだ問題でした。

詳細は関連するイシューをご覧ください。

カナリアと本番デプロイ間のリリース機能のダウンタイム

このイシューに対処するため、NOT NULL 制約を持つ既存のテーブルに、デフォルト値を指定せずに新しい列を追加しました。言い換えれば、これはアプリケーションで列に値を設定する必要があります。

古いバージョンのアプリケーションは、以前はエンティティ/概念が存在しなかったため、NOT NULL 制約を設定しませんでした。

問題はcanaryのデプロイが完了した直後に始まります。その時点で、データベースのマイグレーション(カラムの追加)は正常に実行され、カナリアインスタンスは新しいアプリケーションコードを使い始めます。残念ながら、本番インスタンスはまだ古いコードを使用しているので、新しいリリースエントリの挿入に失敗し始めました。

詳細については、リリースAPIに関連するこのイシューを参照してください。

ノードの種類によってデプロイ時間が異なるためにビルドが失敗する問題

ある本番環境のイシューでparallel キーワードを使用し、変数CI_NODE_TOTAL が整数であることに依存する CI ビルドが失敗しました。これはユーザーがコミットをプッシュした後に発生しました:

  1. 新しいコード:Sidekiqは新しいパイプラインと新しいビルドを作成しました。build.options[:parallel]Hash.
  2. 古いコード:Runnerは、旧バージョンを実行しているAPIノードからジョブをリクエストしました。
  3. その結果、新しいコードはAPIサーバー上で実行されませんでした。古い API サーバーはCI_NODE_TOTAL CI/CD 変数を返そうとしましたが、整数値(たとえば 9)を送信する代わりに、シリアル化されたHash 値 ({:number=>9, :total=>9}) を送信したため、Runner のリクエストは失敗しました。

デプロイパイプラインを見ると、すべてのノードが並行して更新されていることがわかります:

GitLab.com deployment pipeline

しかし、同じ時刻に更新が開始されたにもかかわらず、完了時刻には大きなばらつきがありました:

ノードタイプ持続時間(分)
API54
Sidekiq21
K8S8

parallel キーワードを使用し、CI_NODE_TOTALCI_NODE_INDEX に依存していたビルドは、Sidekiq が更新された後の時間に失敗しました。Kubernetes (K8S)はSidekiqポッドも実行しているため、このウィンドウは46分と長くなることも、33分と短くなることもありました。いずれにせよ、デプロイが完了した後にオンにする機能フラグがあれば、このような事態を防ぐことができます。