💿

毎日本番DBをダンプして、ローカルと開発環境で利用して生産性を上げてる話

2024/02/20に公開4

シードデータで動作確認して大丈夫だったのに、本番反映してみたら想定してなかった挙動・エラーが出た😱そんな経験はありませんか。

恥ずかしながら私は今までに何回もありました。機能開発だけじゃなくバッチやマイグレーションなんかでも発生しがちなコレ。またはシードデータで動作確認できても、本番データでも通用するか検証ができないままプルリクを作る、なんていうこともあると思います。今回はこちらを無くす試みをしたお話です。

「もう本番DBで開発しちゃえばいいじゃない」の問題点

この課題を解決するには、極論すると本番DBで開発するしかないのですが、そうなると言うまでもなく以下の問題が出てきます。

  • レビュー通過してないコードが本番に影響を与える
  • トライ&エラーができない
  • 個人情報をはじめとするセンシティブな情報が開発者の端末に漏れる
  • データ量が多すぎてローカルに持ってこれない

しかし言い換えると、これらをクリアしたDBさえ用意できれば良いわけです。

解決策としてポイントインタイムリカバリと、マスキングしてダンプするバッチ処理

ゲームエイトで立ち上げたPLAYZYマイゲームエイトでは、Amazon Aurora(Mysql)を使っており、本番DBを毎朝ポイントインタイムリカバリしてクローンしています。つまりこの時点では本番と開発環境と本番をクローンした3つのDBクラスターが存在しています。そしてそのクローンしたDBに対して、同時に立ち上げたEC2で以下のバッチ処理を行い、ローカルと開発環境で本番DB同等物が利用できるようにしています。(コードは最後に紹介します。)

  • センシティブな情報をマスキングするSQLを流す
  • マスキング後にmysqldumpでdump。このときに重いテーブルは除く
  • ダンプしたSQLファイルをS3にアップロード
  • ダンプしたSQLファイルを開発環境DBに流す
  • クローンされたDBクラスターを削除

これにて開発環境のDBは毎朝最新状態にリセットされ、ローカルはS3からSQLファイルを持ってきて流すスクリプトを用意して実行することでローカルにインポートしています。

動作確認の質が向上するほか、数多くのメリットが発生。一部デメリットも

本番データ同等物をローカルと開発環境で保有できるようになったことで、以下のメリットがありました。

  • 実装時のバグ・不安が減る
  • レビュー時の不安が減る
  • エラーが起きた実際のユーザーやコンテンツなどで直接動作確認ができ、対応スピードが上がる
  • 社内展開する開発環境の本番との乖離が(ほぼ)なくなる
  • Github Actionsをghコマンドでdispatchすることで、エラーが起きたデータが生成された直後でもクローンして対応可能

デメリットは以下のとおりです。

  • シードデータをメンテするメリットが小さくなり、質やモチベが下がる
  • マスキングしたデータはかえってデータの多様性を下げることになりがち
  • テーブルを外すのでなく減らすしかないとなると、処理工数が膨れがち
  • 画像などDB外のコンテンツの参照など、DBを入れ替えると参照できなくなるロジックがあると、初期に対応が必要になる
  • terraformで実現してますが、今のところGithub Actions上でapplyしてるため、DBクラスターの作成で課金がかかる(毎朝30分くらい)

デメリットはあるものの、乗り越えたら得られるメリットの方が圧倒的に大きいと思っています。ゲームエイトの前職から導入した手法ですが、現在担当してるプロダクトには全部入れましたし、今後も可能なところでは常に導入していきたいと思っています。

実装はGithub ActionsとTerraform

以下のterraformのコードを毎朝Github Actionsでスケジュールして実行しています。

DBクラスターを作成するブロック

resource "random_string" "mysql_password" {
  length  = 16
  special = false
}

module "restored-prod-rds" {
  source  = "terraform-aws-modules/rds-aurora/aws"
  version = "9.0.0"

