Sidekiqワーカーの属性

ワーカークラスは、動作を制御したりメタデータを追加したりするために、特定の属性を定義することができます。

他のワーカーを継承した子クラスもこれらの属性を継承するので、値をオーバーライドしたい場合のみ再定義する必要があります。

ジョブの緊急度

ジョブには、urgency 属性を設定することができます。:high:low:throttledのいずれかです。これらの属性には以下のターゲットがあります:

緊急度キュースケジューリングターゲット実行待ち時間要件
:high10秒10秒
:low (デフォルト)1分5分
:throttledなし5分

ジョブの緊急度を設定するには、urgency クラスメソッドを使用します:

class HighUrgencyWorker
  include ApplicationWorker

  urgency :high

  # ...
end

遅延に敏感なジョブ

多数のバックグラウンドジョブが一度にスケジューリングされると、ワーカーノードが利用可能になるのを待つ間、ジョブの待ち行列が発生することがあります。これは標準的なことで、システムがトラフィックの急増に優雅に対処できるようにすることで、回復力を与えます。しかし、一部のジョブは他のジョブよりも待ち時間の影響を受けやすくなっています。

一般的に、待ち時間の影響を受けやすいジョブは、バックグラウンドワーカーで非同期ではなく、同期的に行われることをユーザーが合理的に期待できるオペレーションを実行します。よくある例は、アクションに続く書き込みです。このようなジョブの例には以下が含まれます:

  1. ブランチへのプッシュに続いてマージリクエストを更新するジョブ。
  2. ブランチへのプッシュ後に、プロジェクトの既知のブランチのキャッシュを無効にするジョブ。
  3. 権限の変更後に、ユーザーが見ることのできるグループとプロジェクトを再計算するジョブ。
  4. パイプライン内のジョブの状態変更後にCIパイプラインの状態を更新するジョブ。

これらのジョブが遅延すると、ユーザーは遅延をバグとして認識するかもしれません。たとえば、ブランチをプッシュして、そのブランチのマージリクエストを作成しようとしたときに、UIでブランチが存在しないと表示されるかもしれません。私たちはこのようなジョブをurgency :high とみなします。

これらのジョブは、スケジュールされてから非常に短時間で開始されるよう、特別な努力が払われています。しかし、スループットを確保するために、これらのジョブには非常に厳しい実行時間要件もあります:

  1. ジョブの実行時間の中央値は1秒未満でなければなりません。
  2. 99%のジョブが10秒以内に完了すること。

ワーカーがこれらの期待に応えられない場合、urgency :high ワーカーとして扱うことはできません。ワーカーを再設計するか、2つの異なるワーカーに作業を分割することを検討してください。1つは、urgency :high コードで素早く実行し、もう1つは、urgency :low、実行待ち時間の要求がありません (しかし、スケジューリング目標も低くなります)。

キューの緊急度の変更

GitLab.comでは、Sidekiqをいくつかのシャードで運用しています。

キューの緊急度を変更したり新しいキューを追加したりする際には、新しいシャードで予想される作業負荷を考慮する必要があります。既存のキューを変更する場合、古いシャードにも影響が及びますが、それは常に仕事を減らすことになります。

そのために、新しいシャードの総実行時間とRPS(スループット)の予想増加量を計算します。これらの値は

  • キュー詳細ダッシュボードには、キュー自体の値が表示されます。新しいキューについては、似たようなパターンを持つキューや、似たような状況でスケジュールされているキューを探すことができます。
  • シャード詳細ダッシュボードには、総実行時間とスループット(RPS)があります。シャード利用率パネルには、このシャードに現在余剰容量があるかどうかが表示されます。

次に、変更するキューの RPS * 平均実行時間 (新規ジョブの推定値) を計算して、新しいシャードに期待される RPS と実行時間の相対的な増加を確認できます:

new_queue_consumption = queue_rps * queue_duration_avg
shard_consumption = shard_rps * shard_duration_avg

(new_queue_consumption / shard_consumption) * 100

