日時でDBの中身をきれいにするDevOps

# はじめに

年月が経つにつれ、開発環境に入っているレコードは汚くなっていきます。
結果として、取得したい内容によっては想定しない値が紐づくレコードも取得され、開発環境では検証にならないケースが出てきてしまいます。
開発環境にはデータが存在しなかったために、検証時にコーナーケースを拾えず不具合になってしまうこともあるかもしれません。
また、そのような開発環境のためにE2Eテストの自動化に悩む例もあるでしょう。

そのような問題に対してのソリューションとして、開発環境を日時で作り直すことが考えられます。
今回はAWS環境に構築されたAurora RDSに対して、CodeBuildを動かして日時で開発環境を作り直す方法を紹介します。

# 要件

- AWS環境
- Aurora RDSにレコードがあり、これをきれいにしたい
  - MySQL
- 日時でDBの中身をきれいにしたい
- DBにはテーブルの作成と初期値の投入もしておいてほしい

# 完成像

- 日時でCodeBuildが動作し、下記処理が流れる
  - DBが一度破壊され作り直される
  - Djangoマイグレーションでテーブルが作られる
  - Django loaddataで、fixtureで定義している初期データが依存関係を解決しつつ投入される

# ディレクトリ構成

```
.
├─ buildspec.yml
├─ requirements.txt
│
├─infrastructure
│  ├─ cleanup-db-codebuild.yml
│  ├─ common.yml
│  ├─ db.yml
│  │
│  └─params
│     ├─ cleanup-db-codebuild.param.json
│     ├─ common-codebuild.param.json
│     └─ db.param.json
│
├─sql
│  └─ 000_drop_db_and_create_default_db.sql
│
└─src
    ├─ manage.py
    │
    ├─cleanup_develop
    │    ├─ settings.py
    │    └─ ...(Djangoのプロジェクトを立ち上げたときに作られるファイル群)
    │
    └─defaultdb
        ├─ apps.py
        ├─ models.py
        │
        ├─fixtures
        │   ├─ article.json
        │   ├─ tag.json
        │   ├─ user.json
        │   ├─ user_tag_relation.json
        │   └─ __init__.py
        │
        ├─management
        │  │
        │  └─commands
        │      └─ load_fixtures.py
        │
        └─migrations
              └─ ...(マイグレーションファイル群)
```

# requirements.txt

```
Django
dj-database-url
mysqlclient
```

:::note info
**おまけ: venvつくるmake**

```
venv:
  python3 -m venv .venv
  cat ./requirements.txt | grep -v mysql | xargs ./.venv/bin/pip install
```
:::

# Django

モデル定義、初期データ定義、初期データ投入プログラムの作成を行います。
ここでのやっていることは下記記事のDjango版になります。

https://qiita.com/masayasviel/items/10a57f8498533cb15d6e

## テーブル定義

