Ruby
MySQL
DB

本番環境のデータを別環境に差分コピーするRuby製ツールを作りました

本番環境のデータを別環境に差分コピーする、Gamma というRuby製のツールを作りました。

背景

  • 本番と同等のデータで別環境(Staging/Dev環境) でもテストを行いたい
  • 本番データはS3 と RDS(mysql) に存在している
  • 本番にはセンシティブな情報が含まれているため、データマスクを施した上で別環境にコピーする必要がある
  • S3は本番とStaging/Dev環境で別バケットを利用している
  • S3のデータは単純に別バケットに全コピーするだけでは動かない

特にS3のデータの扱いが厄介でした。S3のデータは当初バケットのデータを全コピーして開発環境のS3に移すつもりでした。
しかし、私が今関わっているプロジェクトだとS3のデータの扱いが厄介でした。
具体的にはファイルアップロードに Paperclip の hash を使っている(かつ、本番と開発環境ではhash secretが異なる)関係で、本番環境と開発環境では全く同じデータを使ってもデータのpathが変わってしまうという、とてもめんどくさい問題に直面してました。

よって、データを本番環境から開発環境にコピーするタイミングで、S3上のデータを本番S3から開発S3にパスを書き換えてコピーする必要がありました。

既存ツールの調査

mysqldump

手軽に本番データを開発環境に入れる方法としては、本番データをmysqldumpして開発環境にimportする方法があります。
しかし、

  • データマスクは別スクリプトで実施する必要がある
  • S3のデータは別スクリプトでコピーする方法を考えないといけない

という問題があり、どうやってマスクしたらいいのかとか、S3のデータは毎回全コピーし直すわけにはいかないので差分コピーをどうやってやろうとか、色々考えることが多すぎてこの手は諦めました。

S3のバケットは本番を見る かつ Paperclip Hash Secret も本番のものを使うということも考えました。
しかし、開発環境で常に本番に接続するのはうっかりミスで本番データを壊す可能性もあり危険なので、この方針も諦めました。

Embulk

Embulk を使うと、本番環境から別環境に手軽にデータをコピーできそうです。
embulk-filter-mask 等のプラグインを利用すれば、データのマスクも実施できます。

しかし、

  • S3のデータを差分コピーする方法を別途考えないといけない
    • embulkのプラグインを別途作る?

など、やはりS3のデータコピーの方法は考えなければいけませんでした。

また、マスクスクリプトやコピースクリプトはRubyで柔軟に書きたいという思いもありました。

ツールに求める要件

本番データをコピーする際にやりたいことは以下のとおりです。

  • 差分コピーできる
  • レコードをマスクしてコピーできる
  • レコードコピーのタイミングでフックを掛けて別スクリプトを呼び出せる
  • (マスク用のスクリプトはRubyで書きたい)

Gammaの紹介

DBのレコードを差分コピーしつつ、行コピーのタイミングでフックを掛けられる、Gamma
というツールを作りました。
フックスクリプト内で値をマスクしたり、ファイルコピーなどの処理ができます。

今はまだmysqlにしか対応してません。また、同期するテーブルにはidというカラムが無いと動かないです。
(※ これらの点は今後改修が必要と認識しています)

基本的な使い方

まずはテーブルを単純に全コピーする方法を紹介します。

gammaのインストールは以下コマンドで行えます。

gem install gamma

次にmysqlに以下テーブルを用意します。DBはコピー元とコピー先の両方を用意しておきます。

