Renovate でクラウドネイティブなエコシステムのバージョン更新に追従する方法

これは [GLOBIS Advent Calendar 2022](https://qiita.com/advent-calendar/2022/globis) の2日目の記事です。

## はじめに

弊社では主に AWS と EKS (Kubernetes) を使ってサービスインフラを運用していて、その管理ツールとして Terraform や Helm などを採用しています。
それらの運用を続けていると、Terraform Provider や 3rd-party の Helm パッケージ、Docker イメージなど、様々な依存関係のバージョンを更新する必要が生じます。始めは手作業でも何とかなるかもしれませんが、次第に苦痛な作業となってしまいます。
アプリケーション開発においては依存パッケージの自動更新ツールを導入するのは一般的になってきており、インフラ領域でも少し工夫すれば Renovate を導入して自動化できるということを紹介したいと思います。

## Terraform について

### 依存関係

Terraform 本体のバージョンや各種 provider や module のバージョンなどを Renovate で管理しています。ちなみに Kubernetes や Helm の provider を使った場合はリソースの定義（内部の Docker イメージや Helm chart のバージョンなど）まで見てくれるのでかなり賢いです。

### ディレクトリ構成

前提として、Terraform はディレクトリ単位（= Working Directory）で state が分割されるので、依存関係も Working Directory ごとに管理するのが自然です。
弊社の場合は、複数プロダクトのリソースが1つの AWS アカウントに存在するマルチテナントを採用しています。よって機能ごとに分割するために、まずは VPC やネットワークなどのプロダクト間で共有するリソースを `base` という単位で切り出したあと、プロダクト単位で分割しています。
そして本番環境、ステージング環境などの各環境ごとに AWS アカウントが存在するので、 `(プロダクト数) * (環境数)` のオーダーで Working Directory ができます。（Terraform Workspace の機能は使っていません）

```
root
├── base
│   ├── modules
│   ├── dev
│   ├── stg
│   └── prod
├── productA
│   ├── modules
│   ├── dev
│   ├── stg
│   └── prod
└── productB
    ├── modules
    ├── dev
    ├── stg
    └── prod
```

### CI/CD

Terraform の CI/CD には Atlantis を採用しています。

https://qiita.com/chroju/items/f77e8391d6ef7c7cb59a

よって Renovate が PR を作成すると自動で terraform plan が走り、apply まで完了したら PR をマージする運用となります。

### Renovate for Terraform

要件をまとめるとこのようになります。

- `/$product/$env/` というディレクトリ構成で Working Directory を分割している
- Working Directory ごとに Renovate の PR をまとめたい
- PR が作成されると CI による terraform plan が実行される

まずは、Renovate が PR をまとめる仕組みを解説します。
Terraform の場合、Renovate は検出した Working Directory に対して依存関係ごとに個別に PR を作ろうとします。
その際に、決められた規則を基に `branchName` を計算して、同じ値となった PR はグルーピングされます。ドキュメントによると `branchName` のデフォルトは

```
 {{{branchPrefix}}}{{{additionalBranchPrefix}}}{{{branchTopic}}} 
```

で、各テンプレート変数は次のようなデフォルト値を持ちます。

| Template Field | Default Value |
| --- | --- |
| `branchPrefix` | `renovate/` |
| `additionalBranchPrefix` | `""` |
| `branchTopic` | 依存関係によって決まる（ `aws-4.x` など） |

そのためデフォルトの設定のままだと依存関係単位で PR をまとめようとします。

![terraform-renovate-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199914/dc422192-548c-b7ce-3b1e-4d17f9897794.png)

これを Working Directory 単位でまとめるために、先ほどのテンプレート変数を設定ファイルで次のように上書きします。

| Template Field | Value | 補足 |
| --- | --- | --- |
| `branchPrefix` | `renovate/` | デフォルト値から変更なし |
| `additionalBranchPrefix` | `{{packageFileDir}}-` | リポジトリのルートから Working Directory へのパスが格納された `packageFileDir` を使う |
| `branchTopic` | `terraform`  | 依存関係によらない固定値を入れる<br>`groupName` 経由で上書きできる |

こうすることで、`branchName` が `renovate/base/prod-terraform` のような Working Directory 固有の形式になり、PR が Working Directory 単位でまとめられるようになります。

![terraform-renovate-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199914/e459bbac-ccbe-b4d2-f597-656ba9f65a20.png)


以上を踏まえた設定ファイルの例がこちらです。

```json:renovate.json
{
  "extends": [
    "config:base"
  ],
  "rebaseWhen": "never",
  "packageRules": [
    {
      "matchManagers": [
        "terraform"
      ],
      "additionalBranchPrefix": "{{packageFileDir}}-",
      "commitMessageSuffix": "({{packageFileDir}})",
      "groupName": "terraform"
    }
  ]
}
```

`commitMessageSuffix` にも `packageFileDir` を追加することで、どの Working Directory か判断しやすくしています。ちなみにこのオプションはコミットメッセージだけではなく、明示的に指定しない限り PR のタイトルにも反映されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199914/0d3fd211-2372-8923-26e5-02848fe7cf41.png)

