はじめに

Amazon CloudWatch Logs って、知らない間に溜まって料金を圧迫したりしますよね。ロググループを自動で作成するサービスもありますし、保持期間の見直しは定期的に行いたいものです。

こういったケースの打開策のひとつに、AWS CDK の利用が挙げられます。CDK の様々な L2 コンストラクトではプロパティひとつでロググループの保持期間を設定できます。これを実現するために、裏で自動でカスタムリソースを作成してくれているのです。この抽象度の高さが CDK の売りでもあります。

個別の対策としては、このように AWS CDK の導入が有効だと思います。では、すでに大量に存在する雑多なロググループはどうすればいいでしょうか?

前置きが長くなりましたが、この問題を解決するために OSS ツールを作って公開しました!!

AWS log groups lifecycle manager. Contribute to nekrassov01/llcm development by creating an account on GitHub.

どんなツールか

わざわざツールを使って管理するので、使いやすくなければなりません。最初に以下の方針を決め、これらを軸に開発しました。

  • オプトインが不要なリージョンすべてをデフォルトで対象にする
  • リージョン横断で、保持期間の変更やロググループの削除を一括で適用する
  • 高速な処理のためにリージョンごとに並行で動作させる
  • 式評価を用いた柔軟なフィルタリング機能を実装する
  • 多用なユースケースに対応するために、アウトプット形式も多様にする
  • 削減量のシミュレーションを実装する
  • パッケージ部分と CLI 部分を分離し、直接パッケージを利用できるようにする
  • ついでにインストールが楽

リージョン横断で高速に一括処理できるのがベースラインで、そこに付加価値としてフィルタリング、アウトプット形式の多様さ、そして削減量シミュレーションが乗っかるイメージです。言語は以下の理由から Go を選定しました。

  • 並行処理がしやすい
  • ランタイムを内包したバイナリを簡単に生成できる
  • 単に慣れている

インストール

Mac であれば homebrew でインストールできます。

brew install nekrassov01/tap/llcm

Windows, Linux の場合はリリースページからバイナリをダウンロードし、パスの通ったディレクトリに放り込んでください。

コマンド

llcm には 3 つのコマンドがあります。基本的には listpreview で対象を絞り込み、その後 apply でロググループの削除や保持期間の変更を適用するような設計になっています。

コマンド 機能
list リージョン横断でロググループを一覧化します。デフォルトは ASCII テーブル形式です。DescribeLogGroups API の返り値を見やすく加工して出力します。
preview リージョン横断でロググループを一覧化しますが、--desired オプションで希望する状態を渡すことで、その状態を適用した場合の削減量をシミュレーションします。これは日割りのログ量を基準とした単純な計算結果です。
apply --desired で設定した状態を実際に適用します。保持期間の変更やロググループの削除が一括で行われます。

オプション

オプションは以下の通りです。各オプションがどのような値を期待しているかを示しています。

オプション 紐づくコマンド デフォルト値 環境変数
–profile value
-p value
list
preview
apply
任意 AWS_PROFILE
–log-level value
-l value
list
preview
apply
debug
info
warn
error
info LLCM_LOG_LEVEL
–region v1,v2
-r v1,v2
list
preview
apply
af-south-1
ap-east-1
ap-northeast-1
ap-northeast-2
ap-northeast-3
ap-south-1
ap-south-2
ap-southeast-1
ap-southeast-2
ap-southeast-3
ap-southeast-4
ap-southeast-5
ap-southeast-7
ca-central-1
ca-west-1
eu-central-1
eu-central-2
eu-north-1
eu-south-1
eu-south-2
eu-west-1
eu-west-2
eu-west-3
il-central-1
me-central-1
me-south-1
mx-central-1
sa-east-1
us-east-1
us-east-2
us-west-1
us-west-2
オプトインなしで使えるすべてのリージョン
–filter v1,v2
-f v1,v2
list
preview
apply
key:
name
source
class
elapsed
retention
bytes
operator:
>
>=
<
<=
==
==*
!=
!=*
=~
=~*
!~
!~*
–desired value
-d value
preview
apply
delete
1day
3days
5days
1week
2weeks
1month
2months
3months
4months
5months
6months
1year
13months
18months
2years
3years
5years
6years
7years
8years
9years
10years
infinite
–output value
-o value
list
preview
json
prettyjson
text
compressedtext
markdown
backlog
tsv
chart
compressedtext LLCM_OUTPUT_TYPE
–help
-h
list
preview
apply
–version
-v
list
preview
apply