もし5%未満の増加であれば、それ以上のアクションは必要ありません。

そうでない場合は、@gitlab-org/scalability にマージリクエストを送信し、レビューを依頼してください。

外部依存のジョブ

GitLabアプリケーションのバックグラウンドジョブのほとんどは、他のGitLabサービスと通信します。例えば、PostgreSQL、Redis、Gitaly、オブジェクトストレージなどです。これらはジョブの “内部 “依存とみなされます。

しかし、いくつかのジョブは成功裏に完了するために外部のサービスに依存しています。いくつかの例を挙げます:

  1. ユーザーによって設定されたウェブフックを呼び出すジョブ。
  2. ユーザーが設定したKubernetesクラスターにアプリケーションをデプロイするジョブ。

これらのジョブには “外部依存関係 “があります。これはいくつかの点でバックグラウンド処理クラスターのオペレーションにとって重要です:

  1. ほとんどの外部依存関係 (Web-hook など) は SLO を提供しないため、これらのジョブの実行レイテンシを保証できません。実行待ち時間を保証できないので、スループットも保証できません。したがって、トラフィックが多い環境では、外部依存のジョブを緊急度の高いジョブから分離し、それらのキューのスループットを保証する必要があります。
  2. 外部依存性を持つジョブのエラーは、エラーの原因が外部にある可能性が高いため、より高い警告しきい値が設定されます。
class ExternalDependencyWorker
  include ApplicationWorker

  # Declares that this worker depends on
  # third-party, external services in order
  # to complete successfully
  worker_has_external_dependencies!

  # ...
end

ジョブは緊急度が高く、かつ外部依存を持つことはできません。

CPUバウンドとメモリバウンドワーカー

CPU やメモリリソースの制約を受ける Worker は、worker_resource_boundary メソッドでアノテーションされるべきです。

ほとんどのワーカーは、RedisやPostgreSQL、Gitalyなどの他のサービスからのネットワーク応答を待って、ほとんどの時間をブロックされた状態で過ごす傾向があります。Sidekiqはマルチスレッド環境なので、これらのジョブは高い同時実行性でスケジューリングできます。

しかし、一部のワーカーは、Rubyでロジックを実行する_オンCPUに_大量の時間を費やします。Ruby MRIは真のマルチスレッドをサポートしていません - プロセスをホストするマシンのコア数に関係なく、プロセス内のRubyコードの1つのセクションだけが一度に実行できるようにすることで、アプリケーション開発を大幅に簡素化するためにGILに依存しています。IO バインドされたワーカーの場合、ほとんどのスレッドは(GIL の外にある)基礎ライブラリでブロックされるので、これは問題ではありません。

多くのスレッドが同時に Ruby コードを実行しようとすると、GIL 上で競合が発生し、すべてのプロセスが遅くなります。

トラフィックが多い環境では、ワーカーが CPU に負荷がかかっていることを知ることで、同時実行数の少ない別のフリートでワーカーを実行することができます。これにより、最適なパフォーマンスが保証されます。

同様に、ワーカーが大量のメモリを使用する場合は、低同時性、高メモリの特注フリートで実行することができます。

メモリに縛られたワーカーは、10-50ミリ秒の休止を伴う重い GC 作業負荷を発生させます。これはワーカーのレイテンシ要件に影響します。このため、memory バインドされたurgency :high ジョブは許可されず、CI に失敗します。一般的に、memory バインドされたワーカーは推奨されず、作業を処理する別のアプローチを検討すべきです。

ワーカーが大量のメモリと CPU 時間の両方を必要とする場合、緊急度の高いメモリバインドワーカーに対する上記の制限のため、メモリバインドとしてマークされるべきです。

ジョブを CPU バインドとして宣言する場合

この例では、ジョブに CPU バインドを宣言する方法を示します。

class CPUIntensiveWorker
  include ApplicationWorker

  # Declares that this worker will perform a lot of
  # calculations on-CPU.
  worker_resource_boundary :cpu

  # ...
