ConsoleAppFrameworkとAWS CDKで爆速バッチ開発

こんにちは。

テックリードのTerukiです。

C# Advent Calendar 10日目の記事です。

今回は社内で開発したConsoleAppFramework(v4)とAWS CDKを使ってバッチを高速で開発・リリースする仕組みの紹介です。

# はじめに
プロダクト開発をしている上で、定時になったら起動してほしいバッチというのはだいたいあるかと思います。

Oh my teethではエンドユーザーに送信するメッセージであったり、はたまた社内向けのリマインダーであったりなど少なくとも数十個のバッチが存在しています。

バッチ自体はAWSのLambdaで動いているのですが、このLambdaのデプロイや管理が非常に面倒という開発体験上の課題がありました。

この課題をConsoleAppFrameworkとAWS CDKで解決したというお話です。


# 前置き
この記事で触れるバッチ処理とは大量のデータを捌く実行基盤などではなく、単に設定した時間になったら起動するプログラムを指しています。
あくまで15分のタイムアウトがあるLambdaでも十分捌き切れるようなサイズのバッチと考えていただければと思います。

# 登場人物
知らない人向けに私の理解を記載します。知ってるよ！という方は飛ばして大丈夫です:pray:

## ConsoleAppFramework

https://github.com/Cysharp/ConsoleAppFramework