使用例

ユースケースをいくつか紹介します。

ケース 1

Lambda によって生成されたロググループのうち、すべてのリージョンの、中身が空ではなく保存期間が 1 年を超える設定のロググループをリストアップし、保存期間を一律 1 年に変更した場合の削減効果をリストアップする。 出力はマークダウンの表形式とする。

この要件であれば、以下のようなコマンドになります。

llcm preview --desired 1year --filter 'name =~ ^/aws/lambda/.*','bytes != 0','retention > 1year' --output markdown

とりわけ重要なのは --filter です。複数の式を渡すことができ、これらは AND で接続されます。以下のような意味になります。

意味
name =~ ^/aws/lambda/.* Lambda が生成するロググループ名にマッチし、
bytes != 0 中身が空ではなく、
retention > 1year 1 年を超える保持期間が設定されたロググループすべて (無制限含む)

preview における --desired 1year は、上記でフィルターしたロググループの保持期間をすべて 1 年に変更した場合にどの程度ログ容量が削減されるかを示します。マークダウン形式を指定しているため、出力は以下のようになります。BytesPerDay 以降のカラムがシミュレーション結果です。

| Name                 | Region         | Source                      | Class    | CreatedAt                 | ElapsedDays | RetentionInDays | StoredBytes  | BytesPerDay | DesiredState | ReductionInDays | ReducibleBytes | RemainingBytes |
| -------------------- | -------------- | --------------------------- | -------- | ------------------------- | ----------- | --------------- | ------------ | ----------- | ------------ | --------------- | -------------- | -------------- |
| /aws/lambda/tokyo-1  | ap-northeast-1 | 000000000000/ap-northeast-1 | STANDARD | 2019-04-15T21:50:12+09:00 | 2107        | 731             | 161094000389 | 220374829   | 365          | 366             | 80657187414    | 80436812975    |
| /aws/lambda/tokyo-2  | ap-northeast-1 | 000000000000/ap-northeast-1 | STANDARD | 2020-08-26T23:45:50+09:00 | 1608        | 731             | 30273686566  | 41414071    | 365          | 366             | 15157549986    | 15116136580    |
| /aws/lambda/oregon-1 | us-west-2      | 000000000000/us-west-2      | STANDARD | 2020-08-27T14:34:54+09:00 | 1607        | 731             | 28578246408  | 39094728    | 365          | 366             | 14308670448    | 14269575960    |
| /aws/lambda/oregon-2 | us-west-2      | 000000000000/us-west-2      | STANDARD | 2020-08-26T23:48:51+09:00 | 1608        | 731             | 22822519036  | 31220956    | 365          | 366             | 11426869896    | 11395649140    |

この出力で問題ないことが確認できたら、--desired--filter に同じ値を設定した上で apply します。これで、対象のロググループの保持期間が一括で 1 年に設定されます。

llcm apply --desired 1year --filter 'name =~ ^/aws/lambda/.*','bytes != 0','retention > 1year'

ケース 2

東京とオレゴンのロググループのうち、中身が空で 1 年以上前に作成されたものを Backlog のテーブル形式でリストアップする。

この場合リージョンを絞りたいので、以下のように明示的に設定します。

llcm list --filter 'bytes == 0','elapsed > 365' --region ap-northeast-1,us-west-2 --output backlog

