Qiita 内で ecspresso, ecschedule をどう使っているか

https://qiita.com/tomoasleep/items/e7f919c7b4d0f091d458

で Qiita では ECS 化した構成で ecspresso, ecschedule を使っていると軽く触れたのですが、どんな感じで使っているかを具体的に深堀りします。
 ecspresso + ecschedule での管理方法の参考になれば幸いです。
 
(※ 一部名称は実際のものと違う場合があります)

## 前提知識: ecspresso, ecschedule について

ecspresso は ECS Service, ECS Task Definition の宣言的管理、デプロイを行えるようにするツールで、ecschedule は ECS Scheduled Tasks を同様に管理が行えるツールです。

https://github.com/kayac/ecspresso

https://github.com/Songmu/ecschedule

## Qiita 内の ecspresso, ecschedule の設定管理

Qiita の ecspresso, ecschedule の設定は、アプリケーションディレクトリに同梱する形で管理し、アプリケーションリポジトリ側の CI 内に組み込んで利用しています。

以下のようなディレクトリ構成になっています。

```bash
ecs/
  *.md                                        # このディレクトリに関するドキュメント
  {production,staging,...}/                   # 各環境用の設定ファイルを格納するディレクトリ
    {web-server,job-worker,pre-deploy-task,oneshot-task,...}/ # 各 ECS サービス用の ecspresso の設定を格納するディレクトリ
      ecspresso.jsonnet                       # ecspresso の設定ファイル
      ecs-task-def.jsonnet                    # ECS タスク定義の設定ファイル
      ecs-service-def.jsonnet                 # ECS サービス定義の設定ファイル
    ecschedule/                               # 各環境の ECS Scheduled Task を ecschedule で管理するための設定を格納するディレクトリ
    shared/                                   # 共通の設定を格納するディレクトリ
      ecschedule.jsonnet                      # ecschedule の設定ファイル
      schedules.jsonnet                       # scheduled task の schedule だけを切り出した
  scripts/                                    # ECS で利用する各種ツール用のラッパーコマンド、スクリプトを格納するディレクトリ
```

以下のような流れで、デプロイを行っています。

```mermaid
graph LR
Build(docker build)-->PreTask("前処理の ecspresso deploy と run")
PreTask-->Deploy1(ECS Service 1 の ecspresso deploy)
PreTask-->Deploy2(ECS Service 2 の ecspresso deploy)
PreTask-->DeployN(ECS Service N の ecspresso deploy)
Deploy1-->DeployCron(Scheduled Tasks の ecschedule apply)
Deploy2-->DeployCron
DeployN-->DeployCron
DeployCron-->PostTask(後処理)
```

### 多数の ECS Service の設定を Jsonnet で管理する

Qiita で管理している ECS Service のバリエーションはそこそこ多いです。

```bash
ecs/
  {production,staging,...}/
	qiita-xxx-web-server/
	qiita-yyy-web-server/
	job-worker-xxx/
	job-worker-yyy/
	pre-deploy-task/ # 前処理を実行するための ECS Task 定義 (独立させることでデプロイ失敗時の影響を小さくする)
	oneshot-task/    # スタンドアロンタスクを動かすためのベースの ECS Task 定義
	scheduled-task/  # ECS Scheduled Task 用の ECS Task 定義
    .../
```

だいたいこんな感じの背景で、ECS Service 数が多くなっています。

- Qiita には複数のサービスがあり、サービスごとの ECS Service が必要
- Rails サーバー、非同期ジョブ等、アプリケーションに複数種類の起動方法がある
	* 歴史的経緯等、 監視、可用性を考慮し、さらに複数種類に細分化