まず最初にテーブル定義を行います
今回はDjangoでモデルを定義して、このモデル定義からマイグレーションを当てることでテーブルを作ります。
Djangoはマイグレーションファイルを作成 ([makemigrations](https://docs.djangoproject.com/ja/5.2/ref/django-admin/#django-admin-makemigrations)) して、それをもとにDBへマイグレーションを行います ([migrate](https://docs.djangoproject.com/ja/5.2/ref/django-admin/#django-admin-migrate)) 。

`src/defaultdb/models.py` にモデル定義を記載します。

```python
from django.db import models

# Create your models here.

class User(models.Model):
    id = models.AutoField(primary_key=True)
    created_at = models.DateTimeField(auto_now_add=True)
    name = models.CharField(max_length=256)
    code = models.CharField(max_length=256, unique=True)

    class Meta:
        db_table = "user"


class Tag(models.Model):
    id = models.AutoField(primary_key=True)
    created_at = models.DateTimeField(auto_now_add=True)
    created_by = models.ForeignKey(User, on_delete=models.CASCADE)
    name = models.CharField(max_length=256, unique=True)
    is_official = models.BooleanField(default=False)

    class Meta:
        db_table = "tag"


class UserTagRelation(models.Model):
    id = models.AutoField(primary_key=True)
    created_at = models.DateTimeField(auto_now_add=True)
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    tag = models.ForeignKey(Tag, on_delete=models.CASCADE)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=['user', 'tag'], name='user_and_tag_uniq'),
        ]
        db_table = "user_tag_relation"


class Article(models.Model):
    id = models.AutoField(primary_key=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    title = models.CharField(max_length=256)
    article_path = models.TextField()
    user = models.ForeignKey(User, on_delete=models.CASCADE)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=['user', 'title'], name='article_title_and_user_uniq'),
        ]
        db_table = "article"


class ArticleTagRelation(models.Model):
    id = models.AutoField(primary_key=True)
    created_at = models.DateTimeField(auto_now_add=True)
    article = models.ForeignKey(Article, on_delete=models.CASCADE)
    tag = models.ForeignKey(Tag, on_delete=models.CASCADE)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=['article', 'tag'], name='article_and_tag_uniq'),
        ]
        db_table = "article_tag_relation"
```

### マイグレーションファイルの作成

[makemigrations](https://docs.djangoproject.com/ja/5.2/ref/django-admin/#django-admin-makemigrations)を用いて、マイグレーションファイルを作成します。

```sh
python manage.py makemigrations defaultdb
```

## fixtures

初期データを作成します。
Djangoでは、[loaddata](https://docs.djangoproject.com/ja/5.2/ref/django-admin/#django-admin-loaddata)を用いることで、指定形式のデータをコマンドで投入することができます。

https://docs.djangoproject.com/ja/5.2/howto/initial-data/

下記の通り `src/defaultdb/fixtures/` 配下にfixtureファイルを作成します。

<details><summary>article.json</summary>

```json
[
  {
    "model": "defaultdb.Article",
    "pk": 1,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "updated_at": "1970-01-01T09:00:00",
      "title": "はじめまして",
      "article_path": "path/to/initial",
      "user_id": 1
    }
  }
]
```

</details>

<details><summary>tag.json</summary>

```json
[
  {
    "model": "defaultdb.Tag",
    "pk": 1,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "created_by": 1,
      "name": "JavaScript",
      "is_official": true
    }
  },
  {
    "model": "defaultdb.Tag",
    "pk": 2,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "created_by": 1,
      "name": "TypeScript",
      "is_official": true
    }
  },
  {
    "model": "defaultdb.Tag",
    "pk": 3,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "created_by": 1,
      "name": "CoffeeScript",
      "is_official": true
    }
  },
  {
    "model": "defaultdb.Tag",
    "pk": 4,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "created_by": 1,
      "name": "React",
      "is_official": true
    }
  },
  {
    "model": "defaultdb.Tag",
    "pk": 5,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "created_by": 1,
      "name": "Angular",
      "is_official": true
    }
  },
  {
    "model": "defaultdb.Tag",
    "pk": 6,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "created_by": 1,
      "name": "Vue",
      "is_official": true
    }
  },
  {
    "model": "defaultdb.Tag",
    "pk": 7,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "created_by": 1,
      "name": "AWS",
      "is_official": true
    }
  },
  {
    "model": "defaultdb.Tag",
    "pk": 8,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "created_by": 1,
      "name": "AWS lambda",
      "is_official": true
    }
  },
  {
    "model": "defaultdb.Tag",
    "pk": 9,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "created_by": 1,
      "name": "EC2",
      "is_official": true
    }
  },
  {
    "model": "defaultdb.Tag",
    "pk": 10,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "created_by": 1,
      "name": "S3",
      "is_official": true
    }
  }
]
```

</details>

<details><summary>user.json</summary>

```json
[
  {
    "model": "defaultdb.User",
    "pk": 1,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "name": "管理者",
      "code": "admin"
    }
  },
  {
    "model": "defaultdb.User",
    "pk": 2,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "name": "フロント",
      "code": "front"
    }
  },
  {
    "model": "defaultdb.User",
    "pk": 3,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "name": "AWS",
      "code": "aws"
    }
  }
]
```

</details>

<details><summary>user_tag_relation.json</summary>

```json
[
  {
    "model": "defaultdb.UserTagRelation",
    "pk": 1,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "user_id": 2,
      "tag_id": 1
    }
  },
  {
    "model": "defaultdb.UserTagRelation",
    "pk": 2,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "user_id": 2,
      "tag_id": 2
    }
  },
  {
    "model": "defaultdb.UserTagRelation",
    "pk": 3,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "user_id": 2,
      "tag_id": 3
    }
  },
  {
    "model": "defaultdb.UserTagRelation",
    "pk": 4,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "user_id": 2,
      "tag_id": 4
    }
  },
  {
    "model": "defaultdb.UserTagRelation",
    "pk": 5,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "user_id": 2,
      "tag_id": 5
    }
  },
  {
    "model": "defaultdb.UserTagRelation",
    "pk": 6,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "user_id": 2,
      "tag_id": 6
    }
  },
  {
    "model": "defaultdb.UserTagRelation",
    "pk": 7,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "user_id": 3,
      "tag_id": 7
    }
  },
  {
    "model": "defaultdb.UserTagRelation",
    "pk": 8,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "user_id": 3,
      "tag_id": 8
    }
  },
  {
    "model": "defaultdb.UserTagRelation",
    "pk": 9,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "user_id": 3,
      "tag_id": 9
    }
  },
  {
    "model": "defaultdb.UserTagRelation",
    "pk": 10,
    "fields": {
      "created_at": "1970-01-01T09:00:00",
      "user_id": 3,
      "tag_id": 10
    }
  }
]
```

</details>

## 依存関係順に投入するためのコマンドを作成

fixtureの投入される順番は下記にある通り、指定順になります。

https://docs.djangoproject.com/ja/5.2/topics/db/fixtures/#fixtures-loading-order

辞書順が依存関係順である保証はないので、トポロジカルソートを用いて、依存関係順に並べ替えて投入します。
依存順に並べ替え、Djangoのloaddataコマンドを叩きます。
そのために、カスタムコマンド `load_fixtures` を作成します。

https://docs.djangoproject.com/ja/5.2/howto/custom-management-commands/

`src/defaultdb/management/commands/load_fixtures.py` に下記プログラムを作成します。

```python
from collections import deque
import os
import pathlib

from django.core import management
from django.core.management.base import BaseCommand
from django.core.management.commands import loaddata
from django.db import connection


PATH = pathlib.Path(__file__).parent.resolve()
QUERY = """SELECT
  TABLE_NAME AS table_name,
  REFERENCED_TABLE_NAME AS reference_table_name
FROM
  information_schema.KEY_COLUMN_USAGE"""


class Command(BaseCommand):
    help = 'dependency_load_fixture'

    def handle(self, *args, **options):
        fixture_files = set()
        for _root, _dirs, files in os.walk(PATH.parent.parent / 'fixtures'):
            for file in files:
                if file.endswith('.json'):
                    fixture_files.add(file.removesuffix('.json'))

        dependency_map: dict[str, set[str]] = dict()
        rows = self._get_table_dependency()
        for row in rows:
            s: set[str] = set()
            if dependency_map.get(row['table_name']):
                s = dependency_map.get(row['table_name'])
            if row['reference_table_name'] is not None:
                s.add(row['reference_table_name'])
            dependency_map[row['table_name']] = s

        sorted_list = self._topological_sort(dependency_map)
        print(sorted_list)

        fixture_target = [
            table_name
            for table_name in sorted_list
            if table_name in fixture_files
        ]

        management.call_command(loaddata.Command(), *fixture_target, verbosity=0)

    def _get_table_dependency(self):
        with connection.cursor() as cursor:
            cursor.execute(QUERY)
            columns = [col[0] for col in cursor.description]
            rows = [dict(zip(columns, row)) for row in cursor.fetchall()]

        return rows

    def _topological_sort(self, dependency_map: dict[str, set[str]]) -> list[str]:
        all_tables = set(dependency_map.keys())
        # { テーブル名: 入次数 }
        in_degree: dict[str, int] = dict()
        # 依存先から依存しているテーブルの一覧を作成
        graph = {table: [] for table in dependency_map.keys()}
        # グラフと入次数の計算
        for table, deps in dependency_map.items():
            in_degree[table] = len(deps)
            for dep in deps:
                graph[dep].append(table)

        # 現時点で入次数が 0 の頂点をすべてキューに追加する
        queue = deque([table for table, degree in in_degree.items() if degree == 0])
        # 結果（トポロジカル順序）を格納する配列
        sorted_list: list[str] = []

        while queue:
            # 入次数が 0 の頂点を 1 つ取り出す
            table = queue.popleft()
            # トポロジカル順序に追加する
            sorted_list.append(table)
            # その頂点から出る各辺について
            # その先の頂点の入次数を減らし、新たに 0 になったらキューに追加する
            for dependent in graph[table]:
                in_degree[dependent] -= 1
                if in_degree[dependent] == 0:
                    queue.append(dependent)

        # 閉路が存在する（頂点をすべて処理できていない）場合
        if len(sorted_list) != len(all_tables):
            closed_circuit = ', '.join([
                table
                for table, degree in in_degree.items()
                if degree > 0
            ])
            raise ValueError(f'{closed_circuit} で閉路を検出')

        return sorted_list
```

繰り返しになりますが、やっていることは下記記事に書かれているものをDjangoに書き換えたものです。

https://qiita.com/masayasviel/items/10a57f8498533cb15d6e

ただしトポロジカルソートについては下記記事の形に直しています。
相変わらずテーブルのリレーションが循環参照する場合には耐えられません。

https://zenn.dev/reputeless/books/standard-cpp-for-competitive-programming/viewer/topological-sort

# SQL

DBをリセットするために、一度DBごと壊して再度作成し直します。
`sql/` 内を指定順で流すために、ファイルの順序に注意する必要があります。

## 000_drop_db_and_create_default_db.sql

とりあえずDBは一つ。
データベース名とユーザに注意しましょう

```sql
-- DROP DB
DROP DATABASE IF EXISTS `my_database`;

-- CREATE DEFAULT DB
CREATE DATABASE IF NOT EXISTS `my_database`;
GRANT ALL ON my_database.* TO 'my_user'@'%';

```

# Infrastructure

下記をCFnで構築していきます。

- `common.yml`
- `db.yml`
- `cleanup-db-codebuild.yml`

## common.yml

VPC周りと共通SGを作成します。
CodeBuildからGitHubへのアクセスをするために、プライベートサブネットを作成します。

https://qiita.com/realtakahashi/items/a171991bdbb6d588ace2

CidrBlockについては下記の推奨範囲を参考に振りました。

https://qiita.com/northprint/items/cd841c519198b7f17014

```yml
AWSTemplateFormatVersion: "2010-09-09"
Description: cleanup dev codebuild common resources

Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsHostnames: true
      EnableDnsSupport: true
      Tags:
        - Key: Name
          Value: codebuild-vpc

  InternetGateway:
    Type: AWS::EC2::InternetGateway

  AttachInternetGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway

  PublicSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.1.0/24
      AvailabilityZone: !Select [ 0, !GetAZs '' ]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: public-subnet-a

  PublicSubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.2.0/24
      AvailabilityZone: !Select [ 1, !GetAZs '' ]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: public-subnet-b

  PrivateSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.3.0/24
      AvailabilityZone: !Select [ 0, !GetAZs '' ]
      Tags:
        - Key: Name
          Value: private-subnet

  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC

  PublicRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway

  PublicSubnetARouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnetA
      RouteTableId: !Ref PublicRouteTable

  PublicSubnetBRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnetB
      RouteTableId: !Ref PublicRouteTable

  NatEIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc

  NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NatEIP.AllocationId
      SubnetId: !Ref PublicSubnetA

  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC

  PrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGateway

  PrivateSubnetRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet
      RouteTableId: !Ref PrivateRouteTable

  CodeBuildSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName:
        !Join [
          '-',
          [
            cleanup,
            dev,
            CodeBuild,
            sg,
          ],
        ]
      GroupDescription:
        !Join [
          '-',
          [
            cleanup,
            dev,
            CodeBuild,
            sg,
          ],
        ]
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - CidrIp: 0.0.0.0/0
          FromPort: 443
          ToPort: 443
          IpProtocol: tcp

Outputs:
  VpcId:
    Description: Cleanup Dev Common CodeBuild VPC ID.
    Value: !Ref VPC
    Export:
      Name: VpcId
  PublicSubnetAId:
    Description: Cleanup Dev Common CodeBuild Public Subnet A ID.
    Value: !Ref PublicSubnetA
    Export:
      Name: PublicSubnetAId
  PublicSubnetBId:
    Description: Cleanup Dev Common CodeBuild Public Subnet B ID.
    Value: !Ref PublicSubnetB
    Export:
      Name: PublicSubnetBId
  PrivateSubnetId:
    Description: Cleanup Dev Common CodeBuild Private Subnet ID.
    Value: !Ref PrivateSubnet
    Export:
      Name: PrivateSubnetId
  CommonCodeBuildSGId:
    Description: Cleanup Dev Common CodeBuild SG.
    Value: !GetAtt CodeBuildSG.GroupId
    Export:
      Name: CommonCodeBuildSGId
```

## db.yml

リードインスタンスを作成したかったので、インスタンスを複数作成しています。
下記特性の通りに `DependsOn` を用いて、ライターインスタンスをコントロールしています。
レプリケーションについても下記の通りAWS側でいい感じに設定されます。

> Aurora プロビジョンド DB クラスターに 2 つめ、3 つめ、と DB インスタンスを作成すると、Aurora が、書き込み DB インスタンスからその他すべての DB インスタンスに、レプリケーションを自動でセットアップします。これらその他の他の DB インスタンスは、読み取り専用で、Aurora レプリカと呼ばれます。また、クラスター内で書き込みと読み取りの DB インスタンスを組み合わせる方法について説明する際は、リーダーインスタンスとも呼ばれます。

https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/AuroraUserGuide/Aurora.Replication.html#Aurora.Replication.Replicas

:::note warn
**パブリックサブネットにDBを配置している理由**

よろしくないのですが、パブリックサブネットにDBを配置しています。
踏み台サーバーの作成を横着して、手元のDBクライアントから直接DBにしたのでパブリックサブネットに配置しています。
:::


:::note info
**パブリックサブネットに配置したDBにローカルのDBクライアントから接続する**

コンソール上から下記を変更します。

- DBに設定しているSGに、3306ポートに自分のIPを許可する
- Auroraの設定からパブリックアクセスを可能にする
:::


```yml
AWSTemplateFormatVersion: "2010-09-09"
Description: cleanup develop database

Parameters:
  DBClusterScalingMaxCapacity:
    Type: Number
    Default: 64
    Description: DB Cluster ServerlessV2ScalingConfiguration MaxCapacity
  DBClusterScalingMinCapacity:
    Type: Number
    Default: 0
    Description: DB Cluster ServerlessV2ScalingConfiguration MinCapacity
  DBName:
    Type: String
    Description: Default DB Name
  DBMasterId:
    Type: String
    Description: DB Master ID
  DBMasterPassword:
    Type: String
    Description: DB Master Password

Resources:
  DBSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: !Join [ "-", [ cleanup, dev, rdb, sg ] ]
      VpcId: !ImportValue VpcId
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 3306
          ToPort: 3306
          SourceSecurityGroupId: !ImportValue CommonCodeBuildSGId
      Tags:
        - Key: Name
          Value: !Join [ "-", [ cleanup, dev, rdb, sg ] ]

  DBSubnetGroup:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: db-subnet
      SubnetIds:
        - !ImportValue PublicSubnetAId
        - !ImportValue PublicSubnetBId

  RDSCluster:
    Type: AWS::RDS::DBCluster
    Properties:
      Engine: aurora-mysql
      EngineVersion: 8.0.mysql_aurora.3.08.0
      ServerlessV2ScalingConfiguration:
        MaxCapacity: !Ref DBClusterScalingMaxCapacity
        MinCapacity: !Ref DBClusterScalingMinCapacity
      StorageEncrypted: true
      VpcSecurityGroupIds:
        - !Ref DBSecurityGroup
      DBSubnetGroupName:
        Ref: DBSubnetGroup
      DatabaseName: !Ref DBName
      MasterUsername: !Ref DBMasterId
      MasterUserPassword: !Ref DBMasterPassword
      DBClusterIdentifier: !Join [ "-", [ cleanup, dev, db ] ]
      Tags:
        - Key: Name
          Value: !Join [ "-", [ cleanup, dev, db ] ]

  DBServerPrimaryInstance:
    Type: AWS::RDS::DBInstance
    Properties:
      Engine: aurora-mysql
      EngineVersion: 8.0.mysql_aurora.3.08.0
      AutoMinorVersionUpgrade: false
      StorageEncrypted: true
      DBInstanceClass: db.serverless
      DBClusterIdentifier: !Ref RDSCluster
      DBSubnetGroupName:
        Ref: DBSubnetGroup
      DBInstanceIdentifier: !Join [ "-", [ cleanup, dev, db, primary ] ]
      Tags:
        - Key: Name
          Value: !Join [ "-", [ cleanup, dev, db, primary ] ]

  DBServerReadReplicaInstance:
    Type: AWS::RDS::DBInstance
    DependsOn: DBServerPrimaryInstance
    Properties:
      Engine: aurora-mysql
      EngineVersion: 8.0.mysql_aurora.3.08.0
      AutoMinorVersionUpgrade: false
      StorageEncrypted: true
      DBInstanceClass: db.serverless
      DBClusterIdentifier: !Ref RDSCluster
      DBSubnetGroupName:
        Ref: DBSubnetGroup
      DBInstanceIdentifier: !Join [ "-", [ cleanup, dev, db, read, replica ] ]
      Tags:
        - Key: Name
          Value: !Join [ "-", [ cleanup, dev, db, read, replica ] ]

Outputs:
  MyRDSHostEndpoint:
    Value: !GetAtt RDSCluster.Endpoint.Address
    Export:
      Name: MyRDSHostEndpoint
```

## cleanup-db-codebuild.yml

ServiceRoleがかなり大雑把に設定しています。
私のGitHub情報をSecretsManagerに保存しているため、SecretsManager policyを入れています。
cronは深夜0時に流したかったけどUTCでの設定だったので、 `cron(0 15 * * ? *)` にしています。

:::note warn
**適宜変更ください**

- Imageは2025/06時点のものになります
- インスタンスサイズが小さいです
:::

```yml
AWSTemplateFormatVersion: "2010-09-09"
Description: cleanup db codebuild resources

Parameters:
  GitHubSourceRepository:
    Type: String
    Description: GitHub Repository Url.
  DBName:
    Type: String
    Description: Default DB Name
  DBMasterId:
    Type: String
    Description: DB Master ID
  DBMasterPassword:
    Type: String
    Description: DB Master Password

Resources:
  ServiceRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Join [ "-", [ cleanup, db, codeBuild, serviceRole] ]
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - codebuild.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: "/"
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/CloudWatchFullAccess"
        - "arn:aws:iam::aws:policy/AWSCodeBuildDeveloperAccess"
        - "arn:aws:iam::aws:policy/AmazonRDSFullAccess"
        - "arn:aws:iam::aws:policy/AmazonEC2FullAccess"
        - "arn:aws:iam::aws:policy/SecretsManagerReadWrite"
        - "arn:aws:iam::aws:policy/AmazonEventBridgeFullAccess"

  CodeBuildProject:
    Type: AWS::CodeBuild::Project
    Properties:
      Name: !Join [ "-", [ cleanup, db, CodeBuild ] ]
      Source:
        Location: !Ref GitHubSourceRepository
        Type: GITHUB
        GitCloneDepth: 1
        BuildSpec: "buildspec.yml"
      SourceVersion: main
      Environment:
        Type: LINUX_CONTAINER
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/standard:7.0
        EnvironmentVariables:
          - Name: MYSQL_HOST
            Value: !ImportValue MyRDSHostEndpoint
          - Name: MYSQL_DATABASE
            Value: !Ref DBName
          - Name: MYSQL_USER
            Value: !Ref DBMasterId
          - Name: MYSQL_PASSWORD
            Value: !Ref DBMasterPassword
      ServiceRole: !GetAtt ServiceRole.Arn
      Artifacts:
        Type: NO_ARTIFACTS
      VpcConfig:
        SecurityGroupIds:
          - !ImportValue CommonCodeBuildSGId
        Subnets:
          - !ImportValue PrivateSubnetId
        VpcId: !ImportValue VpcId

  EventBridgeInvokeRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Join [ "-", [ cleanup, db, event, invoke, role ] ]
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - events.amazonaws.com
            Action: "sts:AssumeRole"
      Path: "/"
      Policies:
        - PolicyName: "AllowInvokeCodeBuild"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - codebuild:StartBuild
                Resource: !GetAtt CodeBuildProject.Arn

  CodeBuildTriggerRule:
    Type: AWS::Events::Rule
    Properties:
      Name: !Join [ "-", [ cleanup, db, codeBuild, trigger, rule ] ]
      ScheduleExpression: "cron(0 15 * * ? *)"
      State: ENABLED
      Targets:
        - Arn: !GetAtt CodeBuildProject.Arn
          Id: "CodeBuildProjectTarget"
          RoleArn: !GetAtt EventBridgeInvokeRole.Arn
```

# buildspec.yml

SQLを順序で流して、Djangoマイグレートしてテーブルを作り直します。
最後にカスタムコマンドで作成した `load_fixtures` を流して完了です。
GitHubにデプロイしてCodeBuildが流れると、DBがきれいになります。

```yml
version: 0.2

phases:
  install:
    runtime-versions:
      python: 3.13
    commands:
      - apt-get update
      - apt-get install -y default-mysql-client
      - pip install -r requirements.txt

  build:
    commands:
      - for sql in $(ls sql/*.sql | sort); do
          echo "Applying $sql";
          mysql -h $MYSQL_HOST -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE < $sql;
        done

      - cd src

      - python manage.py migrate

      - python manage.py load_fixtures
```

# おわりに

ここまでの長文を読んでいただきありがとうございました。
あくまで開発環境をきれいにして検証をしやすくするための一案として捉えていただければと思います。
とはいえ、セキュリティ的にもコスト的にも甘い部分が多いです。
個人的に開発しているため見逃してください。
また、実サービスのDBはここまでシンプルではなく、もっと複雑かと思います。本記事での内容ですと、テーブルのリレーションの循環参照がまず大きな課題かと思います。
一度テーブルの参照をすべて切る、fixtureの投入をトポロジカルソートを行わずSQLの実行のように順序をあらかじめ定義する、など、暫定的な課題解決方法がありそうです。
これら含めて、私の中でまだ課題解決の目星はついていません。一緒に考えましょう。

これが完成すれば、日時で動作させるe2eであったり、検証しやすさの向上といった、本質的なDevOpsの実現をできる気がしています。

以上、現状での日時でDBの中身をきれいにするためのDevOps成果報告でした。
おわりだよー(o・∇・o)