マイグレーションにおけるダウンタイムの回避

データベースを扱うとき、あるオペレーションではダウンタイムが必要になることがあります。マイグレーションではダウンタイムを発生させることができないため、ダウンタイムなしで同じ最終結果を得るための一連の手順を使用する必要があります。このガイドでは、ダウンタイムが必要と思われるさまざまなオペレーション、その影響、ダウンタイムを発生させずに実行する方法について説明します。

カラムの削除

カラムを削除するのはやっかいです。GitLabのプロセスがまだカラムを使っている可能性があるからです。これを安全に回避するには、3つのリリースで3つのステップが必要です:

  1. カラムを無視する(リリースM)
  2. カラムの削除(リリースM+1)
  3. 無視ルールの削除(リリースM+2)

3つのリリースにまたがる理由は、カラムの削除は簡単にロールバックできない破壊的オペレーションだからです。

この手順に従うことで、GitLab.comへのデプロイやセルフマネージドインストールのアップグレードプロセスで、これらの手順をひとまとめにしてしまうようなことがないようにすることができます。

列を無視する (リリース M)

最初のステップは、アプリケーションコード内でカラムを無視し、モデルのバリデーションを含むそのカラムへのすべてのコード参照を削除することです。Railsはカラムをキャッシュし、さまざまな場所でこのキャッシュを再利用するため、このステップが必要です。これは無視するカラムを定義することで実行できます。たとえば、リリース12.5 、Userモデルのupdated_at を無視するには次のようにします:

class User < ApplicationRecord
  include IgnorableColumns
  ignore_column :updated_at, remove_with: '12.7', remove_after: '2019-12-22'
end

複数のカラムを無視することもできます:

ignore_columns %i[updated_at created_at], remove_with: '12.7', remove_after: '2019-12-22'

モデルがCEとEEに存在する場合、CEモデルでは列は無視されなければなりません。モデルがCEとEEに存在する場合、列はCEモデルでは無視されなければなりません。

列の無視ルールをいつ削除しても安全であるかを示す必要があります:

  • remove_withカラム無視を追加した後、GitLabのリリースに通常2リリース(M+2)(12.7 )を設定します。
  • remove_afterカラムignoreを削除しても安全だと考えられる日付、通常はM+1リリース日の後、M+2開発サイクルの間に設定します。たとえば、12.7 の開発サイクルは、2019-12-182020-01-17の間にあり、列を12.6 削除するリリース 12.6であるため、2019-12-22として、12.6 日付をリリース日に設定するのが安全 12.6です。

この情報によって、カラムの無視をより適切に推論できるようになり、通常のリリースやGitLab.comへのデプロイの両方でカラムの無視を早く削除しすぎないようにすることができます。たとえば、カラムを無視する変更を含む大量の変更をデプロイして、その後にカラムの無視を削除するような事態を避けることができます (これはダウンタイムにつながります)。

この例では、カラムを無視する変更がリリース12.5 に入っています。

列の削除 (リリースM+1)

この例を続けると、カラムの削除はリリース12.6 の_デプロイ後の_マイグレーションに入ります:

デプロイ後のマイグレーションを作成することから始めましょう:

bundle exec rails g post_deployment_migration remove_users_updated_at_column

カラムを削除するマイグレーションを作成する際には、これらのシナリオを考慮する必要があります:

削除されたカラムには、そのカラムに属するインデックスまたは制約がありません。

この場合、トランザクションマイグレーションを使用することができます:

class RemoveUsersUpdatedAtColumn < Gitlab::Database::Migration[2.1]
  def up
    remove_column :users, :updated_at
  end

  def down
    add_column :users, :updated_at, :datetime
  end
end

大きなテーブルでマイグレーションを実行する場合、ロックの再試行を有効にすることを検討できます。

削除されたカラムには、そのカラムに属するインデックスや制約があります。

down 、削除されたインデックスや制約を元に戻す必要がある場合、トランザクションマイグレーションではこれを行うことはできません。マイグレーションはこのようになります:

class RemoveUsersUpdatedAtColumn < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  def up
    remove_column :users, :updated_at
  end

  def down
    unless column_exists?(:users, :updated_at)
      add_column :users, :updated_at, :datetime
    end

    # Make sure to add back any indexes or constraints,
    # that were dropped in the `up` method. For example:
    add_concurrent_index(:users, :updated_at)
  end
end

down メソッドでは、列を再度追加する前に、その列が既に存在するかどうかを確認します。これは、マイグレーションが非トランザクションであり、実行中に失敗した可能性があるためです。

disable_ddl_transaction! はマイグレーション全体をラップするトランザクションを無効にするために使われます。

データベースマイグレーションの詳細については、マイグレーションスタイルガイドのページを参照してください。

無視ルールの削除(リリース M+2)

次のリリース、この例では12.7 で、無視ルールを削除するために別のマージリクエストを設定します。これにより、ignore_column の行が削除され、不要になった場合はIgnoreableColumns の内部も削除されます。

これは、remove_with で示されたリリースで、remove_after の日付が過ぎたらマージされるべきです。

カラム名の変更

標準的な方法でカラムの名前を変更すると、データベースのマイグレーション中やマイグレーション後にアプリケーションが古いカラム名を使い続ける可能性があるため、ダウンタイムが発生します。ダウンタイムを発生させずにカラムの名前を変更するには、2つのマイグレーションが必要です。これらのマイグレーションは同じリリースで実行できます。手順は以下の通りです:

  1. 通常のマイグレーションを追加します (リリース M)
  2. 列を無視(リリース M)
  3. デプロイ後のマイグレーションを追加(リリース M)
  4. 無視ルールを削除(リリースM+1)
note
デフォルト値を持つカラムの名前を変更することはできません。詳細はこのマージリクエストを参照してください。

通常のマイグレーションを追加しました (リリース M)。

まず、通常のマイグレーションを作成する必要があります。このマイグレーションでは、Gitlab::Database::MigrationHelpers#rename_column_concurrently を使って名前の変更を行う必要があります。例えば

# A regular migration in db/migrate
class RenameUsersUpdatedAtToUpdatedAtTimestamp < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  def up
    rename_column_concurrently :users, :updated_at, :updated_at_timestamp
  end

  def down
    undo_rename_column_concurrently :users, :updated_at, :updated_at_timestamp
  end
end

これにより、カラム名の変更、データの同期、インデックスと外部キーのコピーが行われます。

カラムに、元のカラムの名前を含まないインデックスが1つ以上含まれている場合、前述の手順は失敗します。この場合、これらのインデックスの名前を変更する必要があります。

カラムを無視する(リリースM)

次のステップは、アプリケーションコードでカラムを無視し、使用されないようにすることです。Railsはカラムをキャッシュし、さまざまな場所でこのキャッシュを再利用するため、このステップが必要です。このステップはカラムが削除されたときの最初のステップと似ており、同じ要件が適用されます。

class User < ApplicationRecord
  include IgnorableColumns
  ignore_column :updated_at, remove_with: '12.7', remove_after: '2019-12-22'
end

デプロイ後のマイグレーションを追加する (リリースM)

リネームの手順では、デプロイ後のマイグレーションでいくつかのクリーンアップが必要です。このクリーンアップはGitlab::Database::MigrationHelpers#cleanup_concurrent_column_rename を使って実行できます:

# A post-deployment migration in db/post_migrate
class CleanupUsersUpdatedAtRename < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  def up
    cleanup_concurrent_column_rename :users, :updated_at, :updated_at_timestamp
  end

  def down
    undo_cleanup_concurrent_column_rename :users, :updated_at, :updated_at_timestamp
  end
end

大きなテーブルの名前を変更する場合、最初のマイグレーションが実行され、2回目のクリーンアップマイグレーションがまだ実行されていない状態を注意深く考慮してください。Canaryでは、システムがこの状態でかなりの時間実行される可能性があります。

無視ルールの削除(リリースM+1)