- スタンドアロンタスク(直接 ECS Task として起動する)用のもの
	- ※ ecspresso は設計上、これらにも Service 定義が必要
		- Ref: [config file の service 定義不要で standalone task を実行できますか？ · Issue #405 · kayac/ecspresso](https://github.com/kayac/ecspresso/issues/405)
	- Qiita ではこれらは desired 0 の ECS Service として管理


なので、 ECS Service の設定は、以下の通りに [Jsonnet](https://jsonnet.org/) を使った共通化を行い、Service 側のディレクトリは極力薄くし、Service 間で設定の一貫性を持たせやすくしています。


```jsonnet
// ECS Task definition
local AwsDefs = import '../shared/aws-defs.libsonnet';
local QiitaProxyContainerBase = import '../shared/container-base/qiita-proxy-container.libsonnet';
local QiitaRailsWebContainerBase = import '../shared/container-base/qiita-rails-web-container.libsonnet';

{
  // 命名用の関数などを共通部分に切り出し、極力ベタ書きしない
  family: AwsDefs.taskFamilyName('qiita-web-server'), 
  containerDefinitions: [
    // コンテナ定義は、ベースとなる設定を用意し、一部の設定を上書きする形で利用
    QiitaRailsWebContainerBase($.family) {
      cpu: '...',
      memoryReservation: '...',
    },
    QiitaProxyContainerBase($.family),
  ],
  // リソース名なども Service 毎に書かず共通化
  taskRoleArn: AwsDefs.iamRoleArn.taskRole.app,
  executionRoleArn: AwsDefs.iamRoleArn.executionRole.app,
}
```

```jsonnet
// ECS Service definition
local AwsDefs = import '../shared/aws-defs.libsonnet';
local EC2ReplicaService = import '../shared/service-base/ec2-replica-service.libsonnet';

EC2ReplicaService {
  loadBalancers: [
    {
      targetGroupArn: AwsDefs.targetGroupArn.qiitaECS,
      containerName: 'container-name-of-web-server',
      containerPort: XXX,
    },
  ],
}
```

Jsonnet は JSON に近い文法のテンプレート言語で、ecspresso, ecschedule で設定を記述する際に使用することが出来ます。
import や function、継承に近い設定の merge の機能があり、こうした設定の切り出しや共通化が行いやすかったです。
(コメントを書くことが出来る JSON としても使えるのも地味に嬉しいところです。)

### Jsonnet を使った Scheduled Tasks の設定の関心の分離

ちなみに、Scheduled Tasks でもこうした Jsonnet を使った共通化や関心の分離を行っており、インフラレベルの設定を書く部分と、どのタイミングで何のコマンドを実行したいかの設定を書く部分を分けてます。

```jsonnet
// ECS Scheduled Tasks のスケジュールと実行するコマンド指定を切り出したもの

[
  {
    name: 'report-something',
    schedule: 'cron(0 8 * * ? *)',
    description: 'report something',
    command: ['rake', 'report:something'],
  },
  {
    name: 'report-something-monthly',
    schedule: 'cron(0 17 L * ? *)',
    description: 'report something of this month',
    command: ['rake', 'report:something:month'],
  },
]  
```

```jsonnet
// ecschedule の定義本体 (ECS Scheduled Tasks 自体の設定) 

local RuleBase = import '../shared/schedule-rule-base.libsonnet';
local schedules = import './schedules.libsonnet';

{
  // ...
  rules: [
    RuleBase(schedule.name) {
      scheduleExpression: schedule.schedule,
      description: schedule.description,
      containerOverrides: [
        {
          name: 'app-container',
          command: schedule.command,
        },
      ],
    } + (if 'ruleOverrides' in schedule then schedule.ruleOverrides else {})
    for schedule in schedules
  ],
}
```

これにより、ある意味 CronTab に近いような体験で、インフラ面の設定を意識しすぎず書くことが行えるようになります。

## 工夫していること

### aqua を使った ecspresso, ecschedule のバージョン管理

こうした CLI ツールのインストールを容易にするのと、開発者間との差異、 CI とのバージョン差異を減らすために、利用しているツールはバージョン管理を行っています。

Qiita では aqua を使って CLI ツールのバージョン管理を行っています。

https://aquaproj.github.io/

aqua は asdf や xxxenv などのように、プロジェクト単位で利用するバージョンを管理するツールの CLI ツール向け版という感じですが、 [GitHub Actions 内で利用するための Action](https://aquaproj.github.io/docs/products/aqua-installer/) や [Renovate 用の設定 Preset](https://aquaproj.github.io/docs/products/aqua-renovate-config/) を提供しているなど、運用していく上でかゆいところに手が届く感じで便利です。

### 薄いラッパーを用意して、環境変数や引数等の設定の省略を行う

ecspresso などでは go template や組み込みの function を用いて、環境変数を設定に埋め込むことが可能です。

https://sfujiwara.hatenablog.com/entry/2017/12/24/233500

https://sfujiwara.hatenablog.com/entry/2024/08/06/14024

Qiita でも、参照する Docker Image のタグ等の設定で、動的に変更できるように環境変数を利用しています。

ただ、必要な環境変数等が増えてくるとコマンドが長大になっていってしまいます。
Qiita では複数の ecspresso の設定を持っているため、それらも含めてかなり長くなっています。

```bash
IMAGE_TAG=XXX SOME_EXTERNAL_PARAM=XXX ecspresso --config="ecs/staging/qiita-web-server/ecspresso.yml" exec
```

こうしたコマンドがシンプルになるように、 薄いラッパーを用意して使っています。 

```bash
./ecs/scripts/qiita-ecspresso ecs/staging/qiita-web-server exec

# ↓ と同じ

IMAGE_TAG=XXX SOME_EXTERNAL_PARAM=XXX ECSPRESSO_CONFIG=ecs/staging/qiita-web-server/ecspresso.yml ecspresso exec
```

こうしたワークアラウンドは好みが分かれるかもしれませんが、環境変数に関しては ecspresso 自体に、外部コマンドの入力を取得できる機構 (external plugin) の導入が検討されているので、将来的には ecspresso 自体の仕組みで上手くやりやすくなっていくかもしれません。

https://github.com/kayac/ecspresso/pull/760

### ECS Service, Task Definition 名の規約を設けて、自動チェックしている

ECS Service, Task Definition の管理が楽にする & 設定管理の競合防ぐために、以下の命名規約を設けています。

- ECS Service: (ecspresso.jsonnet をおいているディレクトリ名と同じ)
- ECS Task Definition: `${ECS クラスタ名}-${ECS Service 名}`

また、こうして定めた規約からうっかり外れないように、自動でこれらの規約通りになっているかの検証も行っています。

今回は、

* `ecspresso render` を使って最終的に生成される YAML or JSON を出力
* 出力された YAML or JSON の中身を、以下のようなテスト風のスクリプトを Ruby で書いて検証

という流れで、自動で検証を行えるようにしています。


```ruby
config = capture_as_yaml(%(#{ecspresso} render config))
test("The service name in ecspresso.jsonnet should be the same service name with directory name (#{dirname})") do
	ng "service name is #{config[:service]}" unless config[:service] == dirname
end

task_def = capture_as_json(%(#{ecspresso} render task-definition))
test("The task family in task definition name should be #{cluster_name}-#{dirname}") do
	ng "the task family is #{task_def[:family]}" unless task_def[:family] == "#{cluster_name}-#{dirname}"
end
```


-  ルールを独自定義出来るような Linter は (自分が調査した限りでは)  Jsonnet 周りだとあまりなさそう
- 汎用的な設定のテストツールもあるが、今回のようなちょっとした部分で使いたい場合は (要件も複雑ではないため) 社内で使われている Ruby で実装してしまった方がメンテが楽

という理由でこうした実装にしています。

ちなみに、設定のミスを防ぐという文脈では ecspresso 組み込みの `ecspresso verify` を使うのも便利です。参照するリソースが存在するか、利用できるかなどを検証できるため、デプロイした設定が動かない、ということは回避しやすくなります。

https://zenn.dev/fujiwara/articles/ecspresso-20201211

##  参考: ecspresso, ecschedule の他の活用事例、参考文献

Qiita の ECS 化にあたって ecspresso, ecschedule で設定を管理する方法を考えるにあたって、いくつかの事例や公開されている資料を参考にさせていただきました。この場を借りてお礼を申し上げます。 

- [ecspresso MeetUp](https://sfujiwara.hatenablog.com/entry/ecspresso-meetup) で共有されていた各種資料: [ecspresso MeetUpを開催していただきました - 酒日記 はてな支店](https://sfujiwara.hatenablog.com/entry/ecspresso-meetup)
- [ecspressoを活用したECSデプロイの改善 - LayerX エンジニアブログ](https://tech.layerx.co.jp/entry/2023/07/21/175319)
- [ecspresso handbook v2対応版](https://zenn.dev/fujiwara/books/ecspresso-handbook-v2)
- [定時バッチをECS scheduled task + ecscheduleでお手軽管理する - BOOK☆WALKER inside](https://developers.bookwalker.jp/entry/2022/07/29/110000)