ECSタスクで1時間おきにRDS Auroraのスナップショットを取得する

f:id:kaminashi-developer:20211123210637j:plain

エンジニアのTakuです。

カミナシではデータ保全のため、RDSのスナップショットを1時間おきに取得し、万が一のデータ破損に備えております。
今回はそのECSタスクで定期的にバッチ処理を実行する方法について共有させていただきたいと思います。

取得理由

カミナシでは150社以上の企業様にご利用いただいており、毎日膨大なレポートが作成されております。(2022年4月現在)

ご参考)↓の14ページ speakerdeck.com

日々膨大な量のデータが増えており、中には監査などの保管が必要なデータもあり、データやDBの破損が発生するとお客様への影響が大きいです。
そのため、万が一の際の障害発生時の影響を少しでも抑えるため、1時間に1回フルバックアップを取得しそれらを最大24時間保持することとしました。

※ポイントタイムリカバリを利用した特定の時点への復元も可能ですが、そちらが破損する事態にも備え、1時間おきに取得することとしております。

取得方式の検討

結論から申し上げますと、カミナシではECSタスクで1時間に1回取得するようにしました。 取得段階では以下3案ありそれぞれ比較・検討しましたので、採用経緯踏まえお話ししたいと思います。

案1) AWS Backup
案2) AWS Lambda
案3) AWS ECSタスク

途中の経過は不要でECSタスクの設定が見たい!という方は 実装 の項目までとばしてください

案1) AWS Backup

比較するにあたり、

  1. 車輪の再開発はしたくない
  2. メンテナンスコストをなるべく抑えたい

ということから、まずはフルマネージドなサービスを利用できないかと考え、まずはAWS Backupの調査を行いました。

docs.aws.amazon.com

調査の結果、

  1. AWS BackupはRDS Auroraに対応
  2. 削除は最小で1日毎
  3. 取得間隔はcronで自由に設定できる

ことから1時間毎にスナップショットを取得するという要件を満たしておりました。

docs.aws.amazon.com

気になるコストに関しても、

  • データ量に応じたスナップショットの料金はRDSのコンソール or CLI等から取得した料金と同じ
  • スナップショットの実行回数で追加料金

のみのようでした。

aws.amazon.com

スナップショットを取得する度に必要となる Audit Manager のコストは 1バックアップ評価あたり 0.00125 USD (※2022/4/18現在) で、
1日1時間ごとに実行した場合(※)でも Lamdba or ECSタスクの実行コストよりは間違いなく低いだろうということで、こちらの実装を進めることにしました。  ※1時間ごとに毎日実行した場合、月単位で 24*31=744 と1 USD 以下となる

設定はコンソールから以下のようなバックアッププランを作成することで簡単に設定ができました。

f:id:kaminashi-developer:20211128155047p:plain

しかし、ここで開発環境で繰り回し検証をしている際に以下のエラーが出て問題が発覚しました。

Backup job XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX could not start because it is either inside or too close to the automated backup window configured in RDS cluster.

f:id:kaminashi-developer:20211128133535p:plain

AWS Backupの仕様で、データ不整合を防ぐためにRDSのシステムバックアップの前後4時間はバックアップ取得ができないようです。

これは、RDS メンテナンス期間または RDS 自動バックアップ期間が近づいているときに発生する可能性があります。AWS Backup では、RDS メンテナンス期間または RDS 自動バックアップ期間の数時間前に行う RDS バックアップは許可されません。RDS データベースのバックアッププランは、RDS メンテナンス期間と RDS 自動バックアップ期間から 4 時間以上空けてスケジュールを組んでいることを確認してください。

aws.amazon.com

Auroraの場合こちらのバックアップを停止することも不可(ポイントタイムリカバリで使うバックアップでもあり不可)であることから、1時間おきの取得と言う要件を満たさなかったため泣く泣く利用を断念しました。。

案2) AWS Lambda

次に考えたのがAWS Lambdaです。
検索するとAWS Lambdaを利用してRDSのスナップショットを1時間おき取得している例は多々あったのですが、バッチ実行にはコンソールから編集が可能なPython or Node.jsを使っているものが主でした。

しかし、弊社ではバックエンド開発にGolangを利用しており、私を含めPython or Node.jsに長けたエンジニアがあまりおりません。

過去にAWS SAMを利用した記事を書かせていただいたとおり、それらを利用してGoでも書けなくはないのですがやはり手間がかかります。

現時点でAWS SAMを本番運用している実績もないことからこの案も見送りとしました。

案3) AWS ECSタスク

最終的に検討した案がECSタスクの利用です。

  1. ECSタスク(Fargate)はプロダクトのAPIサーバー等でも利用しており利用実績がある
  2. DockerFileを用意することで利用が可能
  3. Golangスクリプトを作成できる

ことから、今後他のエンジニアがメンテナンス容易だろうということで導入を決定しました。

実装

RDSスナップショット取得

前述のとおり、RDSスナップショットを取得するスクリプトGolangを利用しました。