--filter は以下のような意味になります。

意味
bytes == 0 中身が空で、
elapsed > 365 作成から 1 年経過しているロググループすべて

なおキーが日数を表す elapsed または retention である場合は、以下のように --desired と同形式の文字列表現を渡すことができます。評価が失敗するとエラーになります。ちなみに delete が渡された場合も失敗します。

elapsed > 1year

このツールは、Backlog 記法のテーブル形式もサポートします。弊社が Backlog のヘビーユーザーなので、実務利用のためにはこの機能が必要でした。

| Name   | Region         | Source                      | Class    | CreatedAt                 | ElapsedDays | RetentionInDays | StoredBytes |h
| test-1 | ap-northeast-1 | 000000000000/ap-northeast-1 | STANDARD | 2017-12-07T13:16:02+09:00 |        2601 |             731 |           0 |
| test-2 | us-west-2      | 000000000000/us-west-2      | STANDARD | 2017-12-07T12:44:45+09:00 |        2601 |             731 |           0 |
| test-3 | ap-northeast-1 | 000000000000/ap-northeast-1 | STANDARD | 2017-12-07T13:21:09+09:00 |        2601 |             731 |           0 |
| test-4 | us-west-2      | 000000000000/us-west-2      | STANDARD | 2017-12-07T12:50:11+09:00 |        2601 |             731 |           0 |

上記コマンドで削除可能であると判断した場合は、以下のように --desired delete を指定して apply し、削除します。--filter --regionlist 時の設定と同じにします。

llcm apply --desired delete --filter 'bytes == 0','elapsed > 365' --region ap-northeast-1,us-west-2

ケース 3

より本格的な調査のために、そのアカウントのすべてのロググループを TSV 形式でクリップボードにコピーし、スプレッドシートに貼り付ける。

TSV 形式をサポートしているので、クリップボードにコピーすればスプレッドシートや Excel に貼り付けることができます。

# macOS
llcm list --output tsv | pbcopy

# Windows
llcm list --output tsv | Set-Clipboard

ケース 4

削減量をドキュメントにまとめるため、グラフにプロットして視覚化する。保持期間が設定されていないロググループを一律で 1 年に変更した場合の削減量をグラフに出す。

ここまではテキスト形式での出力でしたが、実は簡単な視覚化にも対応しています。Apache ECharts を Go の世界から呼び出せる go-echarts を使い、HTML 化したものをブラウザに表示します。この要件であれば以下コマンドになります。

llcm preview --desired 1year --filter 'retention == inifinite' --output chart

以下のように、削減量シミュレーションが積み上げ棒グラフにプロットされます。

preview

一方 list の場合、現在の容量が円グラフにプロットされます。

list

ざっくり把握したい場合や簡単な状況だけ伝えたい場合には、やはり視覚化が有用ですね。

スロットリングエラーの回避について

apply で使っている API にはそれぞれ以下のクォータがあります。

API クォータ 引き上げ
PutRetentionPolicy アカウントまたはリージョンごとに秒間 5 回まで 不可
DeleteRetentionPolicy アカウントまたはリージョンごとに秒間 5 回まで 不可
DeleteLogGroup アカウントまたはリージョンごとに秒間 10 回まで

llcm は複数のロググループを並行で取り扱うので、なにも対策をしないと当然 ThrottlingException が発生して処理が中断してしまいます。このため、以下のような Retryer を実装しました。今後チューニングするかもしれませんが、現状は問題なく動作しています。

エラーメッセージに “api error ThrottlingException” が含まれる場合のみ、最大 10 回までリトライする。各リトライ前に 1-3 秒のランダムな待機時間を挟む。

AWS SDK for Go v2 におけるリトライパターンについてはこちらの記事が詳しいです。llcm でも大いに参考にさせていただきました。

Lambda で定期実行