カラムが削除されたときと同じように、リネームが完了したら、その後のリリースで無視ルールを削除する必要があります。

列制約の変更

NOT NULL 節(または他の制約)の追加や削除は、通常ダウンタイムなしで行うことができます。しかし、アプリケーションの変更を_最初に_デプロイする必要があります。したがって、列の制約の変更は、デプロイ後のマイグレーションで行う必要があります。

change_column 。列型全体を再定義するため、非効率的なクエリが生成されるため、使用を避けてください。

具体的な使用例については、以下のガイドを参照してください:

カラムタイプの変更

カラムの型を変更するにはGitlab::Database::MigrationHelpers#change_column_type_concurrently を使用します。この方法はrename_column_concurrently と同様に動作します。例えば、users.username の型をstring からtextに変更したいとします:

  1. 通常のマイグレーションの作成
  2. デプロイ後のマイグレーションを作成します。
  3. 新しい型へのデータのキャスト

通常のマイグレーションの作成

通常のマイグレーションは、データの同期を保つためにいくつかのトリガーを設定し、一時的な名前で新しいカラムを作成するために使われます。このようなマイグレーションは次のようになります:

# A regular migration in db/migrate
class ChangeUsersUsernameStringToText < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  def up
    change_column_type_concurrently :users, :username, :text
  end

  def down
    undo_change_column_type_concurrently :users, :username
  end
end

デプロイ後のマイグレーションを作成します。

次に、デプロイ後のマイグレーションを使って変更をクリーンアップする必要があります:

# A post-deployment migration in db/post_migrate
class ChangeUsersUsernameStringToTextCleanup < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  def up
    cleanup_concurrent_column_type_change :users, :username
  end

  def down
    undo_cleanup_concurrent_column_type_change :users, :username, :string
  end
end

これで完了です!

データを新しい型にキャスト

型の変更によっては、データを新しい型にキャストする必要があります。例えば、text からjsonb に変更する場合などです。 この場合、type_cast_function オプションを使用します。不正なデータがなく、キャストが常に成功することを確認してください。キャスト・エラーを処理するカスタム関数を用意することもできます。

マイグレーション例:

  def up
    change_column_type_concurrently :users, :settings, :jsonb, type_cast_function: 'jsonb'
  end

列のデフォルトの変更

カラムのデフォルトを変更するのは、Railsがデフォルトと等しい値をどのように扱うかによって難しくなります。

note
Railsはレコードを書き込むときにデフォルト値をPostgreSQLに送信することを無視します。このタスクはデータベースに任せます。マイグレーションによってカラムのデフォルト値が変更されると、実行中のアプリケーションはスキーマキャッシュによってこの変更に気づきません。アプリケーションは、特にデータベースマイグレーションを実行した後に新しいバージョンのコードをデプロイするときに、誤って間違ったデータをデータベースに書き込む危険性があります。

実行中のコードがカラムの古いデフォルト値を明示的に書き込むことがある場合は、古いデフォルトを明示的に指定するINSERTクエリでRailsが古いデフォルトを新しいデフォルトに置き換えるのを防ぐために、複数のステップを踏む必要があります。

これを行うには、2つのマイナーリリースで手順を踏む必要があります:

  1. モデルSafelyChangeColumnDefault を追加し、マイグレーション後のデフォルトを変更します。
  2. 次のマイナーリリースでは、SafelyChangeColumnDefault の懸念 をクリーンアップします。

セルフマネージドリリースはマイナーリリース全体を1つのゼロダウンタイムのデプロイにバンドルするため、SafelyChangeColumnDefault をクリーンアップする前にマイナーリリースを待たなければなりません。

SafelyChangeColumnDefault 、マイグレーション後のモデルでデフォルトを変更します。

最初のステップはアプリケーションコードにおいてカラムを変更しても安全であるようにマークすることです。

class Ci::Build < ApplicationRecord
  include SafelyChangeColumnDefault

  columns_changing_default :partition_id
end