CLIAWSからGolang用のものも提供されているため以下を利用

aws.amazon.com

実装する際には pkg.go.devを参照しました。

pkg.go.dev

Golangはこういった公式ドキュメントがしっかりしているのも良いですね。

スナップショットを取得〜削除するものですので、簡易なものとし、以下のようなスクリプトを作成しました。

main.go

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "strings"
    "time"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/rds"
    "github.com/joho/godotenv"
)

type rdsClient struct {
    client *rds.Client
    clusterIdentifier string
    instanceIdentifier string
}

func newRDSClient() *rdsClient {
    clusterIdentifier := os.Getenv("DB_CLUSTER_IDENTIFIER")
    instanceIdentifier := os.Getenv("DB_INSTANCE_IDENTIFIER")
    reagion := os.Getenv("REAGION")

    cfg, err := config.LoadDefaultConfig(context.TODO())
    if err != nil {
        log.Printf("faild load config, %v", err)
    }
    cfg.Region = reagion

    client := rds.NewFromConfig(cfg)

    return &rdsClient{
        client,
        clusterIdentifier,
        instanceIdentifier,
    }
}

func (rc *rdsClient) createRDSSnapShot(startUpTime time.Time) {
    currentDateHour := fmt.Sprint(startUpTime.Year(), "-", int(startUpTime.Month()), "-",startUpTime.Day(), "-", startUpTime.Hour())
    p := rds.CreateDBClusterSnapshotInput{
        DBClusterIdentifier: aws.String(rc.clusterIdentifier),
        DBClusterSnapshotIdentifier: aws.String(rc.clusterIdentifier + "-" + currentDateHour),
    }
    
    result, err := rc.client.CreateDBClusterSnapshot(context.TODO(), &p)
    if err != nil && strings.Contains(err.Error(), "DBClusterSnapshotAlreadyExistsFault: Cannot create the cluster snapshot because one with the identifier") {
        log.Printf("snapshot already exist, %v", err)
    } else if err != nil {
        log.Fatalf("faild describe snapshot, %v", err)
    }
    log.Printf("createRdsSnapShot result, %v", result)
}

func (rc *rdsClient) describeRDSSnapShot() {
    p := rds.DescribeDBClusterSnapshotsInput{
        DBClusterIdentifier: aws.String(rc.clusterIdentifier),
        SnapshotType: aws.String("manual"),
    }
    
    result, err := rc.client.DescribeDBClusterSnapshots(context.TODO(), &p)
    if err != nil {
        log.Fatalf("faild describe snapshot, %v", err)
    }
    for _, snapshot := range result.DBClusterSnapshots{
        log.Printf("describeRDSSnapShot DBClusterSnapshotIdentifier, %v", *snapshot.DBClusterSnapshotIdentifier)
        log.Printf("describeRDSSnapShot SnapshotCreateTime, %v", *snapshot.SnapshotCreateTime)
    }
}

func (rc *rdsClient) deleteRDSSnapShot(startUpTime time.Time) {
    yestadayCurrentDate := startUpTime.AddDate(0, 0, -1)
    yestadayCurrentDateHour := fmt.Sprint(yestadayCurrentDate.Year(), "-", int(yestadayCurrentDate.Month()), "-",yestadayCurrentDate.Day(), "-", yestadayCurrentDate.Hour())
    p := rds.DeleteDBClusterSnapshotInput{
        DBClusterSnapshotIdentifier: aws.String(rc.clusterIdentifier + "-" + yestadayCurrentDateHour),
    }

    result, err := rc.client.DeleteDBClusterSnapshot(context.TODO(), &p)
    if err != nil && strings.Contains(err.Error(), "DBClusterSnapshotNotFoundFault: DBClusterSnapshot not found") {
        log.Printf("snapshot not found, %v", err)
    } else if err != nil {
        log.Fatalf("faild delete snapshot, %v", err)
    } else {
        log.Printf("deleteRDSSnapShot result, %v", result)
    }
}

func main() {
    if os.Getenv("APP_ENV") == "local" {
        err := godotenv.Load(fmt.Sprintf("./%s.env", os.Getenv("GO_ENV")))
        if err != nil {
            log.Printf("faild read env file, %v", err)
        }
    }

    client := newRDSClient()

    locale, _ := time.LoadLocation("Asia/Tokyo")
    startUpTime := time.Now().In(locale)

    client.describeRDSSnapShot()
    client.createRDSSnapShot(startUpTime)
    client.deleteRDSSnapShot(startUpTime)
}

利用している関数は以下のとおりです。

他にもCreateDBSnapshotDeleteDBSnapshotという関数もあったのですが、クラスター化してあるDBに対しては使えなかったのがちょっとしたハマりポイントでした。

api error InvalidParameterValue: The specified instance is a member of a cluster and a snapshot cannot be created directly. Please use the CreateDBClusterSnapshot API instead.

ECSタスクの設定

スクリプトが作成できましたら、こちらをAWSコンソールからECSへ設定していきます。