CREATE TABLE `ar_internal_metadata` (
  `key` varchar(255) NOT NULL,
  `value` varchar(255) DEFAULT NULL,
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  PRIMARY KEY (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `products` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `image_path` varchar(512) DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

CREATE TABLE `users` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `email` varchar(255) DEFAULT NULL,
  `encrypted_password` varchar(255) DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  `updated_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

次にgammaの設定ファイル settings.yml を用意し、コピー元DBとコピー先DBの設定をします。

# settings.yml

in_database_config:
  adapter: mysql2
  encoding: utf8
  database: gamma_production
  pool: 5
  host: localhost
  username: root
  password:
out_database_config:
  adapter: mysql2
  encoding: utf8
  database: gamma_development
  pool: 5
  host: localhost
  username: root
  password:

次に、どのテーブルをどのようにコピーするかを設定したファイル data.yml を用意します。

# data.yml

- data:
    table:
      - "*"
    table_without:
      - "ar_internal_metadata"
    mode: "replace"
    delta_column: "updated_at"

table に * 指定をすると、DBにある全てのテーブルが対象となります。
その場合、table_without にコピーしたくないテーブル名を記載することが可能です。
今回は products と users テーブルが同期されることとなります。

mode は 今のところ replace しか対応していません。これはいわゆるupsert(レコードがなければ新規作成、存在すれば更新)となります。
delta_column は差分コピーが必要かを判定するカラムを指定します。updated_at の値がコピー元先で異なる場合に差分コピーが走ります。

まずは gamma dryrun を走らせてみます。Dryrun することで、実際にどのように同期されるかのSQLが出力されます。

gamma dryrun --settings settings.yml --data data.yml

実行結果は以下のようになります。

I, [2018-03-11T19:02:54.482910 #81194]  INFO -- : [replace] Sync Start products
I, [2018-03-11T19:02:54.612671 #81194]  INFO -- : DRYRUN: INSERT INTO products(`id`,`name`,`image_path`,`created_at`,`updated_at`) VALUES ("9","production_product_8","/test/image/8.png","2018-03-11 06:13:26","2018-03-11 06:13:26")

I, [2018-03-11T19:02:55.518998 #81194]  INFO -- : [replace] Sync Start users
I, [2018-03-11T19:02:55.633015 #81194]  INFO -- : DRYRUN: UPDATE `users` SET `id` = "12",`email` = "test_email_11@example.com",`encrypted_password` = "6512bd43d9caa6e02c990b0a82652dca",`created_at` = "2018-03-11 06:13:23",`updated_at` = "2018-03-11 06:13:23" WHERE id = 12

I, [2018-03-11T19:02:55.633129 #81194]  INFO -- : DRYRUN: INSERT INTO users(`id`,`email`,`encrypted_password`,`created_at`,`updated_at`) VALUES ("14","test_email_13@example.com","c51ce410c124a10e0db5e4b97fc2af39","2018-03-11 06:13:23","2018-03-11 06:13:23")

実際にデータをコピーするには gamma apply を利用します。

gamma apply --settings settings.yml --data data.yml
I, [2018-03-11T18:42:44.713544 #72090]  INFO -- : [replace] Sync Start products
I, [2018-03-11T18:42:45.722601 #72090]  INFO -- : [replace] Sync Start users

レコードコピーの際にフックをかける

レコードをコピーする際に、特定のカラムの値を書き換えたり、別スクリプトを走らせたりするhookをかけることができます。

以下のような data.yml を用意します。

- data:
    table:
      - "products"
    mode: "replace"
    delta_column: "updated_at"
    hooks:
      - row:
          scripts:
            - "hooks/copy_image.rb"
- data:
    table:
      - "users"
    mode: "replace"
    delta_column: "updated_at"
    hooks:
      - column:
          name:
            - "email"
          scripts:
            - "hooks/mask_email.rb"

hook用のスクリプトを以下2つ用意します。

# hooks/copy_image.rb

# hooks の row指定をすると、特定行全てが含まれた値がhookスクリプトに渡されます
# apply は dryrunのときには false となります
# record には DBのレコードが入っています

class CopyImage
  def execute(apply, record)
    if apply
      # ここに画像コピーする処理を記載する
      puts "Copy Image. path: #{record["image_path"]}"
    else
      puts "[DRYRUN] Copy Image: #{record}"
    end

    record
  end
end
# hooks/mask_email.rb

# hooks の column 指定をすると、対象行の column名 と value がhookスクリプトに渡されます
# apply は dryrunのときには false となります

class MaskEmail
  def execute(apply, column, value)
    result = value.split("@")[0]
    result = "#{result}@example.com"

    unless apply
      puts "[DRYRUN] #{column} #{value}"
    end

    result
  end
end

この状態で dryrun してみます。

gamma dryrun --settings settings.yml --data data.yml

実行結果は以下のとおりです。

I, [2018-03-11T18:51:35.611121 #76155]  INFO -- : [replace] Sync Start products
[DRYRUN] Copy Image: {"id"=>686, "name"=>"production_product_685", "image_path"=>"/test/image/685.png", "created_at"=>2018-03-11 06:13:27 +0900, "updated_at"=>2018-03-11 02:13:27 +0900}
I, [2018-03-11T18:51:35.726459 #76155]  INFO -- : DRYRUN: UPDATE `products` SET `id` = "686",`name` = "production_product_685",`image_path` = "/test/image/685.png",`created_at` = "2018-03-11 06:13:27",`updated_at` = "2018-03-11 02:13:27" WHERE id = 686

I, [2018-03-11T18:51:36.618468 #76155]  INFO -- : [replace] Sync Start users
[DRYRUN] email test_email_0@example.com
I, [2018-03-11T18:51:36.739384 #76155]  INFO -- : DRYRUN: UPDATE `users` SET `id` = "1",`email` = "test_email_0@example.com",`encrypted_password` = "cfcd208495d565ef66e7dff9f98764da",`created_at` = "2018-03-11 06:13:23",`updated_at` = "2018-03-11 13:13:23" WHERE id = 1

[DRYRUN] email test_email_1@example.com
I, [2018-03-11T18:51:36.739654 #76155]  INFO -- : DRYRUN: UPDATE `users` SET `id` = "2",`email` = "test_email_1@example.com",`encrypted_password` = "c4ca4238a0b923820dcc509a6f75849b",`created_at` = "2018-03-11 06:13:23",`updated_at` = "2018-03-11 13:13:23" WHERE id = 2

productsのレコード同期の際に、hooks/copy_image.rb が実行されています。
また、users.email のレコードは hooks/mask_email.rb にてマスクされた値となっています。

apply すると以下のような実行結果となります。

gamma apply --settings settings.yml --data data.yml
I, [2018-03-11T18:52:32.250442 #76616]  INFO -- : [replace] Sync Start products
Copy Image. path: /test/image/685.png
I, [2018-03-11T18:52:33.268832 #76616]  INFO -- : [replace] Sync Start users

サンプルスクリプト

Gamma-example に動作確認できるサンプルスクリプトを置いています。

まとめ

本番環境のデータを手軽に差分コピーできるツールを作成しました。
コピーのタイミングでフックスクリプトを走らせたり、フック内で値を書き換えられるようなツールを作りました。

まだまだ改善する余地の多いツールですが、ひとまずこのツールで本番データをStaging環境等に手軽にコピーできています。