llcm はパッケージとしても使用できます。つまり Lambda で動かせます。例えば特定のフィルターで絞り込んだロググループに対して Apply したい場合は以下のようなコードになります。

package main

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

    "github.com/aws/aws-lambda-go/lambda"
    "github.com/nekrassov01/llcm"
)

var client *llcm.Client

func init() {
    cfg, err := llcm.LoadConfig(context.TODO(), "")
    if err != nil {
        log.Fatal(err)
    }
    client = llcm.NewClient(cfg)
    log.Println("client created in init")
}

func handleRequest(ctx context.Context) error {
    log.Println("handleRequest started")
    w := os.Stdout

    d := os.Getenv("DESIRED_STATE")
    desired, err := llcm.ParseDesiredState(d)
    if err != nil {
        return err
    }

    f := os.Getenv("FILTERS")
    filter, err := llcm.EvaluateFilter(strings.Split(f, ","))
    if err != nil {
        return err
    }

    man := llcm.NewManager(ctx, client)

    if err := man.SetFilter(filter); err != nil {
        return err
    }

    if err := man.SetDesiredState(desired); err != nil {
        return err
    }

    n, err := man.Apply(w)
    if err != nil {
        return err
    }
    fmt.Fprintf(w, "done: %d\n", n)

    log.Println("handleRequest finished")
    return nil
}

func main() {
    lambda.Start(handleRequest)
}

この Lambda を Amazon EventBridge Scheduler で毎月初に動作させるサンプルを AWS CDK で定義し、サンプルとしてリポジトリに配置しました。ぜひ使ってみてください。

ちなみに TypeScript での Lambda の定義は以下のように Docker イメージから作成し、引数は環境変数で渡すようにしています。冒頭で触れた通り Lambda 関数自体が生成するロググループの保持日数は logRetention プロパティで設定できます。これが裏でカスタムリソース化されるわけです。

...
const fn = new cdk.aws_lambda.DockerImageFunction(this, "Function", {
  description:
    "Set retention period for log groups with no expiration date.",
  code: cdk.aws_lambda.DockerImageCode.fromImageAsset("src/lambda"),
  architecture: cdk.aws_lambda.Architecture.ARM_64,
  role: role,
  logRetention: cdk.aws_logs.RetentionDays.THREE_MONTHS, // コレ
  currentVersionOptions: {
    removalPolicy: cdk.RemovalPolicy.RETAIN,
  },
  timeout: cdk.Duration.minutes(5),
  environment: {
    FILTERS: "retention == infinite",
    DESIRED_STATE: "3months",
  },
});
this.alias = new cdk.aws_lambda.Alias(this, "Alias", {
  aliasName: "live",
  version: fn.currentVersion,
});
...

注意点

いくつか注意点があります。

  • フィルターに渡す文字列は、シングルクォートで囲んでください。 bash による履歴展開など、意図しない展開が起こる可能性があります(bash で echo "name !~ test" と入力してみてください)
  • preview コマンドは削減量のシミュレーションに適していますが、ログ量の日割りを基準にした単純な計算に過ぎないことに注意してください。
  • 便宜上、preview コマンドにおける BytesPerDay フィールドの最小値は 1byte です。サイズが小さいロググループが大量にある場合、この仕様に注意してください。

今後の展望

このツールをメンテナンスしていく上で、現状では以下のような機能を考えています。

  • フィルター式に論理オペレーター && || を導入する
  • list preview で大量のロググループがある場合の対策として、ストリーミング出力を導入する (ソートはできなくなる)

おわりに

AWS の SDK を使って痒いところを掻くためのツールを作るのは、私の場合ほとんど趣味なのですが、今回は業務における課題をクリアするために開発しました。仕事で見つけた課題感を趣味に転用でき、自分のスキルアップにも繋げることができたため、これはいい循環だと実感しています。

ストレージ量削減の補助ツールとしては、もうひとつ S3 関連の簡単なツールを作っており、次回はそちらも紹介したいと思います。