ECSにはサービスとタスクという概念があるのですが、スケジュールを作成し、バッチ的に実行する場合はサービスの作成は不要です。
クラスターとタスク定義の設定でECSタスクの実行ができます。

サービスとタスクという概念はこちらの記事が参考になりました。 qiita.com

手順は以下のとおりです。

  1. ECRへDockerイメージのアップロード
  2. クラスターの作成
  3. タスク定義の作成
  4. タスクのスケジューリング

1. ECRへDocker Imageのアップロード

まずは作成したDockerのImageをECRへアップロードします

可能であればGIthub等と連携したCI/CDを利用したいですが、もしなくてもECRのコンソールに記載のコマンドを実行することでBuild&Uploadすることができます。

f:id:kaminashi-developer:20220416174947p:plain
ECRへのプッシュコマンドの場所

2. クラスターの作成

コンソールからクラスターの作成を行います。

こちらはECSタスク(Fargate)を利用するのであれば「ネットワーキングのみ」でよく、名前を作成したいものに即したものを設定すれば問題ございません。

f:id:kaminashi-developer:20211128140807p:plain

f:id:kaminashi-developer:20211128140910p:plain ※CloudWatch Container Insightsについては必要に応じて有効にしていただければ良いですが、ここでは割愛します。

3. タスク定義の作成

続いて、1でアップしたImageを利用して、タスクの定義を作成します。
設定は以下のとおりです。

f:id:kaminashi-developer:20211128142804p:plain

ポイントになってくるのは「タスクロール」と「コンテナの定義」です。

ECSタスクからRDSのスナップショットを取得するには、必要な権限を持つIAMロールを付与してあげる必要があります。

AWSServiceRoleForRDSのようなフルでDBのアクセス権限を持つIAMを付与することで実行はできると思いますが、今回はSnapShotへの取得・作成・削除権限があれば良いため、それらの権限だけを持つポリシー→ロールを作成して「タスクロール」として付与すると良いかと思います。

今回は以下のような権限を持つポリシーを作成しました。

  • DescribeDBClusterSnapshots
  • CreateDBClusterSnapshot
  • DeleteDBClusterSnapshot

あとは「コンテナの定義」ですが、1でアップしたImageを選択し、環境変数をコンテナに渡してあげることができるため、取得対象のクラスター名などはこちらで定義します。

f:id:kaminashi-developer:20211128143414p:plain f:id:kaminashi-developer:20211128143531p:plain

4. タスクのスケジューリング

ここまで来るとあとはスケジューリングのみです。

スケジューリングタブは Amazon EventBridge から設定を行います。 docs.aws.amazon.com

Amazon EventBridge昨年末にECSのコンソールが新しくなりましたので、旧コンソールの場合は「タスクのスケジューリング」タブから作成することも可能です。

必要になってくるのは、3で作成したタスク定義と、スケジュールルール(スケジュール式) です。

f:id:kaminashi-developer:20211128151902p:plain f:id:kaminashi-developer:20211128151915p:plain

※キャプチャを取り直したため「ルールを編集」となっておりますが、記載項目は新規作成と同じです

スケジュールルールは1時間おきなど定型的なものであれば「固定された間隔で実行」で設定しても良いですし、Cron式の利用も可能です。

検証中、DBの利用状況にもよるのか、以下のエラーが発生してSnapShotの取得バッチが失敗することがありました。

20XX/MM/DD XX:XX:XX faild describe snapshot, operation error RDS: DescribeDBClusterSnapshots, exceeded maximum number of attempts, 3, https response error StatusCode: 0, RequestID: , request send failed, Post "https://rds.ap-northeast-1.amazonaws.com/": dial tcp 54.239.96.231:443: i/o timeout

そのため、カミナシでは10分おき(開発は15分間隔)実行しており、Cron式で以下のように書いております。

cron(0/10 * * * ? *)

タイミングによっては均等な1時間ごとにならない場合があるのですが、RDSのコンソールを見てスナップショットが定期的に取得できていることが確認できればOKです。

f:id:kaminashi-developer:20211128152755p:plain

終わりに

前置きが少し長くなってしまいましたが、ECSにて定期的にRDSのスナップショットを取得する例でした。

今回はRDSのスナップショットでしたが、ECSタスクを利用することで定期的なバッチ処置を行うことができます。
カミナシでも他の様々なバッチ処理に利用しているため、今回の木々が定期的なバッチ処理を行う際の参考になれば幸いです。

また、カミナシでは新たなサービスの追加に加え、増え続けるユーザー様へ安定した価値を提供するために今回のようなサービス安定化からユーザー体験の向上に取り組んでおります。

  • 「ノンデスクワーカー向けのシステム開発ってどんな感じ?」
  • 「成長を続けるサービスに早いフェーズから携わりたい」

など、カミナシの業務に少しでも興味ある方がいらっしゃいましたら、以下採用ページからお気軽にご応募いただけると幸いです。 careers.kaminashi.jp

最後までご覧いただきありがとうございました。