また、Atlantis が自動実行されてしまうのを防ぐために `rebaseWhen` の値は `never` にしています。この辺りは適切なオプションを指定することで各々最強の Renovate 環境を目指していきたいところです。

### 課題

このようにまとめてもかなりの頻度で PR が作られるので、terraform plan で差分が出ない場合は GitHub の機能を使って自動的にマージしたくなります。
ただ現状 Atlantis と GitHub の標準機能だけではいい感じの自動マージは実現できず、GitHub Actions でロジックを作り込むか Atlantis 本体に修正を入れるかしないといけないという状況なので、いずれ何とかしたいなと思っています。

## Kubernetes について

### 依存関係

K8s を本番運用するためには Cluster Autoscaler などの様々なアドオンやアプリケーションが必須で、これらは Kustomize や Helm、素のマニフェストを通してデプロイされます。
その管理方法は設計次第かと思いますが、弊社では Helm を採用しているので Helm chart のバージョンと Docker イメージのタグを Renovate で管理しています。

### ディレクトリ構成

ローカルの chart の dependency として依存する外部 chart を扱っているので以下ような構成にしています。（一部抜粋）

```
root
├── aws-load-balancer-controller
│   ├── charts
│   │   └── aws-load-balancer-controller-1.4.5.tgz
│   ├── env
│   │   ├── dev.yaml
│   │   ├── stg.yaml
│   │   └── prod.yaml
│   ├── Chart.yaml
│   ├── Chart.lock
│   └── values.yaml
│
└── cluster-autoscaler
    ├── charts
    │   └── cluster-autoscaler-9.21.0.tgz
    ├── env
    │   ├── dev.yaml
    │   ├── stg.yaml
    │   └── prod.yaml
    ├── Chart.yaml
    ├── Chart.lock
    └── values.yaml
```

少し具体的に説明すると、依存する chart のバージョンは `Chart.yaml` で指定します。

```yaml:Chart.yaml
apiVersion: v2
name: cluster-autoscaler
version: 1.0.0 # ローカル chart のバージョンなので無関係

dependencies:
- name: cluster-autoscaler
  version: 9.21.0 # 依存する chart のバージョン
  repository: https://kubernetes.github.io/autoscaler
```

そしてイメージのタグは `values.yaml` で指定します。

```yaml:values.yaml
cluster-autoscaler:
  image:
    repository: k8s.gcr.io/autoscaling/cluster-autoscaler
    tag: v1.22.3
  ...
```

また、環境ごとに複数の k8s クラスタを管理していると `values.yaml` の一部をクラスタ固有の値で上書きしたくなるので `env` ディレクトリの中に追加の yaml を配置しています。

### CI/CD