  manage_master_user_password = false
  name                        = "restored-prod-temp"
  engine                      = "aurora-mysql"
  engine_version              = "5.7.mysql_aurora.2.11.2"
  instance_class              = "db.t3.small"
  restore_to_point_in_time = {
    source_cluster_identifier  = "prod"
    use_latest_restorable_time = true
  }
  instances = {
    one = {
      promotion_tier = 1
    }
  }
  vpc_id                 = var.vpc_id
  subnets                = var.private_subnets
  create_db_subnet_group = true
  security_group_rules = {
    ex1_ingress = {
      cidr_blocks = []
    }
    ex1_ingress = {
      source_security_group_id = var.default_security_group_id
    }
  }
  security_group_description = "Managed by Terraform"
  skip_final_snapshot        = true
  storage_encrypted          = true
  monitoring_interval        = 60
  deletion_protection        = false
  autoscaling_enabled        = false
  publicly_accessible        = false
  master_password            = random_string.mysql_password.result

  db_parameter_group_name         = var.parameters.name
  db_cluster_parameter_group_name = var.cluster_parameters.name
  ca_cert_identifier              = "rds-ca-rsa2048-g1"
}

DBクラスターの削除とS3へのダンプファイルがアップロードできるように、立ち上げるEC2の権限を設定

resource "aws_iam_role" "ec2" {
  name = "restored-rds-worker-role"
  assume_role_policy = jsonencode({
    "Version" : "2012-10-17"
    "Statement" : [
      {
        "Action" : "sts:AssumeRole"
        "Effect" : "Allow"
        "Principal" : {
          "Service" : ["ec2.amazonaws.com"]
        },
      },
    ]
  })
  inline_policy {
    name = "restored-rds-worker-role-policy"
    policy = jsonencode({
      "Version" : "2012-10-17"
      "Statement" : [
        {
          "Action" : "s3:PutObject"
          "Effect" : "Allow"
          "Resource" : ["arn:aws:s3:::${var.s3_bucket_id}/database_dump.sql"]
        },
        {
          "Effect" : "Allow",
          "Action" : [
            "rds:DeleteDBCluster",
            "rds:DescribeDBClusters",
            "rds:DeleteDBInstance",
          ],
          "Resource" : [module.restored-prod-rds.cluster_arn, module.restored-prod-rds.cluster_instances["one"].arn]
        }
      ]
    })
  }
  managed_policy_arns = [
    "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" # デバッグ用にSSH接続できるように
  ]
}

resource "aws_iam_instance_profile" "this" {
  name = "restored-rds-worker-instance-profile"
  role = aws_iam_role.ec2.name
}

マスキング・ダンプ・アップロード・インポートを行うEC2本体

起動時に実行されるスクリプトとしてuser dataを渡しています。


data "aws_ssm_parameter" "latest_amazonlinux_2023" {
  # aws ssm get-parameters --names /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64
  # すると得られる"ami-012261b9035f8f938"はconsoleから立ち上げ時のデフォと一致する

  # https://aws.amazon.com/jp/blogs/news/amazon-linux-2023-a-cloud-optimized-linux-distribution-with-long-term-support/より
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}

locals {
  user_data = <<-EOF
#!/bin/bash
# mysqlクライアントのインストール
sudo yum update -y
sudo rpm -Uvh https://dev.mysql.com/get/mysql80-community-release-el9-5.noarch.rpm
sudo yum install -y mysql-community-client

# 疎通確認。DBインスタンスの作成完了直後にEC2が立ち上がるので一応
while ! mysqladmin ping -h ${module.restored-prod-rds.cluster_endpoint} --silent; do
  sleep 1
done

# マスキング処理
MYSQL_PWD=${random_string.mysql_password.result} mysql -h ${module.restored-prod-rds.cluster_endpoint} --user=root -e "UPDATE payout_users SET ${join(", ", [
  "name = CONCAT('山田花子', id)",
  "address = CONCAT('東京都渋谷区東1-1-', id)",
  "email = CONCAT('user', id, '@example.com')",
  "postal_code = CONCAT('1', LPAD(id, 6, '0'))",
  "tel = CONCAT('080', LPAD(id, 8, '0'))",
])};" production