それから、デフォルトを変更するためにデプロイ後のマイグレーションを作成します:

bundle exec rails g post_deployment_migration change_ci_builds_default
class ChangeCiBuildsDefault < Gitlab::Database::Migration[2.1]
  def up
    change_column_default('ci_builds', 'partition_id', from: 100, to: 101)
  end

  def down
    change_column_default('ci_builds', 'partition_id', from: 101, to: 100)
  end
end

大きなテーブルでマイグレーションを実行する場合、ロックの再試行を有効にすることを検討できます。

次のマイナーリリースでSafelyChangeColumnDefault

次のマイナーリリースでは、columns_changing_default の呼び出しを削除する新しいマージリクエストを作成してください。また、SafelyChangeColumnDefault のインクルードも、別のカラムで不要であれば削除してください。

ラージテーブルのスキーマ変更

change_column_type_concurrentlyrename_column_concurrently 、ダウンタイムなしでテーブルのスキーマを変更することができますが、大きなテーブルではあまりうまくいきません。すべての作業が順番に行われるため、マイグレーションが完了するまでに非常に長い時間がかかり、デプロイを進めることができません。また、多くの行を順番に高速に更新するため、データベースへの負荷が大きくなります。

データベースへの負荷を軽減するために、大きなテーブル(例えば、issues)の列をマイグレーションする場合は、バックグラウンドマイグレーションを使用する必要があります。バックグラウンドマイグレーションは、デプロイを遅くすることなく、作業や負荷をより長い期間に分散します。

詳細については、バッチバックグランドマイグレーションのクリーンアップに関するドキュメントを参照してください。

インデックスの追加

add_concurrent_index を使用している場合、インデックスの追加にダウンタイムは必要ありません。

詳細はマイグレーション・スタイル・ガイドも参照してください。

インデックスの削除

インデックスの削除にダウンタイムは必要ありません。

テーブルの追加

このオペレーションは、まだテーブルを使用するコードがないので安全です。

テーブルの削除

テーブルの削除は、デプロイ後のマイグレーションを使用して安全に行うことができますが、アプリケーションがテーブルを使用しなくなった場合に限ります。

データベース辞書で説明したプロセスを使用して、db/docs/deleted_tables にテーブルを追加します。テーブルが削除されても、データベースマイグレーションでは参照されます。

テーブル名の変更

データベースのマイグレーション中やマイグレーション後も、アプリケーションが古いテーブル名を使用し続ける可能性があるため、テーブル名の変更にはダウンタイムが必要です。

テーブルとActiveRecordモデルがまだ使用されていない場合、古いテーブルを削除して新しいテーブルを作成するのが「テーブル名の変更」の望ましい方法です。

テーブルの名前を変更するには、マルチリリースのrename tableプロセスに従ってください。

外部キーの追加

外部キーの追加は、通常3つのステップで行います:

  1. トランザクションの開始
  2. ALTER TABLE を実行して制約を追加します。
  3. すべての既存データのチェック

ALTER TABLE は通常、トランザクションが終了するまで排他ロックを取得するため、この方法ではダウンタイムが発生します。

GitLabではこれを回避するためにGitlab::Database::MigrationHelpers#add_concurrent_foreign_key 。この方法では、ダウンタイムは必要ありません。

外部キーの削除

このオペレーションにはダウンタイムは必要ありません。

integer プライマリキーのマイグレーションbigint

integer プライマリ・キー(PK)を持ついくつかのテーブルのオーバーフロー・リスクを防ぐために、それらの PK をbigintにマイグレーションする必要があります。 ダウンタイムを発生させることなく、データベースに過度の負荷をかけることなく、これを行うプロセスを以下に示します。

コンバージョンを初期化し、既存データのマイグレーションを開始(リリースN)

処理を開始するには、新しいbigint 列を作成するために通常のマイグレーションを追加します。提供されているinitialize_conversion_of_integer_to_bigint ヘルパーを使用してください。このヘルパーは、新しいレコードに対して両方のカラムを同期させるデータベーストリガーも作成します(コード):