マニフェストのデプロイには Argo CD を採用しています。
Helm dependency を経由しているので Application の定義には依存関係の情報が一切出てきません。
`repoURL` には Helm ではなく GitHub リポジトリの URL を指定します。

```yaml:application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: cluster-autoscaler
  namespace: argocd
spec:
  ...
  source:
    helm:
      valueFiles:
        - env/prod.yaml # クラスタ固有の設定値で values.yaml を上書きする
    path: cluster-autoscaler
    repoURL: 'https://github.com/org/repo'
    targetRevision: HEAD
  ...
```

また、Renovate によって PR が作られたらデプロイされるマニフェストの差分を確認したくなりますが、Argo CD にはその機能はありません。
本題から逸れるので詳細は割愛しますが、GitHub Actions で helm template を実行して自動コミットするような機能を作り込んでいます。

### Renovate for Helm

要件をまとめるとこのようになります。

- Helm dependency 経由で入れている外部 chart のバージョンを自動更新したい
- Helm values の中の Docker イメージのバージョンを自動更新したい
- デフォルトの `values.yaml` 以外にも values ファイルが存在する
- これらの依存関係を chart ごとに1つの PR にまとめたい

これらの要件を満たす設定ファイルの例がこちらです。

```json:renovate.json
{
  "extends": [
    "config:base"
  ],
  "helm-values": {
    "fileMatch": [
      "(^|/)values\\.yaml$",
      "/env/(dev|stg|prod)\\.yaml$"
    ]
  },
  "packageRules": [
    {
      "matchManagers": [
        "helmv3",
        "helm-values"
      ],
      "additionalBranchPrefix": "{{{replace '^(.*?)(\\/.*)*$' '$1' packageFileDir}}}-",
      "commitMessageSuffix": "({{{replace '^(.*?)(\\/.*)*$' '$1' packageFileDir}}})",
      "groupName": "helm"
    }
  ]
}
```

Renovate の manager としては `helmv3` が `Chart.yaml` の依存 chart のバージョンに、 `helm-values` が `values.yaml` の Docker イメージのタグに対応しています。
また `values.yaml` 以外の values ファイルを読み込ませるために `fileMatch` に条件を追加しています。
しかし、ディレクトリの構成上、通常の `values.yaml` と環境ごとの values ファイルを Renovate に認識させると `packageFileDir` が以下のように異なってしまいます。

- `cluster-autoscaler`
- `cluster-autoscaler/env`

そのため、正規表現でトップレベルのディレクトリだけ抜き出して `additionalBranchPrefix` に設定しています。

ここからは更に細かい話ですが、今回例に挙げた Cluster Autoscaler と AWS Load Balancer Controller には個別の対応をしており、考え方自体は他にも応用できるので紹介します。

### Cluster Autoscaler

Kubernetes クラスタ自体のマイナーバージョンと Cluster Autoscaler のマイナーバージョンを合わせる必要があるので、 `allowedVersions` でマイナーバージョンが上がらないように制限を入れています。

```json:renovate.json
{
  "packageRules": [
    {
      "matchPackagePatterns": ["cluster-autoscaler"],
      "matchDatasources": ["docker"],
      "allowedVersions": "<= 1.22",
      "dependencyDashboardApproval": true
    }
  ]
}
```

### AWS Load Balancer Controller

執筆時点では ALBC の Docker イメージが ECR のプライベートリポジトリに存在するため、自動更新できません。
Docker Hub には `amazon/aws-alb-ingress-controller` という名前でホストされていますが Pull レート制限があるので直接使いたくないのが正直なところです。
そこで、Renovate による自動更新のときだけ Docker Hub からタグ情報を取得するように Regex manager の機能を使って書き換えています。