[ConsoleAppFramework](https://github.com/Cysharp/ConsoleAppFramework)は[Advent Calendar 3日目](https://neue.cc/2024/12/03_SharpFuzz.html)の担当をされている@neueccさんがメインで開発されているコマンドラインツールを作りやすくしてくれるライブラリです。
執筆時点での最新バージョンはv5ですが、この記事ではv4を使っています。（細かい話で後述します）

Cysharp製のライブラリはパフォーマンスと開発体験を最大限意識した作りになっているので、要件を満たす場合はライブラリ選定で真っ先に候補にあがります。めっちゃ便利。

## AWS CDK
https://docs.aws.amazon.com/cdk/v2/guide/home.html

インフラ構成をコードで記述できるライブラリです。
AWSにはCloudFormationというIaCを実現できるサービスがありますが、これはテンプレートをJSONかYAMLで手書きするので記述量が増えると視認性は悪くなりますし、CloudFormationのデザイナも重くなります。
YAMLが1000行を超えるともうかなりつらい状態になります。

AWS CDKでは好きな言語(TypeScript, Python, Java, .NET, Go)でインフラ構成を記述することができます。
ピンと来ないかもしれませんがテンプレートに同じようなことを何回も書いていた場合は、CDKでは好きな言語のfor文などで記述することができるためかなりスッキリします。

CDKは実行するとCloudFormationのテンプレートを生成するので、デプロイ時にはCloudFormationが裏で動いています。

# 課題
会社がスケールするにつれて、必要なバッチが増えていきました。

バッチのコードを書いた後は、バッチを追加するためにまず開発環境のLambdaをCloudFormationに書いてLambdaを作成するようなフローでやっていましたがこれがかなり面倒な作業です。

単にLambdaを作ればいいというよりは、定時で起動させるためにEventBridgeのリソースもCloudFormationに記載したり、EventBridgeがLambdaを起動できるようにPermissionを追加したりと意外と書くことが多いです。

テンプレートイメージ：
```yaml
Permission:
  Type: 'AWS::Lambda::Permission'
  Properties:
    Action: 'lambda:InvokeFunction'
    FunctionName: !Ref Function
    Principal: events.amazonaws.com
  SourceArn: !GetAtt Rule.Arn
Rule:
  Type: 'AWS::Events::Rule'
  Properties:
    Description: 'everyday at 10:00.'
    Name: batch-rule
    ScheduleExpression: cron(0 1 * * ? *)
    State: ENABLED
    Targets:
      - Arn: !GetAtt Function.Arn
        Id: function
Function:
  Type: 'AWS::Lambda::Function'
  Properties:
    Architectures:
      - x86_64
    Code:
      ImageUri: # Dockerの場合イメージを指定
    FunctionName: 'LambdaName'
    MemorySize: 512
    PackageType: Image
    Role: !GetAtt BatchRole.Arn # 本体は省略
    Timeout: 600
```

1個や2個ならまだしも、20個も30個もになるともう大変です。
基本コピペで増やしていくことになりますがミスったら終わりです。
cron式を間違えたら大事件。

このLambdaの追加が面倒でたまたま動かしたい時間に動いているLambdaに処理を同居させてお茶を濁すなども行われておりなかなかひどい状態になっていました。

これを見事に解決したのがAWS CDKとConsoleAppFrameworkでした。

# 前提条件
- AWS CDK CLIが使えるようになっていること
- .NET SDKが使えるようになっていること
- Dockerが使えるようになっていること

# アプローチ
YAMLを手書きするのがつらいわけなので、それを自動化できたらOKです。

テンプレートを自動生成するならAWS CDKの出番ですが、テンプレートに記載していた名前やらcron式やらはどこから引っ張ってくるのでしょうか。

バッチの本体にcron式が書いてあったらいつ実行されるのか分かりやすくていいですよね。

というわけでバッチの本体のメソッドに属性をつけてしまいましょう。

```cs
public class SampleBatch : ConsoleAppBase {
    /// <summary>
    /// 19時に実行
    /// </summary>
    [Batch("0 10 * * *")]
    public void Run() {
        Console.WriteLine("Hello World!");
    }
}
```

AWSのEventBridgeはUTCでcron式を解釈するのでそこだけ注意ですが、これでこのバッチがいつ動くのか一目瞭然です。

以前のやり方ではコード側には記載はなくCloudFormationのテンプレートにしか書いていなかったのでパッと見でいつ動作するバッチなのはよく分かりませんでした。

これで属性を一個付ければ `cdk deploy` するだけでLambdaの実行環境の出来上がりというわけです。

後はバッチ属性がついていたらLambdaを作成するようなCDKのコードを書いてあげます。

↓イメージ
```cs
var batches = typeof(BatchAttribute).Assembly.GetTypes()
    .Where(t => t.Namespace?.StartsWith("Batch") ?? false)
    .ToList();

// Batch属性がついているメソッドをすべて取得してBatchFunctionを作成
foreach (var batch in batches) {
    var methods = batch.GetMethods()
     .Where(m => m.GetCustomAttribute<BatchAttribute>() != null)
     .Select(method => {
         var batchAttribute = method.GetCustomAttribute<BatchAttribute>()!;
         return (method, batchAttribute);
     })
     .ToList();
    foreach (var method in methods) {
        _ = new BatchFunction(this, StackName, batch.Name, method.method.Name, method.batchAttribute.CronFormula, role, dockerImage.Repository, dockerImage.ImageTag);
    }
}
```

ここでCDKが.NETに対応している強みがでます。CDKと言えばTypeScriptというイメージがありますが、C#で書くことでリフレクション経由で属性がついたメソッドを取得することができるのです。
(SourceGeneratorでもできそうですが、cdk deploy時にしか動かないためビルドが遅くなるだけであまりメリットはなさそうですね)

# デバッグ
バッチのデバッグ起動もConsoleAppFrameworkは非常に簡単です。

launchSettings.jsonに下記のように記載するだけです。

```json
{
  "profiles": {
    "SampleBatch Run": {
      "commandName": "Project",
      "commandLineArgs": "SampleBatch Run"
    }
  }
}
```

ConsoleAppFrameworkはオプション引数をケバブケースに自動で変換するのですが、Lambda側で起動設定を分かりやすくするためにこの機能を無効にしています。

```cs
var builder = ConsoleApp.CreateBuilder(args, (context, options) => {
    /* ~ */
    options.NameConverter = s => s; // ケース変換しない
});
```

そうしたらVisual StudioやRiderなどから起動ボタンを押すだけでそのバッチが起動します。
めちゃくちゃお手軽ですね。

# 細かい話

細かい部分で言及したいところがあるのですが、すべて書くと長くなるので折りたたんでおきます。興味ある方はどうぞ。

<details><summary>細かい話</summary>

## ConsoleAppFramework v5について

この仕組みが社内で完成して少ししてからConsoleAppFramework v5がリリースされました。
Source Generatorをふんだんに使ってパフォーマンスを上げたりAOTに完全対応していたりとすごいことになっています。

NuGetからインストールした後、ConsoleAppFrameworkのクラスをF12で覗こうとするとすべてが自動生成されたコードになって出てくるのが非常に驚きです。
ライブラリ本体が全部Source Generatorで生成されているのが非常に特徴的ですが、今回のバッチの仕組みはBatch属性を付けるだけでお手軽実装を目指していたため、ConsoleAppFrameworkにバッチのメソッドを登録するのは自動でやってもらう必要がありました。

v4では、`AddAllCommandType` という `ConsoleAppBase`を継承したクラスのメソッドをすべてConsoleAppFrameworkに登録する便利メソッドがあるのですが、v5では仕組み上実現できないため廃止されています。

そのため、v5でこのバッチの仕組みを実現するためにはBatch属性の記載に加えてConsoleAppFramework側にも1行別で書く必要があり、それはコンセプト上微妙だなということで一旦v4に留まることにしました。

v5にアップグレードを試みた時にSource GeneratorにはSource Generatorをという意気込みでやっていましたがしばらくしてSource Generatorで生成されたコードにSource Generatorがアクセスできないということを思い出して詰みました。。

何か代替手段があれば良いなと思っているのですが、今のところ思いついていません:sob:

## Docker経由でのLambdaの起動

Dockerを使っているのはシンプルにしたいためでもありますが、Lambdaのイメージ設定のCMDの値によって起動するバッチを選べるようにしたかったからでもあります。

[docker-entrypoint.sh](https://github.com/ohmyteeth/ConsoleAppFrameworkCDKBatchSample/blob/main/Batch/docker-entrypoint.sh)にてdotnetコマンドに `$@`を渡しているため、dotnetコマンドの後ろにそのままDockerのCMDの値を渡すことができます。

CMDの値をCDKで動的に与えることでバッチの起動を出し分けています。

謎のエンドポイントにcurlで叩いていますが、これはLambdaの仕様に合わせてエンドポイントを叩かないとLambda自体がエラーになってしまうためです。

本来は公式のDockerイメージでやるべきで、試したりもしたのですがどうしてもCMD経由でパラメータを渡すことができず一旦諦めています。環境変数経由で渡すこともできそうではありますが、ちょっと時間切れ。。
</details>

# おわりに
AWS CDKとConsoleAppFrameworkでバッチの開発体験が格段に向上しました。

この考え方自体はAWS CDKやConsoleAppFramework以外にも適用できると思うので、開発体験を上げるために参考にしてもらえたら嬉しいです。

今回は言及してませんが、ConsoleAppFrameworkはコンストラクタインジェクションが使えるのでDI経由であれこれできて便利ですし、DIを適切に使えばバッチの自動テストも可能です。

CIにcdk deployを組み込めば自動でデプロイもできます。

一通り動作するサンプルリポジトリを用意しているのでこちらも参考にしてもらえたらと思います。

ではでは。

https://github.com/ohmyteeth/ConsoleAppFrameworkCDKBatchSample

# Oh my teethについて
Oh my teethでは未来の歯科体験を創るために日々活動しています。

Techチームではより良いユーザー体験を提供するべく、Webフロントエンドからバックエンド、スマホアプリに機械学習モデルなど、さまざまなプロダクトを開発しています。

一緒に未来の歯科体験を創りませんか？興味がある方は是非こちらを確認してください。

https://oh.careers/

カジュアル面談も可能なので気軽に応募してみてください！

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192685/0138b492-e02b-852e-61fa-fc065b53d285.png" width="450" />