Argo CDとCDK8sで構築する！プログラマブルなGitOpsワークフローの実践ガイド

# はじめに

本記事では、プログラマブルな IaC ツール「**CDK8s**」と GitOps ツール「**Argo CD**」の連携方法を紹介します。

CDK8s で生成したマニフェストを Argo CD でデプロイし、GitOps のワークフローに組み込むことで、インフラ管理の柔軟性と自動化を向上させることができます。本記事では **Argo CD プラグインの活用方法**、**App of Apps パターンによる自己修復機能の実現方法**などを具体的に解説します。

## この記事をおすすめしたい人

- **CDK8s のデプロイ方法で悩んでいる**
- **Argo CD に CDK8s を組み合わせたい**
- **Argo CD のプラグイン構築手順を知りたい**
- **CDK8s で Argo CD の App of Apps パターンを導入したい**

## 用語の補足

| 用語 | 説明 |
| --- | --- |
| [**GitOps**](https://www.redhat.com/ja/topics/devops/what-is-gitops)  | Git リポジトリを Single Source of Truth として、Kubernetes などのインフラストラクチャを宣言的に管理する運用手法です。|
| [**Argo CD**](https://argo-cd.readthedocs.io/en/stable/) | Kubernetes 向けの GitOps に基づく継続的デリバリーツールです。Git リポジトリをソースとして、Kubernetes クラスタの状態を管理します。 |
| [**App of Apps パターン**](https://argo-cd.readthedocs.io/en/stable/operator-manual/cluster-bootstrapping/) | 複数の Argo CD アプリケーションを一つの親アプリケーションで管理するパターンです。親アプリケーションが子アプリケーションの定義をGitから読み込み、Argo CDがそれらをデプロイ・管理します。 |
| [**CDK8s**](https://cdk8s.io/) | TypeScriptやPythonなどの汎用プログラミング言語を使用して Kubernetes マニフェストを構築するための IaC ツールです。 |
| [**Projen**](https://projen.io/docs/project-types/cdk8s/) | CDK8s プロジェクトの初期設定、ビルド、テスト、デプロイなどを自動化するツールです。プロジェクトのライフサイクル全体を管理しやすくします。 |

「CDK8s って何が嬉しいの？」という方には、ぜひ一度触ってみて欲しいです。以下観点でオススメです。

- アプリとインフラで同じプログラミング言語を使用できるため、開発者の学習コストが軽減される
- 静的型付け言語をサポートしており、IDE の型補完で開発者体験が高まる
- 少ない記述で Kubernetes マニフェスト（YAML ファイル）を定義できる
- 環境ごとにパラメータを柔軟に制御できる
- 既に Kubernetes を運用している環境でも部分的に導入できる

# 背景

当社では全文検索システムを Kubernetes 環境で構築し、運用しています。このシステムではファイルサーバーなどのソースからデータクロールを行い、Elasticsearch に格納する検索インデックスを作成します。検索インデックス作成時に多数の並列処理を行うため、Argo Workflows を利用してジョブを管理しています。

![システム概要図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2989026/25412019-d85a-2139-22cb-722ec121582c.png)

Argo Workflows は Kubernetes ネイティブなワークフローエンジンで、ジョブのスケジューリングや監視で非常に役立ちます。しかし、ユーザー利用の増加に伴い、ワークフローを定義したマニフェスト（YAML ファイル）が肥大化し、YAML 管理の煩雑さが課題となっていました。この課題を解決するため、Kubernetes マニフェストをプログラミング言語で定義できる「**CDK8s**」を導入しました。CDK8s で条件分岐や繰り返し処理を活用することで、**複雑なリソース定義をシンプル**に記述できるようになり、マニフェスト管理が容易になりました[^1]。

[^1]: CDK8s 導入の詳細な経緯については、[Kubernetes でワークフローを組むなら cdk8s-argoworkflow がよさそう！](https://speakerdeck.com/mhrtech/cdk8s-argoworkflow-is-great)を参照してください。

本システムのデプロイでは、GitOps の原則に基づいた Argo CD を採用しています。Argo CD は Git リポジトリを Single Source of Truth として扱い、Kubernetes クラスタの状態を管理します。 本システムは AWS（開発環境）とオンプレミス（本番環境）のハイブリッド構成となっており、両方の環境で Argo CD を利用しています。

```plantuml
@startuml
left to right direction

actor "開発者" as developer
artifact "GitHub \nリポジトリ" as github
developer -->> github : Push

cloud "AWS（開発環境）" {
  control "Argo CD" as argocd_aws
  node "Kubernetes Cluster" as k8s_aws

  github <.. argocd_aws : Pull
  argocd_aws -->> k8s_aws : Sync
}

frame " オンプレミス（本番環境）" {
  control "Argo CD" as argocd_onprem
  node "Kubernetes Cluster" as k8s_onprem

  github <.. argocd_onprem : Pull
  argocd_onprem -->> k8s_onprem : Sync
}

@enduml
```

この構成において、CDK8s で生成したマニフェストをどのように Argo CD で扱うかが議論となりました。GitHub Actions で CDK8s ソースをビルド・デプロイすることも検討しましたが、オンプレミス環境で GitHub Actions を利用するにはセルフホストランナーの構築・運用が必要となります。既に Argo CD で多数のマニフェストを管理しているため、セルフホストランナーを追加で運用するのはオーバーヘッドが大きいと判断しました[^2]。そのため、**CDK8s を導入する場合でも、既存の Argo CD によるデプロイ方式を維持することにしました。**

[^2]: セキュリティ対策・OSパッチ適用・リソース管理・ネットワーク設定など、運用上の考慮事項が多く発生します。

# 課題

Argo CD によるデプロイ方式を採用しましたが、CDK8s との組み合わせで悩ましい課題がありました。

CDK8s はインフラをコードとして記述し、マニフェストを動的に生成するという特徴を持っています。そのため、生成されたマニフェストを Git リポジトリで管理するのではなく、CDK8s のソースコード自体を管理することが推奨されています。CDK8s プロジェクトで自動生成される `.gitignore` ファイルからも、この思想が示されています。

```txt: .gitignore (CDK8sプロジェクト)
# ~~ Generated by projen. To modify, edit .projenrc.ts and run "npx projen".
# (中略)
/lib
/dist/ # CDK8s が生成したマニフェストは Git 管理対象外
!/.eslintrc.json
!/cdk8s.yaml
!/.projenrc.ts
```

一方、Argo CD は GitOps の原則に基づき、Git リポジトリにコミットされたマニフェスト（YAML ファイル）を唯一の情報源として扱います。Argo CD では YAML ファイルをデプロイ対象として認識するため、**プログラミング言語で記述された CDK8s のソースコードを認識しません。** この違いが CDK8s と Argo CD を連携させる上での課題となりました。

:::note warn
CDK8s で生成されたマニフェストを、Git リポジトリへ無理やりコミットするということもできますが非推奨です。ソースコードとマニフェストの両方を Git 管理してしまうと、両者の整合性を保つことが難しくなります。最新でないマニフェストが環境へ反映されるリスクがあり、GitOps の「Git リポジトリが唯一の情報源」という原則に反します[^3]。
:::

[^3]: それでも本方式を採用するのであれば、GitHub Actions などで Git ライフサイクルに整合性担保の仕組みを導入しましょう。

# 解決方法

この課題を解決するため、 Argo CD リポジトリサーバーでサイドカーコンテナに「**プラグイン**」を導入しました。

Argo CD リポジトリサーバーは、Git リポジトリからアプリケーションの設定ファイルを取得し、Kubernetes クラスターへ適用するための重要なコンポーネントです。Argo CD リポジトリサーバーには以下の特徴があります。

1. **Git リポジトリのキャッシュ**  
  指定された Git リポジトリの内容をローカルにキャッシュします。
2. **マニフェストの生成**  
  Git リポジトリから取得したファイル（Helm や Kustomize）を解析し、Kubernetes へ適用できる形式に変換します。
3. **継続的な同期**  
  Git リポジトリの状態に変更がないか、継続的に監視します。
4. **プラグインのサポート**  
  標準でサポートされていない任意ツールで、マニフェストを生成するためのプラグインをサポートしています。

Argo CD リポジトリサーバーでは、サイドカーコンテナにプラグインを導入できます。プラグインでは、CDK8s などの任意ツールを動作させることができます。プラグインの仕組み・詳細な構成は以下のブログが大変参考になりました。

> ![Argo CD アーキテクチャ](https://raw.githubusercontent.com/hiroki-it/tech-notebook-images/master/images/drawio/blog/argocd/argocd_architecture_repo-server.png)
出典： https://hiroki-hasegawa.hatenablog.jp/entry/2023/05/02/145115#%E4%BB%95%E7%B5%84%E3%81%BF-1

## GitOps ワークフローに CDK8s を組み込む

プラグインの導入により、Argo CD がリポジトリと同期する際に CDK8s のマニフェスト生成とクラスタ反映を自動実行できます。言い換えると、CDK8s でプログラマブルに生成された Kubernetes マニフェストを **GitOps ワークフロー**に組み込むことができます。

```plantuml
@startuml
skinparam titleFontSize 18
title GitOps ワークフロー（例）

| ローカル環境 |
start

:CDK8s プロジェクトの開発;
group CDK8sテスト {
  :スナップショットテストを実行;
  :マニフェストの内容を確認;
  :問題なければスナップショットを更新;
}
:変更をコミット;
:Git リポジトリへプッシュ;

| Git リポジトリ |
:プルリクエストを作成;
package GitHub Actions {
  :静的解析;
  :スナップショットテストを実行
  (ローカルのマニフェスト確認漏れ検知);
}
:コードレビュー;
:マージ;

| Argo CD |
:Git リポジトリの変更を検知;
package プラグイン {
  :CDK8s のビルドを実行;
  :生成されたマニフェストの結合;
}
:Argo CD アプリケーションを更新;
:デプロイ対象のクラスターを更新;

@enduml
```

:::note
CDK8s はプログラミングの柔軟性を活かして動的なマニフェスト生成が可能ですが、GitOps 環境では、Argo CD が扱うマニフェストは**原則として静的なもの**であるべきです。環境ごとの差異は ConfigMap などで管理するのではなく、CDK8s のプログラミングロジック（例えば、TypeScript の `if` 文など）で吸収することを推奨します[^4]。このようにすることで、生成されるマニフェストは Git にコミットされたソースコードと一貫性を保ち、GitOps の原則に沿った運用を実現できます。
:::

[^4]: ただし、最適なアプローチはプロジェクトによって異なります。プロジェクトの規模やチームのスキルセットを考慮し、[CDK8s で Helm チャートを利用](https://cdk8s.io/docs/latest/basics/helm/)するなど他の選択も検討してください。

また、スナップショットテストでは、生成されたマニフェストが意図した通りのものであることを確認できます。スナップショットテストはローカル環境だけでなく、プルリクエスト作成後のタイミングでも自動実行を推奨します。開発者がマニフェストをきちんと確認しているか、簡易的に検証できるためです。

これらを実践することで、GitOps のベストプラクティスである「プルリクエストによる変更管理[^5]」を効果的に実現し、安心してインフラの変更を進めることができます。

[^5]: [GitOpsとは？](https://www.splunk.com/ja_jp/data-insider/gitops.html) から GitOps のベストプラクティスを引用しています。

# 構築方法

構築内容を大きく分けると、以下の 2 つです。

1. **プラグインの導入**  
  CDK8s ビルドに必要なツールを含む Docker イメージを作成し、Argo CD リポジトリサーバーにサイドカーコンテナとしてデプロイします。また、プラグインが生成したマニフェストをArgo CDが認識できるように設定します。
2. **Argo CD アプリケーションの定義**  
  CDK8s を使って Argo CD アプリケーションを定義します。App of Apps パターンを採用することで、Argo CD アプリケーション自体も GitOps で管理できます。

以下では、私がハマったポイントの注意点を交えつつ、具体的に構築方法を解説します。

## 1. プラグインの導入

まず、Argo CD リポジトリサーバーのサイドカーコンテナにプラグインを導入します。以下を行うことで Argo CD と CDK8s を連携させることができます。

### 1.1. サイドカーコンテナ用のカスタムイメージを定義

`nodejs` や `projen` など CDK8s のビルドに必要なツール群を含むカスタム Docker イメージを生成します。本記事では、ベースイメージとして `quay.io/argoproj/argocd:v2.13.1` を利用します。

Dockerfile のサンプルは以下の通りです。

<details><summary>Dockerfile の全体像</summary>

```dockerfile: Dockerfile
ARG ARGOCD_VERSION=v2.13.1
FROM quay.io/argoproj/argocd:${ARGOCD_VERSION}

# Switch to root for the ability to perform install
USER root

RUN apt-get update && apt-get install -y curl \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add \
    && echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list \
    && curl https://baltocdn.com/helm/signing.asc | apt-key add - \
    && echo "deb https://baltocdn.com/helm/stable/debian/ all main" | tee /etc/apt/sources.list.d/helm-stable-debian.list

RUN apt-get update && apt-get install -y \
        nodejs \
        npm \
        apt-transport-https \
        yarn \
        helm \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

RUN set -x \
    && curl -fsSL https://deb.nodesource.com/setup_20.x -o /tmp/setup_node.sh \
    && chmod +x /tmp/setup_node.sh \
    && bash -Ex /tmp/setup_node.sh \
    && apt-get purge -y libnode-dev nodejs npm \
    && apt-get install -y nodejs \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

RUN curl -o /usr/local/bin/sops -L https://github.com/mozilla/sops/releases/download/v3.9.1/sops-v3.9.1.linux \
    && chmod +x /usr/local/bin/sops

RUN npm install -g npm typescript projen cdk8s-cli

# Switch back to non-root user
USER argocd
```

</details>

:::note warn
DockerHub の同名レジストリ [`argoproj/argocd`](https://hub.docker.com/r/argoproj/argocd/tags) では、本記事執筆時点で v2.6.15 までしか公開されていません。v2.6.15 ではサイドカーコンテナを使わずに、Argo CD リポジトリサーバーで直接プラグインを実行する方式がありました。このバージョンでは、サイドカーコンテナも使用できるため、各方式を混在させると問題が発生しやすいです。私はかなりハマりました。詳細は以下の記事を参照してください。
[Using Argo CDs new Config Management Plugins to Build Kustomize, Helm, and More | Codefresh](https://codefresh.io/blog/using-argo-cds-new-config-management-plugins-to-build-kustomize-helm-and-more/)
:::

v2.8 以降はサイドカーコンテナ形式しか利用できないようになっています。従って、Quay.io で公開されている v2.8 以降のイメージ利用を推奨します。

### 1.2. カスタムイメージのビルド・プッシュ

ビルドしたカスタムイメージをコンテナレジストリにプッシュします。GitHub Packages を使用する例は以下の通りです。

```bash: GitHub Packages へのプッシュ例
$ export DOCKER_REGISTRY="xxx"
$ export IMAGE="xxx/xxx/argocd"
$ export USERNAME="yusei-matsuo_xx"
$ export GITHUB_TOKEN="xxx"  # GitHub Personal Access Token

$ docker build -t ${DOCKER_REGISTRY}/${IMAGE}:latest .
$ echo ${GITHUB_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
$ docker push ${DOCKER_REGISTRY}/${IMAGE}:latest
```

### 1.3. プラグイン用の ConfigMap を作成

`cdk8s-plugin-config.yaml` を作成し、プラグインの設定を定義します[^6]。サイドカーコンテナが CDK8s プロジェクトをビルドした後、どのマニフェストをデプロイするか指定します。

[^6]: `cdk8s-plugin-config.yaml` についても CDK8s で構成できます。本記事では手順を簡略化するため、YAML ファイルで記述しています。

```yaml: cdk8s-plugin-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: cdk8s-plugin-config
data:
  plugin.yaml: |
    apiVersion: argoproj.io/v1alpha1
    kind: ConfigManagementPlugin
    metadata:
      name: cdk8s
    spec:
      init:
        command: ["sh", "-c"]
        args: ["npm install && npx projen build"]
      generate:
        command: ["sh", "-c"]
        args: ["find dist -name \"${ARGOCD_ENV_INCLUDE}\" -print0 | xargs -0 -I {} sh -c 'cat {}; echo \"---\"'"]

```

#### spec.init セクション
  
`npm install` と `npx projen build` を実行して CDK8s プロジェクトをビルドします。

#### spec.generate セクション

`dist` ディレクトリ内で `$ARGOCD_ENV_INCLUDE` にマッチするファイルを検索し、Argo CD が理解できる形式として単一のファイルに結合します。複数の YAML ファイルがマッチすることを想定しており、各ファイルの連結箇所を `---` で区切ります。この区切りがないと、正常なマニフェストとして解釈されない場合があります。

環境変数の `ARGOCD_ENV_INCLUDE` については、後述の Argo CD アプリケーションで設定します。

### 1.4. Argo CD リポジトリサーバーのサイドカーコンテナ設定

Argo CD リポジトリサーバーでサイドカーコンテナを使用するように設定します。前述のカスタムイメージを `image` で指定します。

<details><summary>サイドカーコンテナの設定例</summary>

```yaml: argocd-repo-server.yaml (抜粋)
# 中略
containers:
  - name: cdk8s-plugin
    command: [/var/run/argocd/argocd-cmp-server]
    image: xx/xx # カスタムイメージのコンテナレジストリ
    securityContext:
      runAsNonRoot: true
      runAsUser: 999
    volumeMounts:
      - mountPath: /var/run/argocd
        name: var-files
      - mountPath: /home/argocd/cmp-server/plugins
        name: plugins
      - mountPath: /home/argocd/cmp-server/config/plugin.yaml
        subPath: plugin.yaml
        name: cdk8s-plugin-config
      - mountPath: /tmp
        name: cmp-tmp
      - mountPath: /home/argocd
        name: argocd-home
# 中略
volumes:
  - emptyDir: {}
    name: var-files
  - emptyDir: {}
    name: plugins
  - configMap:
      name: cdk8s-plugin-config
    name: cdk8s-plugin-config
  - emptyDir: {}
    name: cmp-tmp
  - emptyDir: {}
    name: argocd-home
```

</details>

#### サイドカーコンテナの補足

- **サイドカーコンテナの役割**  
  Argo CD の `repo-server` は、標準でサポートされていないツールの実行をサイドカーコンテナに委託できます。サイドカーコンテナに CDK8s のプラグインを導入することで、特定のビルドやデプロイプロセスをサポートできます。
- **ボリュームの設定**  
  上記の例では、サイドカーコンテナが必要なファイルや設定にアクセスできるように、いくつかのボリュームをマウントしています。これにより、異なるコンテナ間でデータの共有が可能になります。
- **詳細な設定と実装例**  
  より詳しいサイドカーの設定や Argo CD のアーキテクチャに関する情報は、[Argo CDのマイクロサービスアーキテクチャと自動デプロイの仕組み](https://hiroki-hasegawa.hatenablog.jp/entry/2023/05/02/145115#03-repo-server)を参照してください。この記事は Argo CD の内部動作や設定の詳細について非常に有益です。

### 1.5. 設定の適用と Argo CD リポジトリサーバーの再起動

前述の設定を適用し、Argo CD リポジトリサーバーを再起動します。

```bash
# cdk8sプラグインの設定を適用
$ kubectl apply -n argocd -f cdk8s-plugin-config.yaml
configmap/cdk8s-plugin-config created

# Argo CDリポジトリサーバーの設定を適用
$ kubectl apply -n argocd -f argocd-repo-server.yaml
deployment.apps/argocd-repo-server configured

# リポジトリサーバーの再起動
$ kubectl rollout restart deployment argocd-repo-server -n argocd
deployment.apps/argocd-repo-server restarted
```

リポジトリサーバーの Pod 内で、サイドカーコンテナが正常に起動していることを確認します。以下の通り `cdk8s-plugin` が表示されれば、問題ありません。

```bash
$ kubectl -n argocd get pod <pod_name> -o jsonpath="{.spec.containers[*].name}"
cdk8s-plugin argocd-repo-server
```

サイドカーコンテナにコマンドを実行して Projen がインストールされているか確認します。以下のようなバージョン情報が表示されれば、プラグインの導入が成功しています。

```bash
$ kubectl exec -it -n argocd <pod_name> -c cdk8s-plugin -- npx projen --version
0.90.3 # Projen のバージョン情報
```

これで Argo CD が Git リポジトリと同期する際に、CDK8s プロジェクトのビルドとマニフェスト生成が自動的に実行されます。

## 2. Argo CD アプリケーションの構築

ここからは CDK8s を利用して、Argo CD アプリケーションを定義します。

Argo CD アプリケーションでは、Git リポジトリの情報やデプロイ対象のマニフェストを指定します。Argo CD アプリケーションの定義自体も自動更新するため、**App of Apps パターン**を採用しています。

:::note
**App of Apps パターン**とは、複数の Argo CD アプリケーションを一元管理し、デプロイ構成を効率化する手法です。具体的には、「親の Argo CD アプリケーション」が「子の Argo CD アプリケーション群」を管理します。

```plantuml
@startuml
skinparam titleFontSize 18
title App of Apps パターン
!define RECTANGLE class

package " 親アプリケーション " as Root {
  RECTANGLE ParentApp {
    Argo CD Application
  }
}

package "子アプリケーション" as ChildApps {
  RECTANGLE ChildApp1 {
    Argo CD Application
  }

  RECTANGLE ChildApp2 {
    Argo CD Application
  }

  RECTANGLE ChildApp3 {
    Argo CD Application
  }
}

RECTANGLE Resource1 {
  Kubernetes Resource
}

RECTANGLE Resource2 {
  Kubernetes Resource
}

RECTANGLE Resource3 {
  Kubernetes Resource
}

RECTANGLE Resource4 {
  Kubernetes Resource
}

RECTANGLE Resource5 {
  Kubernetes Resource
}

RECTANGLE Resource6 {
  Kubernetes Resource
}

ParentApp -down-> ChildApp1 : 管理
ParentApp -down-> ChildApp2 : 管理
ParentApp -down-> ChildApp3 : 管理

ChildApp1 -down-> Resource1 : デプロイ
ChildApp1 -down-> Resource2 : デプロイ
ChildApp2 -down-> Resource3 : デプロイ
ChildApp2 -down-> Resource4 : デプロイ
ChildApp3 -down-> Resource5 : デプロイ
ChildApp3 -down-> Resource6 : デプロイ

note right of Root
  子アプリケーションのマニフェストを指定
end note

note right of ChildApps
  ・個別のリポジトリ/設定
  ・リポジトリの変更を監視
end note

@enduml
```

**App of Apps パターン**を採用することで、以下のメリットがあります。

- **子アプリケーションの管理の簡素化**  
  子アプリケーションの追加・削除・更新を親アプリケーションのマニフェスト変更のみで行えます。kubectl コマンドを個別に実行する必要がなく、効率的です。  
- **一元管理**  
  すべての Argo CD アプリケーションを親アプリケーションで一元管理できるため、構成管理が容易になります。
- **自己修復機能**  
  親アプリケーションが子アプリケーションの状態を監視し、意図しない変更を自動的に修正します。

親アプリケーションを一度デプロイした後、Git リポジトリへ Push するだけで**子アプリケーションの追加や削除が自動反映**されます。

:::

App of Apps パターン以外に、ApplicationSet でも Argo CD アプリケーションを自動更新できます。ApplicationSet では、環境ごとのパラメータを指定できるのが特徴です。しかし、CDK8s を利用する場合、環境ごとのパラメータ指定は CDK8s レイヤーの責務です。プログラミング言語の if 文を使用し、**CDK8s ビルド後は静的なマニフェストとする**ことが望ましいです[^7]。CDK8s では ApplicationSet の恩恵が少ないため、シンプルに表現できる App of Apps パターンを採用しました。

[^7]: [AWS CDK ベストプラクティス](https://aws.amazon.com/jp/blogs/news/best-practices-for-developing-cloud-applications-with-aws-cdk/)の「デプロイ時ではなく、合成時に決定する」において、本件が触れられています。CDK8s でも同様の考え方が適用されます。

以下より、CDK8s による Argo CD アプリケーションの構築手順を解説します。

### 2.1. CDK8s プロジェクト設定

CDK8s プロジェクトでは [Projen](https://projen.io/) を利用しています。Projen では `.projenrc.ts` でプロジェクトの設定を一元管理します。

以下は今回の設定例です。[Construct Hub](https://constructs.dev/) で公開されている Argo CD の Construct を利用しています。

```ts: .projenrc.ts
import { cdk8s } from 'projen';
import { UpdateSnapshot } from 'projen/lib/javascript/jest';
const project = new cdk8s.Cdk8sTypeScriptApp({
  cdk8sVersion: '2.3.33',
  cdk8sCliVersion: '2.198.273',
  cdk8sPlus: true,
  typescriptVersion: '5.3.3',
  defaultReleaseBranch: 'main',
  name: 'cdk8s',
  projenrcTs: true,
  prettier: true,
  prettierOptions: {
    settings: { printWidth: 100, singleQuote: true },
  },
  jestOptions: { updateSnapshot: UpdateSnapshot.NEVER },
  deps: [
    '@opencdk8s/cdk8s-argoworkflow',
    '@opencdk8s/cdk8s-argocd-resources', // Argo CD の Construct
    'constructs@^10.1.42',
  ],
  devDeps: ['@types/jest', 'constructs@^10.1.42'],
});
project.synth();
```

Projen がインストールされた環境に上記を反映し、`npx projen`を実行することで同様のプロジェクトが生成されます。

### 2.2. ソースコードの配置

CDK8s プロジェクトで構築するソースコードは、Projen のデフォルト設定に沿って `src` ディレクトリに配置します。`src` 配下のディレクトリ構成例は以下の通りです。

```txt
src/
├── charts/
│   ├── argocd.ts         # Argo CD アプリケーションのリソース定義
│   ├── clean.ts          # Argo Workflows (Clean)の定義
│   ├── extract.ts        # Argo Workflows (Extract)の定義
│   └── search.ts         # Argo Workflows (Search)の定義
├── config/
│   └── commonSettings.ts # 共通設定
└── main.ts               # エントリーポイント
```

本記事では、上記の `charts/argocd.ts` と `main.ts` を解説します。その他のソースについては省略しますが、以下で Argo Workflows の CDK8s コード例を掲載しています。詳細が気になる方は、以下を参考してください。

https://qiita.com/y_matsuo_/items/0eff5ea550c2cb0d1794

### 2.3. Argo CD アプリケーションのリソース定義

Argo CD アプリケーション を定義した `charts/argocd.ts` のコード全量は以下の通りです。Argo CD のプロジェクトはデフォルトにしていますが、CDK8s で定義することも可能です。

<details><summary>charts/argocd.ts の全体像</summary>

```ts: charts/argocd.ts
import * as argo from '@opencdk8s/cdk8s-argocd-resources';
import { Chart } from 'cdk8s';
import { Construct } from 'constructs';
import { IConfig, GitRepoUrl, pascalToKebabCase } from '../config/commonSettings';

export interface ArgoCdParentProps extends IConfig {
  childManifestPrefix: string;
}

export interface ArgoCdChildProps extends IConfig {
  manifestPrefixes: string[];
}

const syncPolicy: argo.ApplicationSyncPolicy = {
  automated: {
    prune: true,
    selfHeal: true,
  },
  retry: {
    limit: 3,
    backoff: {
      duration: '10s',
      maxDuration: '1m0s',
      factor: 2,
    },
  },
  syncOptions: ['CreateNamespace=true'],
};

const project = 'default';

const destination: argo.ApplicationDestination = {
  namespace: `argocd-cdk8s`,
  server: 'https://kubernetes.default.svc',
};

const createGitSource = (env: 'Prod' | 'Dev'): argo.ApplicationSource => ({
  repoURL: GitRepoUrl,
  targetRevision: env === 'Prod' ? 'main' : 'develop',
  path: 'infra/cdk8s',
});

export class ArgoCdParentChart extends Chart {
  constructor(scope: Construct, id: string, props: ArgoCdParentProps) {
    super(scope, id, props);

    new argo.ArgoCdApplication(this, `ArgoCdAppParent`, {
      metadata: {
        name: `${props.env.toLocaleLowerCase()}-cdk8s-parent`,
        namespace: 'argocd',
        finalizers: ['resources-finalizer.argocd.argoproj.io'],
      },
      spec: {
        destination,
        source: {
          ...createGitSource(props.env),
          plugin: {
            name: 'cdk8s',
            env: [
              {
                name: 'INCLUDE',
                value: `${props.childManifestPrefix.toLocaleLowerCase()}-*.k8s.yaml`,
              },
            ],
          },
        },
        project,
        syncPolicy,
      },
    });
  }
}

export class ArgoCdChildChart extends Chart {
  constructor(scope: Construct, id: string, props: ArgoCdChildProps) {
    super(scope, id, props);

    for (const manifestPrefix of props.manifestPrefixes) {
      new argo.ArgoCdApplication(this, `ArgoCdApp${manifestPrefix}`, {
        metadata: {
          name: pascalToKebabCase(manifestPrefix),
          namespace: 'argocd',
        },
        spec: {
          destination,
          source: {
            ...createGitSource(props.env),
            plugin: {
              name: 'cdk8s',
              env: [
                {
                  name: 'INCLUDE',
                  value: `${manifestPrefix.toLocaleLowerCase()}-*.k8s.yaml`,
                },
              ],
            },
          },
          project,
          syncPolicy,
        },
      });
    }
  }
}
```

</details>

以下では `charts/argocd.ts` の実装例を解説します。各設定項目の動作については全て紹介できないため、必要に応じて [Argo CD のユーザーガイド](https://argo-cd.readthedocs.io/en/stable/user-guide/application-specification/)もご参考ください。

#### 同期ポリシーの設定

まず、Argo CD の同期ポリシーを設定します。

```typescript
const syncPolicy: argo.ApplicationSyncPolicy = {
  automated: {
    prune: true, 
    selfHeal: true,
  },
  retry: {
    limit: 3,
    backoff: {
      duration: '10s',
      maxDuration: '1m0s',
      factor: 2,
    },
  },
  syncOptions: ['CreateNamespace=true'],
};
```

Argo CD ユーザーガイドの [Automated Sync Policy](https://argo-cd.readthedocs.io/en/stable/user-guide/auto_sync/)に記載はありませんが、retry も設定できます。`retry` は同期に失敗した場合のリトライ回数や間隔を指定します。

#### Git リポジトリの定義

次に、Gitリポジトリの情報を定義します。環境によってブランチ名を切り替えるため、環境識別子 `env` を外部から受け取ります。

```ts
const createGitSource = (env: 'Prod' | 'Dev'): argo.ApplicationSource => ({
  repoURL: GitRepoUrl,
  targetRevision: env === 'Prod' ? 'main' : 'develop', // 環境に応じてブランチ名を切替
  path: 'infra/cdk8s', // CDK8s プロジェクトのパス
});
```

#### 親アプリケーションの定義

親アプリケーションとして、ArgoCdParentChart クラスを定義します。`metadata.finalizers` の設定が、親アプリケーションの特徴です。ここに `resources-finalizer.argocd.argoproj.io` を指定することで、親アプリケーションの削除時に子アプリケーションのリソースも削除されます。

```ts: 親の Argo CD アプリケーション
export interface ArgoCdParentProps extends IConfig {
  childManifestPrefix: string; // 子アプリに関するマニフェスト名のプレフィックス
}

export class ArgoCdParentChart extends Chart {
  constructor(scope: Construct, id: string, props: ArgoCdParentProps) {
    super(scope, id, props);

    new argo.ArgoCdApplication(this, `ArgoCdAppParent`, {
      metadata: {
        name: `${props.env.toLocaleLowerCase()}-cdk8s-parent`,
        namespace: 'argocd',
        finalizers: ['resources-finalizer.argocd.argoproj.io'], // 親アプリ削除時に子アプリのリソースを削除
      },
      spec: {
        destination,
        source: {
          ...createGitSource(props.env),
          /* プラグインの設定 */
          plugin: {
            name: 'cdk8s',
            env: [
              {
                name: 'INCLUDE', // サイドカーコンテナに渡す環境変数名
                value: `${props.childManifestPrefix.toLocaleLowerCase()}-*.k8s.yaml`,　// 子アプリのマニフェスト名
              },
            ],
          },
        },
        project,
        syncPolicy,
      },
    });
  }
}
```

プラグインの利用において、重要なポイントは以下です。

:::note

- `source.plugin.env` を指定することで、プラグインのサイドカーコンテナに環境変数を渡せます。ここで指定した環境変数（INCLUDE） は [cdk8s-plugin-config.yaml](#3-プラグイン用の-configmap-を作成) で定義した generate コマンドで使用されます。サイドカーコンテナでは、環境変数のプレフィックスに `ARGOCD_ENV_` が付与される点に注意が必要です。例えば、`INCLUDE` は `ARGOCD_ENV_INCLUDE` として渡されます。
- `INCLUDE` の値には、子アプリケーションのマニフェスト名を指定します。この例では小文字化した `props.childManifestPrefix` を指定しており、外部から子アプリケーションのマニフェスト名（プレフィックス）が渡されることを想定しています。

:::

#### 子アプリケーションの定義

子アプリケーションとして、ArgoCdChildChart クラスを定義します。

```ts: 子アプリケーション
export interface ArgoCdChildProps extends IConfig {
  manifestPrefixes: string[]; // マニフェスト名のプレフィックス群
}

export class ArgoCdChildChart extends Chart {
  constructor(scope: Construct, id: string, props: ArgoCdChildProps) {
    super(scope, id, props);

    for (const manifestPrefix of props.manifestPrefixes) {
      new argo.ArgoCdApplication(this, `ArgoCdApp${manifestPrefix}`, {
        metadata: {
          name: pascalToKebabCase(manifestPrefix),
          namespace: 'argocd',
        },
        spec: {
          destination,
          source: {
            ...createGitSource(props.env),
            /* プラグインの設定 */
            plugin: {
              name: 'cdk8s',
              env: [
                {
                  name: 'INCLUDE', // サイドカーコンテナに渡す環境変数名
                  value: `${manifestPrefix.toLocaleLowerCase()}-*.k8s.yaml`, // 子アプリがデプロイするマニフェスト名
                },
              ],
            },
          },
          project,
          syncPolicy,
        },
      });
    }
  }
}
```

子アプリケーションのポイントは以下です。 

:::note

- for 文で複数の子アプリケーションを一括で定義するため、`props.manifestPrefixes` では配列でマニフェスト名のプレフィックスを受け取ります。配列の数だけ、子アプリケーションが生成されます。
- 親アプリケーションと同様に、`source.plugin.env` に環境変数を指定します。`INCLUDE` の値には、子アプリケーションでデプロイしたいマニフェスト名を指定します。この例では `props.manifestPrefixes` から取り出した要素を、小文字化して渡しています。

:::

### 2.4. エントリーポイントのリソース定義

ここでは `main.ts` の実装例を簡単に紹介します。`main.ts` では、Argo CD アプリケーションとその子アプリケーションを環境ごとに定義します。Argo Workflows の定義も含め、全体のリソース構成を一元管理できます。

```ts: main.ts
import { App } from 'cdk8s';
import { ArgoCdParentChart, ArgoCdChildChart } from './charts/argocd';
import { CleanChart } from './charts/clean';
import { ExtractChart } from './charts/extract';
import { SearchChart } from './charts/search';
import { IConfig } from './config/commonSettings';

const app = new App();
const envList: IConfig[] = [{ env: 'Prod' }, { env: 'Dev' }];

// 環境ごとに Argo CD アプリケーションと配下のリソースを定義
for (const env of envList) {
  // Argo Workflows の定義
  const search = new SearchChart(app, `${env.env}SearchWorkflow`, { env: env.env });
  const extract = new ExtractChart(app, `${env.env}ExtractWorkflow`);
  const clean = new CleanChart(app, `${env.env}CleanWorkflow`);

  // 子アプリケーションの定義
  const child = new ArgoCdChildChart(app, `${env.env}ArgoCdChild`, {
    env: env.env,
    manifestPrefixes: [search.node.id, extract.node.id, clean.node.id],
  });

  // 親アプリケーションの定義
  new ArgoCdParentChart(app, `${env.env}ArgoCdParent`, {
    env: env.env,
    childManifestPrefix: child.node.id,
  });
}

app.synth();
```

Argo CD の各アプリケーションへ渡している `xx.node.id` は、CDK8s の Construct ID です。各種 Chart の 第2引数に指定した ID が、そのまま Construct ID として使用されます。Construct ID は、CDK8s のリソースを一意に識別するための ID です。

Construct ID は Chart から生成される YAML ファイルの名前に使用されます[^8]。実際の YAML ファイル一覧を確認してみましょう。`npx projen build` を実行すると、dist デイレクトリ配下で Chart ごとに YAML ファイルが生成されます。

```bash
$ npx projen build
👾 build » default | ts-node --project tsconfig.dev.json .projenrc.ts
👾 build » compile | tsc --build
👾 build » post-compile » synth | cdk8s synth
Synthesizing application
  # dev 環境のマニフェスト
  - dist/devargocdchild-c85ba404.k8s.yaml    # 子アプリケーション
  - dist/devargocdparent-c842aaac.k8s.yaml   # 親アプリケーション
  - dist/devcleanworkflow-c8384248.k8s.yaml  
  - dist/devextractworkflow-c89f61e5.k8s.yaml 
  - dist/devsearchworkflow-c8a39644.k8s.yaml
  # prod 環境のマニフェスト
  - dist/prodargocdchild-c8f03f35.k8s.yaml   # 子アプリケーション
  - dist/prodargocdparent-c838bf1a.k8s.yaml  # 親アプリケーション
  - dist/prodcleanworkflow-c8d2b803.k8s.yaml
  - dist/prodextractworkflow-c8d32e2c.k8s.yaml
  - dist/prodsearchworkflow-c83d7141.k8s.yaml
```

各 YAML ファイル名のプレフィックスに、Chart の Construct ID（第2引数に指定した値）が小文字化して付与されています。

[^8]: [YamlOutputType](https://cdk8s.io/docs/latest/reference/cdk8s/typescript/#cdk8s.YamlOutputType) により、CDK8s で生成されるマニフェストの出力形式を変更できます。デフォルトでは Chart ごとに YAML ファイルが出力されますが、Chart 内リソースごとの出力や単一ファイルでの出力も設定できます。

# 挙動の確認

実際にどのような挙動になるか、開発環境（dev）へのデプロイを確認します。あくまで挙動確認が目的なので、プルリクエスト作成などの厳密な GitOps ワークフローは省略します。

前述の `npx projen build` をローカル環境で実行し、生成されたマニフェストを Argo CD に適用します。以下コマンドで、最初の一回のみ親アプリケーションを適用すれば大丈夫です。親アプリケーションが環境に反映されると、数分以内に子アプリケーションも自動反映されます。

```bash:
# dev 環境の親アプリケーションを適用
$ kubectl -n argocd apply -f dist/devargocdparent-*.k8s.yaml
application.argoproj.io/dev-cdk8s-parent created
```

Argo CD の管理コンソールで、確認してみましょう。`dev-cdk8s-parent` が親アプリケーションに該当します。子アプリケーションの `dev-xx-workflow` が自動追加されています。

![dev-cdk8s-parent-1](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2989026/300e62b7-b3d6-4f5a-6b7c-7479bf57d9fc.png)

`dev-search-workflow` を開くと、子の Argo CD アプリケーションから K8s クラスターへ反映されたマニフェストを確認できます。

![dev-search-workflow-1](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2989026/4eae975c-d7fe-aa69-7440-1d0defaaec4e.png)

これらのマニフェストは、Argo CD に導入した CDK8s プラグインに基づいて自動生成されています。Git リポジトリの CDK8s ソースが変更されると、上記も自動更新されます。

挙動確認のため、CDK8s プロジェクトのリソース定義ファイルを変更してみましょう。デプロイ対象を管理している変数に以下を追加します[^9]。

[^9]: あくまで挙動確認なので、具体的な値は気にしないでください。

```diff_typescript: config/commonSettings.ts
export const ConfigList: IConfigList[] = [
+ {
+   indexName: '0-test',
+   secret: 'secret-test',
+   targets: [
+     {
+       smbRemoteName: 'EI6-4-SV01',
+       smbShareDir: 'Qiita',
+       targetPath: 'テスト',
+     },
+   ],
+ },
  {
    indexName: '2j4',
    secret: 'secret-2j4',
    targets: [
```

上記の変更について、マニフェストに問題がないかスナップショットテストで確認します。

```bash
$ npx projen test
👾 test | jest --passWithNoTests --ci
 FAIL  test/workflow.test.ts
  ● Workflow › SearchProd

    expect(received).toMatchSnapshot()

    Snapshot name: `Workflow SearchProd 1`

    - Snapshot  -  0
    + Received  + 65

    @@ -109,10 +109,68 @@
          "kind": "WorkflowTemplate",
          "metadata": {
            "labels": {
              "type": "search",
            },
    +       "name": "0-test-search",
    +       "namespace": "argo",
    +     },
    +     "spec": {
    +       "entrypoint": "0-test-search",
    +       "templates": [
    +         {
    +           "name": "0-test-search",
    +           "steps": [
    # (途中結果を省略)
Snapshot Summary
 › 4 snapshots failed from 1 test suite. Inspect your code changes or re-run jest with `-u` to update them.

Test Suites: 1 failed, 1 passed, 2 total
Tests:       4 failed, 4 passed, 8 total
Snapshots:   4 failed, 4 passed, 8 total
Time:        3.679 s, estimated 4 s
Ran all test suites.
```

マニフェストの変更に問題なければ、```npx projen test -u```でスナップショットを更新します。次にソースをコミットし、Git リポジトリに Push します[^10]。

[^10]: ここではあくまで挙動確認のため、プルリクエストを作成していません。実開発では直接 Push せず、プルリクエスト作成およびレビュー後にマージすることを推奨します。詳細は[解決方法](#解決方法)のワークフロー例を参考にしてください。

```bash
$ git add .
$ git commit -m "テスト用にリソース追加" && git push
```

数分経過後に、Argo CD の管理コンソールから `dev-search-workflow` を確認します。Git リポジトリの変更を検知して、新たなリソースが追加されています。

![dev-search-workflow-2](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2989026/3af72438-2880-3804-a4e5-d9a68cc957de.png)

## 子アプリケーション自身の自動更新

CDK8s で定義した子アプリケーション自体も、Git リポジトリへの Push のみで自動更新されることを確認します。ここでは子アプリケーションの `xx-search-workflow` が不要になったと仮定します。

まず、main.ts から SearchChart 関連のリソースを取り除きます。`ArgoCdChildChart` の `manifestPrefixes` に SearchChart の Construct ID を含めないように変更します。

```diff_typescript: main.ts
import { App } from 'cdk8s';
import { ArgoCdParentChart, ArgoCdChildChart } from './charts/argocd';
import { CleanChart } from './charts/clean';
import { ExtractChart } from './charts/extract';
- import { SearchChart } from './charts/search';
import { IConfig } from './config/commonSettings';

const app = new App();
const envList: IConfig[] = [{ env: 'Prod' }, { env: 'Dev' }];

for (const env of envList) {
- const search = new SearchChart(app, `${env.env}SearchWorkflow`, { env: env.env });
  const extract = new ExtractChart(app, `${env.env}ExtractWorkflow`);
  const clean = new CleanChart(app, `${env.env}CleanWorkflow`);

  const child = new ArgoCdChildChart(app, `${env.env}ArgoCdChild`, {
    env: env.env,
-   manifestPrefixes: [search.node.id, extract.node.id, clean.node.id],
+   manifestPrefixes: [/**search.node.id,**/ extract.node.id, clean.node.id], // 一部コメントアウト
  });

  new ArgoCdParentChart(app, `${env.env}ArgoCdParent`, {
    env: env.env,
    childManifestPrefix: child.node.id,
  });
}

app.synth();
```

前述と同様、上記の変更についてスナップショットテストを実行・更新します。その後、コミットしたソースを Git リポジトリに Push します[^10]。

```bash
$ git add src/main.ts 
$ git commit -m "挙動確認用にsearch処理をコメントアウト" && git push
```

数分経過後に、Argo CD の管理コンソールから親アプリケーションを確認します。子アプリケーション群から `dev-search-workflow` が削除され、`dev-cdk8s-parent` と `dev-extract-workflow` のみ残っていることが確認できます。

![dev-cdk8s-parent-2](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2989026/5e9d5cf9-ef85-d8f3-b155-26e8d8949833.png)

子アプリケーションにはデプロイ設定が直接定義されており、これらも自動更新されるのは嬉しいポイントです。上記は単なる削除でしたが、子アプリケーションの増加やデプロイ先切替も kubectl の実行は不要です。Git リポジトリへの Push のみでデプロイ設定自体が変更されるのは CDK Pipelines の Self Mutation に近い開発者体験だと感じました[^11]。

[^11]: CDK Pipelines については、[AWS Black Belt Online Seminar AWS CDKの開発を効率化する機能 (Basic #3)](https://pages.awscloud.com/rs/112-TZM-766/images/AWS-Black-Belt_2023_AWS-CDK-Basic-3-AppDev_0831_v1.pdf#page=29)を参照してください。

# おわりに

本記事では CDK8s の GitOps を実現するため、Argo CD のプラグインとアプリケーション構築方法を解説しました。プラグインを導入することで、CDK8sで生成したマニフェストを Argo CD でデプロイすることができます。この手法の嬉しいポイントは以下の通りです。

- **デプロイを Argo CD に一元化**  
  通常のマニフェストに加えて、CDK8s で生成したマニフェストも Argo CD でデプロイできる。
- **デプロイ設定の自動更新**  
  Git リポジトリへの Push のみで、Argo CD アプリケーションの追加や削除が自動反映される。
- **少ない記述で柔軟なリソース定義**  
  CDK8s のプログラム性により、各リソースを少ない記述で柔軟に定義できる。

私は Kubernetes、Argo CD の経験が浅く、プラグインを動作させるまでに多くの時間を要しました。苦労したものの、kubectl など多数のコマンドを叩いたので色々と勉強になりました。一方、CDK8s での Argo CD アプリケーション実装はとても簡単でした。Construct の提供者に感謝申し上げます。

CDK8s のコンセプト・開発者体験は非常に素晴らしいです。私は CDK8s 以外に AWS CDK も愛用していますが、生で YAML ファイルを書くという手法には戻れません。本記事が CDK8s を利用・検討している方の参考になれば幸いです。

# 参考資料

- [【ArgoCD🐙】ArgoCDのマイクロサービスアーキテクチャと自動デプロイの仕組み - 好きな技術を布教したい 😗](https://hiroki-hasegawa.hatenablog.jp/entry/2023/05/02/145115)
- [Integrating cdk8s with Argo CD](https://shipit.dev/posts/integrating-cdk8s-with-argocd.html)
- [GitHub - kurzdigital/argocd-cdk8s-plugin: CDK8S plugin for ArgoCD](https://github.com/kurzdigital/argocd-cdk8s-plugin)
- [@opencdk8s/cdk8s-argocd-resources 0.0.8 - Construct Hub](https://constructs.dev/packages/@opencdk8s/cdk8s-argocd-resources/v/0.0.8?lang=typescript)
- [Argo CDのApp of apps patternは何が嬉しいのか | DevelopersIO](https://dev.classmethod.jp/articles/argocd-app-of-apps-pattern/)
- [Argo CDのApplication SetとApp of apps patternの違いと使い分け | DevelopersIO](https://dev.classmethod.jp/articles/argo-cd-application-set-app-of-apps-pattern/)
- [GitHub - christianh814/app-of-apps-example: App of Apps example with sync waves](https://github.com/christianh814/app-of-apps-example)