# ダンプ
MYSQL_PWD=${random_string.mysql_password.result} mysqldump -h ${module.restored-prod-rds.cluster_endpoint} --user=root --set-gtid-purged=OFF production --ignore-table=production.versions --ignore-table=production.sessions --add-drop-database > /tmp/database_dump.sql

# S3にアップロード
aws s3 cp /tmp/database_dump.sql s3://${var.s3_bucket_id}/database_dump.sql

# ダンプファイルを開発環境DBにインポート
cat /tmp/database_dump.sql | MYSQL_PWD=${var.rds_password} mysql -h ${var.cluster_endpoint} --user=root production

# CIで削除するとCI実行時間(つまり課金)が大きくなるので、削除はAWS CLIで行う
aws rds delete-db-instance --db-instance-identifier ${module.restored-prod-rds.cluster_instances["one"].id}
aws rds delete-db-cluster --skip-final-snapshot --delete-automated-backups \
    --db-cluster-identifier ${module.restored-prod-rds.cluster_id}
EOF
}

resource "aws_instance" "this" {
  depends_on                  = [module.restored-prod-rds]
  ami                         = data.aws_ssm_parameter.latest_amazonlinux_2023.value
  user_data                   = base64encode(local.user_data)
  vpc_security_group_ids      = [var.default_security_group_id]
  subnet_id                   = var.private_subnets[0]
  instance_type               = "t3.nano"
  iam_instance_profile        = aws_iam_instance_profile.this.name
  associate_public_ip_address = true
  tags = {
    Name = "restored-rds-worker-instance"
  }
}

Terraformを実行するGithub Actions

Github Actionsではリソースのapplyとdestroyをそれぞれ別でスケジュールしていて、1時間後にdestroyしてEC2も削除しています。ここはデータ量で時間間隔や実行頻度が調整されるべきところです。
使っているterraformコマンドはapplyとdestroyではなく両方applyであり、環境変数でモジュールの作成を条件分岐してリソースのapply/destroyを制御しています。

なお、エラーの原因となるデータが本番で生まれたばかりとなると今朝のデータには残ってないので、
ghコマンドを叩いてリアルタイムでS3にダンプデータを吐き出させることもできます。

gh workflow run update-dev-rds.yml -f step=restoreでDB、EC2を作り、反映後にはgh workflow run update-dev-rds.yml -f step=removeで削除しています。

name: Update dev RDS

on:
  # gh workflow run update-dev-rds.yml -f step=restore
  workflow_dispatch:
    inputs:
      step:
        required: false
        default: "remove"
  schedule:
    - cron: "47 21 * * *" # 6:47am restore
    - cron: "47 22 * * *" # 7:47am remove restored

jobs:
  terraform:
    runs-on: ubuntu-latest
    env:
      APPLY_RESTORED_PROD_RDS: ${{ (github.event.schedule == '47 21 * * *' || github.event.inputs.step == 'restore') && 'yes' || 'no' }}
    steps:
      - uses: actions/checkout@v3
      - name: Setup Terraform
        uses: ./.github/actions/setup-terraform # 詳細は省略
      - name: Fetch AWS credentials
        uses: ./.github/actions/assume-role-with-oidc # 詳細は省略
      - name: Apply
        run: terraform apply -auto-approve

ローカルにインポートするスクリプト

このスクリプトをローカルで何回でも実行することで、データ一括修正などのバッチでも本番データとしての再現性を残しつつ、トライ&エラーが何回でもできるようになります。

#!/usr/bin/env bash

aws s3 cp s3://${BUCKET_NAME}/database_dump.sql database_dump.sql

echo "Importing database_dump.sql..."
cat database_dump.sql | docker-compose exec -T mysql-dev mysql -u root -P 3306 development

ここまでご一読いただき、ありがとうございました!

ゲームエイトテックブログ

Discussion

gekalgekal

よい考え方です。参考になりました。

r-sugir-sugi

パフォーマンス改善系のタスクで本番環境のデータ量あると捗りそう👏とても良い