# frozen_string_literal: true

class InitializeConversionOfMergeRequestMetricsToBigint < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  TABLE = :merge_request_metrics
  COLUMNS = %i[id]

  def up
    initialize_conversion_of_integer_to_bigint(TABLE, COLUMNS)
  end

  def down
    revert_initialize_conversion_of_integer_to_bigint(TABLE, COLUMNS)
  end
end

新しいbigint カラムは無視します:

# frozen_string_literal: true

class MergeRequest::Metrics < ApplicationRecord
  include IgnorableColumns
  ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22'
end

既存のデータをマイグレーションするために、バッチ化されたバックグラウンドマイグレーション(コード)をエンキューします:

# frozen_string_literal: true

class BackfillMergeRequestMetricsForBigintConversion < Gitlab::Database::Migration[2.1]
  restrict_gitlab_migration gitlab_schema: :gitlab_main

  TABLE = :merge_request_metrics
  COLUMNS = %i[id]

  def up
    backfill_conversion_of_integer_to_bigint(TABLE, COLUMNS, sub_batch_size: 200)
  end

  def down
    revert_backfill_conversion_of_integer_to_bigint(TABLE, COLUMNS)
  end
end

バックグラウンドマイグレーションを監視

マイグレーションが実行中にどのように実行されているかをチェックします。これには複数の方法があります。

バッチバックグラウンドマイグレーションのハイレベルステータス

バッチバックグラウンドマイグレーションのステータスを確認する方法を参照してください。

データベースへのクエリ

関連するデータベーステーブルを直接クエリできます。読み込み専用のレプリカへのアクセスが必要です。クエリの例:

-- Get details for batched background migration for given table
SELECT * FROM batched_background_migrations WHERE table_name = 'namespaces'\gx

-- Get count of batched background migration jobs by status for given table
SELECT
  batched_background_migrations.id, batched_background_migration_jobs.status, COUNT(*)
FROM
  batched_background_migrations
  JOIN batched_background_migration_jobs ON batched_background_migrations.id = batched_background_migration_jobs.batched_background_migration_id
WHERE
  table_name = 'namespaces'
GROUP BY
  batched_background_migrations.id, batched_background_migration_jobs.status;

-- Batched background migration progress for given table (based on estimated total number of tuples)
SELECT
  m.table_name,
  LEAST(100 * sum(j.batch_size) / pg_class.reltuples, 100) AS percentage_complete
FROM
  batched_background_migrations m
  JOIN batched_background_migration_jobs j ON j.batched_background_migration_id = m.id
  JOIN pg_class ON pg_class.relname = m.table_name
WHERE
  j.status = 3 AND m.table_name = 'namespaces'
GROUP BY m.id, pg_class.reltuples;

Sidekiqログ

Sidekiqログを使用して、バッチバックグラウンドマイグレーションを実行するワーカーを監視することもできます:

  1. @gitlab.com のメールアドレスでKibana にサインインします。
  2. インデックスパターンをpubsub-sidekiq-inf-gprd* に変更します。
  3. json.queue: cronjob:database_batched_background_migration のフィルタを追加。

PostgreSQL の遅いクエリログ

低速クエリログは、実行に1秒以上かかった低速クエリを記録します。バッチバックグラウンドマイグレーションでこれらを見るには、以下のようにしてください:

  1. @gitlab.com のメールアドレスでKibana にサインインします。
  2. インデックスパターンをpubsub-postgres-inf-gprd* に変更します。
  3. json.endpoint_id.keyword: Database::BatchedBackgroundMigrationWorker のフィルタを追加。
  4. オプション。更新情報のみを表示するには、json.command_tag.keyword: UPDATE のフィルタを追加します。
  5. オプション。失敗したステートメントのみを表示するには、json.error_severity.keyword: ERROR のフィルタを追加します。
  6. オプション。テーブル名によるフィルタを追加します。

Grafana ダッシュボード