```json:renovate.json
{
  "packageRules": [
    {
      "matchPackagePatterns": [
        "602401143452.dkr.ecr.ap-northeast-1.amazonaws.com/amazon/aws-load-balancer-controller"
      ],
      "enabled": false
    }
  ],
  "regexManagers": [
    {
      "fileMatch": [
        "(^|/)values\\.yaml$",
        "/env/(dev|stg|prod)\\.yaml$"
      ],
      "matchStrings": ["# renovate: depName=(?<depName>.*?)\\s*repository: .*?\\s*tag: (?<currentValue>.*?)\\s"],
      "datasourceTemplate": "docker",
      "versioningTemplate": "docker"
    }
  ]
}
```

まず既存の ECR リポジトリは自動更新を無効化して、 `regexManagers` に対象ファイルと認識させたい文字列を正規表現で追加します。
そしてその正規表現にマッチするように `values.yaml` を修正します。

```yaml:values.yaml
aws-load-balancer-controller:
  image:
    # renovate: depName=docker.io/amazon/aws-alb-ingress-controller
    repository: 602401143452.dkr.ecr.ap-northeast-1.amazonaws.com/amazon/aws-load-balancer-controller
    tag: v2.4.4
```

このように Regex manager はかなり強力な機能で、使い方次第で任意のファイルの任意の依存関係を更新できるようになります。
最終的な設定ファイルと作成される PR の例は以下の通りです。

```json:renovate.json
{
  "extends": [
    "config:base"
  ],
  "helm-values": {
    "fileMatch": [
      "(^|/)values\\.yaml$",
      "/env/(dev|stg|prod)\\.yaml$"
    ]
  },
  "packageRules": [
    {
      "matchManagers": [
        "helmv3",
        "helm-values",
        "regex"
      ],
      "additionalBranchPrefix": "{{{replace '^(.*?)(\\/.*)*$' '$1' packageFileDir}}}-",
      "commitMessageSuffix": "({{{replace '^(.*?)(\\/.*)*$' '$1' packageFileDir}}})",
      "groupName": "helm"
    },
    {
      "matchPackagePatterns": ["cluster-autoscaler"],
      "matchDatasources": ["docker"],
      "allowedVersions": "<= 1.22",
      "dependencyDashboardApproval": true
    },
    {
      "matchPackagePatterns": [
        "602401143452.dkr.ecr.ap-northeast-1.amazonaws.com/amazon/aws-load-balancer-controller"
      ],
      "enabled": false
    }
  ],
  "regexManagers": [
    {
      "fileMatch": [
        "(^|/)values\\.yaml$",
        "/env/(dev|stg|prod)\\.yaml$"
      ],
      "matchStrings": ["# renovate: depName=(?<depName>.*?)\\s*repository: .*?\\s*tag: (?<currentValue>.*?)\\s"],
      "datasourceTemplate": "docker",
      "versioningTemplate": "docker"
    }
  ]
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/199914/621b0e66-fc50-c5c6-1f34-b359153049f1.png)

### 課題

こちらも Terraform と同様に自動マージまでは実現できておらず、何をもって差分がないと判断するかを含めて今後の課題です。
また、chart によっては互換性を失わないために chart 本体のバージョンと Docker イメージのタグを調整する必要があり、そこまでの要件を Renovate で表現するのはコスパが良くなさそうなので手動対応にしています。

## まとめ

このように Renovate を応用すれば Terraform や Kubernetes の依存バージョン更新にも適用することができます。
多少の Renovate の学習コストはかかりますが、それに見合うリターンを得る可能性は十分にある、ということが少しでも伝われば幸いです。

## 参考

- https://docs.renovatebot.com/modules/manager/terraform/
- https://docs.renovatebot.com/modules/manager/helmv3/
- https://docs.renovatebot.com/modules/manager/helm-values/
- https://docs.renovatebot.com/configuration-templates/
- https://docs.renovatebot.com/configuration-options/
- https://docs.renovatebot.com/modules/manager/regex/
- https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/add-ons-images.html
- https://github.com/kubernetes/autoscaler/tree/master/cluster-autoscaler#releases