AIエージェントを使ってRDSに関するCloudWatchの費用を50%削減した✨
株式会社tacomsのSREのMorixです!
今回はtacomsのCloudWatchの費用を50%削減した話をしたいと思います。
新しい仕組みの導入をAIエージェントを使って実現したので、仕組みの詳細やAIエージェントのためのプロンプトなどをご紹介できればと思います!
コスト削減効果!!
tacomsのAWSコストの悩み
tacomsはAWSを使用しており、データベースはAWS RDS(Aurora MySQL)を使用しています。
セキュリティや障害・パフォーマンス分析の観点からAuditログの出力を有効化していました。
AuditログはCloudWatch Logsへ出力されますが、それがまぁ高い!
さまざまなAWSのサービスを使用していますが、CloudWatchの費用はその中でもトップレベルに高かったです。
その中でもRDSのログがCloudWatchの費用に占める割合は相当なものでした。
トレードオフを加味し別の方式でログバックアップをすることにした
CloudWatch Logsが高く使いたくないので、RDS内部でファイルとして出力しているログをダウンロードすることにしました。
大まかな内容はクラスメソッド様の次の記事と同様です。(多少アレンジはしてます)
大変参考になった上に「我々もちゃんとやらねば!」と背筋を正されました。本当に感謝です!
この方式を取ることでリアルタイムでのAuditログの確認はしづらくなります。
tacomsの場合、そこにリアルタイム性を求めていなかったため、費用削減を優先させました。
仕組み
冒頭にも記載しましたが、大まかな仕組みは次のとおりです。
2つのLambdaがありますがそれぞれの役目を説明します。
- RDSログファイル一覧取得
- 直近数時間以内に書き込みがされたログファイルパスを取得しその情報をSQSに登録します
- RDSログダウンロード
- SQSに渡されたログファイルをRDSからダウンロードしS3に保存します
これを踏まえ手順の説明をします。
1.定期起動
EventBridgeで30分ごとに「RDSログファイル一覧取得」Lambdaを起動します。
2.前回のログファイル一覧を取得
この処理は定期的に実行されており、前回ダウンロードしたログファイルは無駄なので再ダウンロードしたくありません。
そのため前回確認したログファイルと今回確認するログファイルで内容の差分があればダウンロードするようにしています。
この目的のために前回取得したログファイル一覧情報をS3に保持してあるので、このタイミングでそのダウンロードをします。
3.ログファイル一覧取得
RDSからログファイル一覧を取得します。
APIは DescribeDBLogFiles
を使用しており、次のような条件で取得しています。
- FilenameContains:
*.log.*
- このような指定をすることでログローテートされてるファイルを対象にできます
- FileLastWritten: 1時間以内
- FileSize: 1
- 0バイトのファイルもあるのでそれを除外します
4.ログのパスをSQSに登録
2と3の結果をもとに、ダウンロードすべきRDSのログファイルを抽出します。
具体的にはFileLastWrittenまたはFileSizeが前回と異なる場合、ダウンロード対象とします。
対象のログファイルのパスとDBインスタンス名をSQSに登録します。1ファイルごとに1キューを登録しています。
このタイミングで3の結果をS3に保存しておき、次回起動時に利用できるようにしてます。
5.キュー取得
SQSのエンキューでRDSログダウンロードLambdaが起動します。
SQSを利用する理由ですが、並列処理をコードにしなくてLambdaでよしなにやってくれるのでこうしました。
コードのわかりやすさ重視です。
6.ログダウンロード
RDSからログファイルをダウンロードします。
今回使用するAPI downloadCompleteLogFile
はSDKでは使えないものです。
そのためSigV4で署名したうえでリクエストする必要があります。
aws-sdk-go-v2
を使用した実際の例が見当たらなかったので、コードを一部抜粋して紹介します。
// createRDSLogRequest はRDSログファイルをダウンロードするためのHTTPリクエストを作成します
func (h *Handler) createRDSLogRequest(ctx context.Context, event RDSLogEvent) (*http.Request, error) {
url := fmt.Sprintf("https://rds.%s.amazonaws.com/v13/downloadCompleteLogFile/%s/%s",
h.config.Region, event.DBInstanceIdentifier, event.LogFileName)
// AWS認証情報を取得
credentials, err := h.awsCfg.Credentials.Retrieve(ctx)
if err != nil {
return nil, fmt.Errorf("unable to get credentials: %v", err)
}
// HTTPリクエストの作成
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %v", err)
}
// Bodyを空に指定したいので、空文字をハッシュ化
hash := fmt.Sprintf("%x", sha256.Sum256([]byte("")))
// SigV4署名の作成
signer := v4.NewSigner()
err = signer.SignHTTP(ctx, credentials, req, hash, "rds", h.config.Region, time.Now())
if err != nil {
return nil, fmt.Errorf("failed to sign request: %v", err)
}
return req, nil
}
7.ログをS3に出力
ログファイルをS3に保存します。
Athenaでパーティションを使えるように保存パスを ${bucket name}/audit/${rds cluster name}/${rds instance name}/yyyy/mm/dd/hh/xxx.log.gz
となるようにします。
ここでの工夫を紹介します。
単純にログダウンロード→ログアップロードとやると、ログファイル分Lambdaのメモリを確保せねばなりません。
そのためLambdaの最低メモリ割り当てを128MB以上にしないとならず、お金がかかります。
128MBのままにするために、S3へアップロードするときにストリーミングアップロードをしました。
ストリーミングアップロードをすることで、HTTPでログをダウンロードしS3にアップロードというのを徐々にやれるため、とても省メモリです。
こちらのコードサンプルをご紹介します。
pipeReader, pipeWriter := io.Pipe()
gzipWriter := gzip.NewWriter(pipeWriter)
errCh := make(chan error, 1)
go func() {
var downloadErr error
defer func() {
gzipWriter.Close()
if err := recover(); err != nil {
downloadErr = fmt.Errorf("panic occurred: %v", err)
}
if downloadErr != nil {
pipeWriter.CloseWithError(downloadErr)
} else {
pipeWriter.Close()
}
errCh <- downloadErr
}()
transport := &http.Transport{
MaxIdleConns: 1,
IdleConnTimeout: 180 * time.Second,
DisableCompression: true,
DisableKeepAlives: false,
MaxIdleConnsPerHost: 1,
ResponseHeaderTimeout: 30 * time.Second,
ReadBufferSize: bufferSize,
WriteBufferSize: bufferSize,
}
client := &http.Client{
Transport: transport,
Timeout: 240 * time.Second,
}
// 上記createRDSLogRequest関数で取得したreq
resp, err := client.Do(req)
if err != nil {
downloadErr = fmt.Errorf("failed to download log file: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
downloadErr = fmt.Errorf("failed to download log file: status code %d", resp.StatusCode)
return
}
buf := make([]byte, bufferSize)
if _, err := io.CopyBuffer(gzipWriter, resp.Body, buf); err != nil {
downloadErr = fmt.Errorf("failed to copy data: %v", err)
return
}
}()
// S3へのアップロード
_, err = h.uploader.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String(h.config.S3Bucket),
Key: aws.String(s3Key),
Body: pipeReader,
ContentEncoding: aws.String("gzip"),
})
if err != nil {
return fmt.Errorf("failed to upload to S3: %v", err)
}
if err := <-errCh; err != nil {
return err
}
AIエージェントを使ったコーディング
さて、ようやくAIエージェントの話です。
ここまでお話した仕組みは様々なブログ記事を参考にしつつ私がアレンジして考えたものです。
実際のコーディングはCursorのAIエージェントを使って行いました。
LambdaのコードやTerraformのコードはほとんどAIエージェントに作ってもらいました。私がコーディングしたのは「6.ログダウンロード」のコード部分と「7.ログをS3に出力」のストリーミングアップロードのコード部分のみです。
こういうネット上に例がない複雑なコードは苦手なようです。
ただ「AIエージェント使いました!」じゃなにも参考にならないと思うので、2つのLambdaを使ったプロンプトをご紹介したいと思います。
ちなみにこのプロンプト一発だと、動くコードはできあがりますが、品質の良いコードではないのでそこはペアプロ的な感じでリファクタリングを指示し続ける必要があります。
ログ一覧取得をするLambda作成プロンプト
# List RDS Log Files
AWS LambdaでRDSのログファイル一覧を取得し、差分があるファイルがある場合はAWS SQSに登録する。
## 処理の流れ
1. DBクラスタ名からDBインスタンス名一覧を取得
2. S3から前回取得したログファイル一覧を取得
- 条件
- ファイル名に「.log.」が含まれる
- fileLastWrittenが1時間以内
- ファイルサイズが1byte以上
3. RDSからログファイル一覧を取得([DescribeDbLogFiles](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/APIReference/API_DescribeDBLogFiles.html))
- DescribeDbLogFilesの出力結果サンプル
```json
{
"DescribeDBLogFiles": [
{
"LogFileName": "audit/audit.log.0.2025-01-12-11-30.0",
"LastWritten": 1736683200884,
"Size": 4050670
},
{
"LogFileName": "audit/audit.log.0.2025-01-12-12-00.1",
"LastWritten": 1736685001437,
"Size": 3770472
},
{
"LogFileName": "audit/audit.log.0.2025-01-12-12-30.0",
"LastWritten": 1736686313795,
"Size": 3147927
},
{
"LogFileName": "audit/audit.log.1.2025-01-12-11-30.0",
"LastWritten": 1736683201444,
"Size": 3402198
},
{
"LogFileName": "slowquery/mysql-slowquery.log.2025-01-12.12",
"LastWritten": 1736683202574,
"Size": 732
}
]
}
```
4. 2と3の差分を比較し、差分が検出されたログファイル名をSQSに登録する(1ファイルにつき1キュー)
5. 3の結果をS3に保存する
## AWSリソースについて
- S3バケット名
- xxxxxxxx
- 前回のログファイル一覧を保存するS3パス
- targetLogFiles/${dbClusterName}/${dbInstanceName}.json
- SQS名
- xxxxxxxx
- Lambda
- Name: list-rds-log-files
- 環境変数
- DB_CLUSTER_NAMES: データベースクラスタ名をカンマ区切りにした文字列
ログダウンロードをするLambda作成プロンプト
# Put RDS Log to S3
AWS SQSにエンキューされたら実行されるLambda関数。
RDSのログファイルをS3に保存する。
## 処理の流れ
1. キューのメッセージに記載されたログファイルをRDSからS3にコピーする([DownloadCompleteLogFile](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/DownloadCompleteDBLogFile.html))
- キューのメッセージは次のようなJSONになっている。
```json
{
"db_cluster_name": "your-cluster-name",
"DBInstanceIdentifier": "camel-db-instance-1",
"LogFileName": "audit/audit.log.0.2025-01-12-11-30.0"
}
```
- S3へのアップロードにはストリームアップロードを使う。Lambdaが必要とするメモリを少なくするのが狙い。gzip圧縮も同時に行う。
- AWSのDownloadCompleteDBLogFile APIはSDKには存在しない。次のようにAPIを使用する。
- `https://rds.${region}.amazonaws.com/v13/downloadCompleteLogFile/${dbInstanceIdentifier}/${logFileName}`にAWSSignature Version 4 (SigV4)で署名したうえでGETリクエストする
## AWSリソースについて
- S3バケット名
- xxxxxxxxxx
- RDSログ保存先S3バケットとディレクトリ構成
- xxxxxxxxxxx/
- audit/${dbClusterName}/${dbInstanceName}/yyyy/mm/dd/hh/${logFileName}.gz
- error/以下構成は↑と同じ
- slowlog/以下構成は↑と同じ
- targetLogFiles/${dbClusterName}/${dbInstanceName}.json
- このファイルにLambda ListLogFilesで取得したログファイル一覧の結果JSONが格納される
導入した効果
この仕組みを導入した結果、CloudWatch LogsのPutに関する課金額が半減しました!!
今回作成したLambdaに関する費用はほぼ$0だったので、とても削減効果の高い施策でした!
またS3に保存したログをAthenaでクエリーできるようにもできたので、利便性を損なわずに移行することもできました。(冒頭お話したとおりリアルタイム性が犠牲になってます)
またAIエージェントを利用した開発を行ったことで、複雑な処理部分以外を簡単に作成することができたので、大幅な開発コストの削減を行えました。
体感ではありますが全部一人で作っていたらコーディングだけで3日はかかったと思いますが、AIエージェントだと1日でほぼほぼ作れました。
コーディングの精度もよく、リファクタリングの指示もほぼ意図通りやってもらったのでストレスも少なかったです。
tacomsは次のようにAI技術をどんどん取り入れ活用しやすい環境です!
今後もAIを使って効率を上げて課題に取り組んでいきたいと思います!
Discussion