データベースの健全性を監視するには、以下の追加メトリクスを使用します:

  • PostgreSQLのタプル統計: 活発に変換されているテーブルの更新率が高い場合や、このテーブルのデッドタプルの割合が増加している場合は、autovacuum
  • PostgreSQLの概要:プライマリデータベースサーバで高いシステム使用率や1秒あたりのトランザクション数((TPS) )が表示された場合、マイグレーションが問題を引き起こしている可能性があります。

Prometheusメトリクス

各バッチバックグラウンドマイグレーションのメトリクス数がPrometheusに公開されます。これらのメトリクスは、Thanosで検索および可視化できます(例を参照)。

列の入れ替え(リリースN + 1)

バックグラウンドが完了し、すべてのレコードに新しいbigint 列が入力されたら、列を入れ替えることができます。スワップはデプロイ後のマイグレーションで行われます。正確なプロセスは変換されるテーブルによって異なりますが、一般的には以下の手順で行われます:

  1. 提供されているensure_batched_background_migration_is_finished ヘルパーを使用して、バッチマイグレーションが終了したことを確認します(例を参照)。マイグレーションが完了していない場合、後続のステップは失敗します。事前に確認することで、より役立つエラーメッセージを表示することができます。
  2. integer 列を使用する既存のインデックスと一致するbigint 列を使用するインデックスを作成します例を参照)。
  3. integer 列を使用して既存の FK と一致するbigint 列を使用して外部キー(FK) を作成します。これは、他のテーブルを参照するFKと、マイグレーション対象のテーブルを参照するFKの両方に対して行います例を参照)。
  4. トランザクション内で、列を入れ替えます:
    1. 関係するテーブルをロックします。デッドロックの可能性を減らすため、親から子の順番で行うことを推奨します(例を参照)。
    2. カラムの名前を入れ替えます例を参照)。
    3. トリガー関数をリセットします
    4. デフォルトを入れ替えます
    5. PK制約(もしあれば)を入れ替えます例を参照)。
    6. 古いインデックスを削除し、新しいインデックスの名前を変更します例を参照)。
    7. 古い外部キー (もし存在すれば) を削除し、新しい外部キーの名前を変更します(例を参照ください)。

マージリクエストの例とマイグレーションを参照してください。

トリガと古いinteger 列を削除します(リリースN + 2)。

デプロイ後のマイグレーションと提供されているcleanup_conversion_of_integer_to_bigint ヘルパーを使用して、データベーストリガと古いinteger 列を削除します(例を参照してください)。

無視ルールの削除(リリースN + 3)

カラムが削除された後の次のリリースでは、無視ルールはもう必要ないので削除してください(例を参照してください)。

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

データのマイグレーションは厄介です。通常、データのマイグレーションは3つのステップで行われます:

  1. 最初のデータバッチのマイグレーション
  2. アプリケーションコードのデプロイ
  3. 残りのデータのマイグレーション

通常はこれでうまくいきますが、常にうまくいくわけではありません。例えば、フィールドのフォーマットがJSONから他のものに変更される場合、ちょっとした問題が発生します。アプリケーションコードをデプロイする前に既存のデータを変更すると、ほとんどの場合エラーに遭遇するでしょう。一方、アプリケーションコードをデプロイした後にマイグレーションを行うと、同じ問題に遭遇する可能性があります。

単に無効なデータを修正するだけであれば、デプロイ後のマイグレーションで十分です。データの形式を変更する必要がある場合(たとえば、JSONから他のものへ)、通常は新しいデータ形式のために新しいカラムを追加し、アプリケーションにそれを使用させるのが最善です。このような場合、手順は次のようになります:

  1. 新しいフォーマットで新しいカラムを追加します。
  2. 既存のデータをこの新しいカラムにコピーします。
  3. アプリケーションコードのデプロイ
  4. デプロイ後のマイグレーションで、残っているデータをコピーします。

一般的に、万能な解決策はありません。したがって、可能な限り最善の方法で実装されるように、マージリクエストでこの種のマイグレーションについて議論するのが最善です。