end

ワーカーが CPU バインドかどうかの判定

ワーカーが CPU バウンドしているかどうかを判定するには、以下の方法を使います:

  • Sidekiq 構造化 JSON ログで、ワーカーのdurationcpu_s フィールドを集約します。
  • duration はジョブの総実行時間を秒単位で表します。
  • cpu_s は、Process::CLOCK_THREAD_CPUTIME_ID カウンタから得られるもので、ジョブが CPU 上で費やした時間の指標です。
  • cpu_sduration で割ると、CPU に費やされた時間のパーセンテージが得られます。
  • この比率が 33% を超えた場合、ワーカーは CPU バインドとみなされ、そのようにアノテーションされるべきです。
  • これらの値は小さなサンプルサイズではなく、かなり大きな集合体に対して使うべきです。

フィーチャー・カテゴリー

すべてのSidekiqワーカーは、既知のフィーチャーカテゴリーを定義する必要があります。

ジョブデータの一貫性戦略

GitLab 13.11以前では、Sidekiqワーカーは読み込みも書き込みも常にプライマリデータベースノードにデータベースクエリを送っていました。シングルノードのシナリオでは、自分の書き込みを読み込むワーカーでさえ古い読み込みに遭遇することはありえないためです。しかし、ワーカーがプライマリに書き込み、レプリカから読み取る場合、レプリカがプライマリより遅れている可能性があるため、古いレコードを読み取る可能性はゼロではありません。

データベースに依存するジョブの数が増えると、データの一貫性を即座に確保することは、プライマリデータベースサーバーに持続不可能な負荷をかけることになります。そこで、Sidekiqワーカーにデータベースロードバランシング機能を追加しました。ワーカーのdata_consistency フィールドを設定することで、スケジューラが以下のいくつかの戦略で読み込みレプリカをターゲットにできるようになります。

即時性と一次負荷の軽減を交換

Sidekiqワーカーには、すべての読み込みと書き込みにプライマリデータベースノードを使用する必要があるのか、それとも読み込みはレプリカから提供できるのかを明示的に判断するよう求めています。これは、data_consistency フィールドが設定されていることを保証する RuboCop ルールによって強制されます。

このフィールドを設定する際には、以下のトレードオフを考慮してください:

  • 即座に一貫性のある読み取りを保証しますが、プライマリ・データベースの負荷は増加します。
  • プライマリデータベースの負荷を軽減するためにリードレプリカを優先しますが、古いリードが発生する可能性が高くなり、再試行が必要になります。

このフィールドが導入される前と同じ動作をメンテナーにするには、:always に設定して、データベースオペレーションがプライマリのみをターゲットにするようにします。このようにしなければならない理由としては、ほとんど、あるいは排他的に書き込みを行うワーカーや、自身の書き込みを読み込むワーカーで、古いレコードがレプリカから読み戻された場合にデータの一貫性のイシューに遭遇する可能性がある場合などがあります。このようなシナリオは避けるようにしてください。:always はルールではなく例外と考えるべきです。

レプリカからの読み取りを可能にするために、:sticky:delayed の2つの整合性モードを追加しました。RuboCop ルールは、:always データ一貫性モードが使用される場合に開発者に注意を促します。ワーカーがプライマリデータベースを必要とする場合、このルールをインラインで無効にすることができます。

:sticky または:delayed の一貫性を宣言すると、ワーカーはデータベース負荷分散の対象となります。

どちらの場合も、レプリカが最新でなく、ジョブのスケジューリングからの時間が最小遅延間隔より短い場合、ジョブは最小遅延間隔(0.8秒)までスリープします。これにより、レプリケーション処理が終了するまでの時間が与えられます。sticky ワーカーはすぐにプライマリに切り替わりますが、delayed ワーカーはすぐに失敗し、一度リトライされます。ワーカーがまだレプリケーションラグに遭遇している場合は、代わりにプライマリに切り替わります。ワーカーが書き込みを行わない場合は、:sticky:delayed の一貫性設定を適用することを強くお勧めします。ワーカーはプライマリデータベースノードに依存する必要がないからです。

以下の表は、data_consistency 属性とその値を、レプリケーションの読み込みを優先する度合いと、レプリケーションが追いつくのを待つ度合いの順に並べたものです:

データの一貫性説明ガイドライン
:alwaysジョブはプライマリデータベースを使用する必要があります(デフォルト)。主に書き込みを行うワーカーや、自身の書き込みを読み込む際にデータの一貫性に関して厳しい要求があるワーカー、cron ジョブに使うべきです。
:stickyジョブはレプリカを優先しますが、書き込みやレプリケーションの遅延が発生した場合はプライマリに切り替えます。可能な限り高速に実行されることが要求されるジョブで、初期のキューイング遅延は多少許容される場合に使用されるべきです。
:delayedジョブはレプリカを優先しますが、書き込みはプライマリに切り替えます。ジョブの開始前にレプリケーションの遅延が発生した場合、ジョブは一度再試行されます。次の再試行でレプリカがまだ最新でない場合、プライマリに切り替わります。これは、キャッシュの有効期限切れやウェブフックの実行など、通常、実行を遅らせることが問題にならないジョブに使用されるべきです。

どのような場合でも、ワーカーは完全にキャッチアップされたレプリカかプライマリノードから読み込みます。

ワーカーにデータの一貫性を設定するには、data_consistency クラスメソッドを使います:

class DelayedWorker
  include ApplicationWorker

  data_consistency :delayed

  # ...
end

feature_flag プロパティ

feature_flag プロパティを使用すると、ジョブのdata_consistencyを切り替えることができ、特定のジョブのロードバランシング機能を安全に切り替えることができます。feature_flag を無効にすると、ジョブのデフォルトは:always になります。これはジョブが常にプライマリデータベースを使用することを意味します。

feature_flag プロパティでは、アクターに基づく機能ゲートを使用できません。つまり、機能フラグを特定のプロジェクト、グループ、ユーザーのみにトグルすることはできませんが、その代わりに、パーセンテージ・オブ・タイム・ロールアウトを安全に使用することができます。Sidekiqクライアントとサーバーの両方で機能フラグをチェックするので、時間の10%をロールアウトすると、レプリカを使用する有効なジョブの1%(0.1 [from client]*0.1 [from server])になる可能性があります。

使用例:

class DelayedWorker
  include ApplicationWorker

  data_consistency :delayed, feature_flag: :load_balancing_for_delayed_worker

  # ...
end

べき等ジョブによるデータの一貫性

:sticky または:delayed のデータ一貫性を宣言する冪等ジョブの場合、重複排除中に最新のWALロケーションを保持し、完全にキャッチアップされたレプリカから読み込むようにします。

ジョブの一時停止制御

pause_control プロパティを使用すると、条件付きでジョブ処理を一時停止できます。ストラテジーがアクティブな場合、ジョブは別個のZSET に保存され、ストラテジーが非アクティブになったときに再度キューに入れられます。PauseControl::ResumeWorker は、一時停止されたジョブが再起動されなければならないかどうかをチェックする cron ワーカーです。

pause_control を使用するには、次のようにします:

  • lib/gitlab/sidekiq_middleware/pause_control/strategies/ で定義されている戦略のいずれかを使用します。
  • lib/gitlab/sidekiq_middleware/pause_control/strategies/ でカスタムストラテジーを定義し、そのストラテジーをlib/gitlab/sidekiq_middleware/pause_control/strategies.rb に追加します。

使用例:

module Gitlab
  module SidekiqMiddleware
    module PauseControl
      module Strategies
        class CustomStrategy < Base
          def should_pause?
            ApplicationSetting.current.elasticsearch_pause_indexing?
          end
        end
      end
    end
  end
end
class PausedWorker
  include ApplicationWorker

  pause_control :custom_strategy

  # ...
end