Kustomizeの拡張機能でIngressの環境管理を実現する

Kustomizeは本番環境、ステージング環境、開発環境など環境ごとに異なるYAMLを管理するのに非常に便利です。

しかし、KubernetesビルトインなL7 Loadbalancerの管理APIであるIngressについてはかなり以前から要望があったものの、Kustomizeはビルトインサポートしない方針を取ってきました。

https://github.com/kubernetes-sigs/kustomize/issues/347

今回はKustomizeの柔軟性のための機能について、Ingressの環境管理を例に整理します。

今回使用したコードは[Github](https://github.com/jlandowner/kustomize-ingress)に置いてあります。

# Ingressの環境管理のつらみ

IngressはKubernetesビルトインなL7 Loadbalancerの管理リソースです。

Ingress APIはもともと`extention/v1beta1`APIとしてKubernetesの初期から存在し、`networking.k8s.io/v1beta1`の長い期間を経て、2020年8月リリースのv1.19でやっとGAを迎え`networking.k8s.io/v1`となりました。

IngressはKubernetesに組み込みのコントローラーがなく、ユーザーが任意のコントローラーをデプロイする必要があります。

そのため、IngressのSpecはホスト名とパスから構成されたIngress Ruleと、そのルールの転送先Serviceを設定するだけの最小限なものとなっています。

加えて、L7ネットワークの機能はネットワークプロダクトごとに実現できることが大きく異なるため、最小限のSpecの代わりにプロダクトごとの設定はIngressのアノテーションで設定することが一般的です。

これにより、各Ingress Controllerのプロダクトはアノテーションで設定できる独自オプションが大量に用意されています。
例えば、Kubernetes SIGとしてホストされているingress-nginxやAWS Load Balancer Controllerのアノテーションのリファレンスにも大量のオプションがあることが見て取れます。

https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/

https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.3/guide/ingress/annotations/

私は普段からAmazon EKSでAWS Load Balancer Controllerを使用していますので、ALBのIngressを例に説明します。

```yaml:ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-west-2:ACCOUNTID:certificate/PRODUCTION_ARN
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]'
    alb.ingress.kubernetes.io/ssl-redirect: '443'
  name: my-ingress
  namespace: default
spec:
  rules:
  - host: app1.example.com
    http:
      paths:
      - path: /api/v1alpha1/
        pathType: Prefix
        backend:
          service:
            name: app1
            port: 
              number: 8080
```

まずホスト名は`app1.example.com`は環境によって異なる情報です。例えばステージング環境は`app1.stg.example.com`といった別のホスト名にしたいことが多いでしょう。

一方で、パス`/api/v1alpha1/`はアプリケーションに非常に依存している情報で、アプリ側のコンテキストルートの設定と合っている必要があります。

なので、Ingressのパス設定含めて十分にテストが必要で、環境依存のあるホスト名のみを環境ごとに変更したいはずです。

次に上記の例ではAWS Load Balancer Controllerの独自の設定としてSSLリダイレクトを設定しており、ALBにアタッチするAmazon ACMの証明書ARNを設定しています。
`alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-west-2:ACCOUNTID:certificate/PRODUCTION_ARN`というアノテーションです。これも環境ごとに異なります。

> 使ったことなかったですが、証明書を自動ディスカバリする機能があるようです
> https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.3/guide/ingress/cert_discovery/

これに加えて実際に使用する際はALBのHealthCheckを有効化したり、ALBのアクセスログオプションを有効化したりなど、さらに多くのアノテーションが必要になります。多くのアノテーションが必要な中で環境依存がある情報とその他の設定が入り混じっているのは非常につらいです。

# Kustomizeの拡張機能

では、KustomizeのビルトインにないからKustomizeが使えないかというとそんなことはありません。Kustomizeには柔軟性のための機能が複数用意されています。

今回は以下の4つのやり方を説明します。

- JSONPatch
- Strategic Merge Patch
- Replacements
- Transformer (KRM function exec plugin)

どの方法でも実現することが可能です。以降の例では全て以下のIngressを生成することとします。

```yaml:ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-west-2:ACCOUNTID:certificate/STAGING_ARN # 置換①ステージングのARN
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]'
    alb.ingress.kubernetes.io/ssl-redirect: '443'
  name: my-ingress
  namespace: default
spec:
  rules:
  - host: app1.stg.example.com # 置換②ステージングのホスト名
    http:
      paths:
      - path: /api/v1alpha1/
        pathType: Prefix
        backend:
          service:
            name: app1
            port: 
              number: 8080
```

## JSONPatch

Kustomizeでは以前よりRFC 6902のJSONPatchをサポートしており、これで生成されたYAMLにJSONPatchの仕様に沿ったパッチを当てることが可能です。

JSONPatchのチュートリアルはまさにIngressで説明されています。
https://github.com/kubernetes-sigs/kustomize/blob/master/examples/jsonpatch.md

先ほどのIngressのホスト名を`app1.stg.example.com`に変更し、アノテーションの証明ARNをステージングの証明書に変更するパッチを作成します。

```yaml:kustomization.yaml
resources:
- ../base

patchesJson6902:
- target:
    group: networking.k8s.io
    version: v1
    kind: Ingress
    name: my-ingress
  patch: |-
    - op: replace
      path: /spec/rules/0/host
      value: app1.stg.example.com # ステージングのホスト名
    - op: replace
      path: /metadata/annotations/alb.ingress.kubernetes.io~1certificate-arn
      value: arn:aws:acm:us-west-2:ACCOUNTID:certificate/STAGING_ARN # ステージングの証明書ARN
```

https://github.com/jlandowner/kustomize-ingress/blob/main/patchesJson6902/kustomization.yaml

JSONPatchは配列で複数のオペレーションを記載できるため、1つのリソースに対して複数の置換をする場合も1つのPatchに記載できます。

アノテーションのパスは/が含まれているため、`~1`を代わりに使用します。
http://jsonpatch.com/#json-pointer

IngressRuleは配列なので、置換するホスト名を配列のインデックスで指定する必要があるのが少し残念ですが、比較的シンプルに記載できます。

## Strategic Merge Patch

Strategic Merge Patchは`kubectl apply`でYAMLを適用する際にも使用される、複数のyamlファイルの内容をいい感じにマージする仕様です。こちらもJSONPatchとともに以前よりKustomizeでサポートされてきました。

ただし、Ingressで使用するには少し問題があります。次の例で説明します。

マージする内容のみを切り出したYAMLを`staging_ingress_patch.yaml`として作成します。

```yaml:staging_ingress_patch.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-west-2:ACCOUNTID:certificate/STAGING_ARN # ステージングの証明書ARN
  name: my-ingress
spec:
  rules:
  - host: app1.stg.example.com # ステージングのホスト名
    http:
      paths:
      - path: /api/v1alpha1/
        pathType: Prefix
        backend:
          service:
            name: app1
            port: 
              number: 8080
```

https://github.com/jlandowner/kustomize-ingress/blob/main/patchesStrategicMerge/staging_ingress_patch.yaml

そしてkustomization.yamlにStrategic Merge Patchとして先程のファイルを使用するよう記載します。

```yaml:kustomization.yaml
resources:
- ../base

patchesStrategicMerge:
- staging_ingress_patch.yaml
```

https://github.com/jlandowner/kustomize-ingress/blob/main/patchesStrategicMerge/kustomization.yaml

パッチ対象の`staging_ingress_patch.yaml`を見ると、アノテーションは上書きする証明書ARNのアノテーションのみを記載すれば、この値だけが上書きされていい感じです。

しかし、ホスト名については、非常に冗長な記載になっていることがわかるかと思います。
これは、`spec.rules`内の配列の置換になるため、配列が構造体の場合は全てのプロパティを記載する必要があります。

できれば以下のように記載したいですが、これだとhostが上書きされるだけでなくパスのプロパティも上書きされ、無くなってしまいます。

```yaml:こうしたいけどできない
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-west-2:ACCOUNTID:certificate/STAGING_ARN # ステージングの証明書ARN
  name: my-ingress
spec:
  rules:
  - host: app1.stg.example.com # ステージングのホスト名
```

一応実現したいことはできていますが、Ingressのホスト名の置換に実用的とは言えません。

## Replacements

YAML内の特定の項目で、別のYAMLの項目を置換する機能です。

:::note info
以前は似た機能で Vars/VarReference という機能がありましたが現在は非推奨で、代わりにこのReplacementsの利用が推奨されています。
:::

まず、置換する情報をKustomizeでビルドするYAMLに含める必要があります。

この例では、ダミーで`ingress-config-staging.yaml`というConfigMapを作成し、こちらに置換する情報を記載します。

```yaml:ingress-config-staging.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: ingress-config-staging
data:
  host: app1.stg.example.com # ステージングのホスト名
  certificate-arn: arn:aws:acm:us-west-2:ACCOUNTID:certificate/STAGING_ARN # ステージングの証明書ARN
```

https://github.com/jlandowner/kustomize-ingress/blob/main/replacements/ingress-config-staging.yaml

次にこのConfigMapをYAMLに含めるためにkustomization.yamlの`resources`に追加します。
そして`replacements`でConfigMapの`data`内の各項目でIngressの値を置換するように設定します。

```yaml:kustomization.yaml
resources:
- ../base
- ingress-config-staging.yaml

replacements:
# ホスト名の置換
- source:
    kind: ConfigMap
    name: ingress-config-staging
    fieldPath: data.host
  targets:
  - select:
      kind: Ingress
      name: my-ingress
    fieldPaths:
    - spec.rules.0.host
# 証明書ARNの置換
- source:
    kind: ConfigMap
    name: ingress-config-staging
    fieldPath: data.certificate-arn
  targets:
  - select:
      kind: Ingress
      name: my-ingress
    fieldPaths:
    - metadata.annotations.alb\.ingress\.kubernetes\.io/certificate-arn
```
https://github.com/jlandowner/kustomize-ingress/blob/main/replacements/kustomization.yaml

ホスト名、証明書ARNそれぞれ置換元、置換先のフィールドの設定が必要になり、kustomization.yamlの記載量が増えますが、ConfigMap単体で見ると環境ごとに必要な情報をまとめて記載することができます。

## Transformer (KRM function exec plugin)

Kustomize v3で追加された外部コマンドによるYAMLの置換をサポートする機能です。外部コマンドに標準入力から変更するYAMLが渡り、標準出力することでKustomizeのビルド結果にすることができます。

また置換に必要な情報をカスタムリソースの要領でオリジナルのYAMLで記載できます。外部コマンドはこれを元に環境ごとの情報に置換することができます。

今回はIngressの環境ごとの情報を持つオリジナルのYAMLと、その情報を元にIngressを置換するPythonスクリプトを作成します。

今回は以下のような`kind: IngressPatch`というTransformerのYAMLを作成しました。これに置換対象の情報を記載します

```yaml:staging_ingress_patch.yaml
apiVersion: kustomize-ingress/v1alpha1 # オリジナル
kind: IngressPatch # オリジナル
metadata:
  annotations:
    config.kubernetes.io/function: |
      exec:
        path: ./krmfunc/ingress_patch.py
  name: my-ingress
spec:
  hosts: 
  - name: app1.stg.example.com # ステージングのホスト名 
    target: app1.example.com   # 置換対象
  annotations:
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-west-2:ACCOUNTID:certificate/STAGING_ARN # ステージングの証明書ARNのアノテーション
```

https://github.com/jlandowner/kustomize-ingress/blob/main/transformers/staging_ingress_patch.yaml

`kind`, `apiVersion`には任意の値が設定できます。このYAMLが自作したものだとわかるものがいいでしょう。

`spec`には任意の値を設定でき、今回は置換するホスト名と証明書ARNを記載できるようにします。実際はこのYAMLの設計に設計力が求められそうです。

最後に、このYAMLをTransfomerたらしめる設定として、アノテーションで実行する外部コマンドのパスを指定します。今回は`ingress_patch.py`というPythonスクリプトに渡すので、そのパスを指定します。

```yaml
  annotations:
    config.kubernetes.io/function: |
      exec:
        path: ./krmfunc/ingress_patch.py
```

ここで注意点ですが、実行するコマンドのパスは相対パスの場合`base`の中に配置する必要があります。
今回はbaseディレクトリ内に`krmfunc`というディレクトリを作成し、ここにスクリプト`ingress_patch.py`を配置します。

```
.
├── README.md
├── base
│   ├── ingress.yaml
│   ├── krmfunc
│   │   └── ingress_patch.py
│   └── kustomization.yaml
└── transformers
    ├── kustomization.yaml
    └── staging_ingress_patch.yaml
```

作成したTransformerのYAMLはkustomization.yamlの`transformers`で読み込むように設定します。

```yaml:kustomization.yaml
resources:
- ../base

transformers:
- staging_ingress_patch.yaml
```

これでkustomize build時に外部コマンドが実行されるようになります。

次に、外部コマンドの標準入力に渡ってくる内容ですが、以下のような`ResourceList`というYAMLが渡ってきます。
この入力を処理するスクリプトを作成することになります。

```yaml:外部コマンドの標準入力に渡される内容
apiVersion: config.kubernetes.io/v1alpha1
kind: ResourceList
items:
- apiVersion: networking.k8s.io/v1
  kind: Ingress
  metadata:
    annotations:
      alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-west-2:ACCOUNTID:certificate/PRODUCTION_ARN
      alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]'
      alb.ingress.kubernetes.io/ssl-redirect: "443"
      kubernetes.io/ingress.class: alb
      kustomize.config.k8s.io/id: |
        group: networking.k8s.io
        kind: Ingress
        name: my-ingress
        namespace: default
        version: v1
      config.kubernetes.io/index: '0'
      internal.config.kubernetes.io/index: '0'
      internal.config.kubernetes.io/id: '1'
      config.k8s.io/id: '1'
    name: my-ingress
    namespace: default
  spec:
    rules:
    - host: app1.example.com
      http:
        paths:
        - backend:
            service:
              name: app1
              port:
                number: 8080
          path: /api/v1alpha1/
          pathType: Prefix
functionConfig:
  apiVersion: kustomize-ingress/v1alpha1
  kind: IngressPatch
  metadata:
    annotations:
      config.kubernetes.io/function: |
        exec:
          path: ./krmfunc/ingress_patch.py
    name: my-ingress
  spec:
    hosts: 
    - name: app1.stg.example.com
      target: app1.example.com
    annotations:
      alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-west-2:ACCOUNTID:certificate/STAGING_ARN
```

これを見ると、`items`にKustomizeでビルドされたYAMLがあり、`functionConfig`にTransformerのYAMLである`IngressPatch`があることがわかります。

外部コマンドではfunctionConfigの内容をもとに、itemsのYAMLを置換して標準出力する必要があります。

今回は以下のようなスクリプトをPythonで作成しました。

```py:ingress_patch.py
#!/usr/bin/env python3
from functools import reduce
import os
import sys
import yaml

import logging

def main(args=sys.argv[1:]):
  # 標準入力からResourceListのYAMLを読み込み
  resource_list = yaml.load(sys.stdin, Loader=yaml.FullLoader)

  # functionConfigのspecから置換する情報を取得
  target_name = dict_get(resource_list, 'functionConfig.metadata.name')
  annotations = dict_get(resource_list, 'functionConfig.spec.annotations', {})

  hosts = []
  for host in dict_get(resource_list, 'functionConfig.spec.hosts', []):
    hosts.append({
      'name': dict_get(host, 'name'),
      'target': dict_get(host, 'target') })

  output = []
  for resource in dict_get(resource_list, 'items', []):
    if resource['apiVersion'] in ['extensions/v1beta1', 'networking.k8s.io/v1', 'networking.k8s.io/v1beta1'] \
        and resource['kind'] == 'Ingress' \
        and dict_get(resource, 'metadata.name') == target_name:

      # patch annnotation
      if len(annotations) > 0:
        ann = dict_get(resource, 'metadata.annotations')
        if ann is not None:
          for key in annotations:
            ann[key] = annotations[key]
        else:
          resource['metadata']['annotations'] = annotations

      for host in hosts:
        host_name = dict_get(host, 'name')
        target = dict_get(host, 'target')

        # patch host
        for rule in dict_get(resource, 'spec.rules', []):
          if target is None or rule['host'] == target:
            rule['host'] = host_name

    output.append(resource)
  # 置換結果を標準出力
  sys.stdout.write(yaml.dump_all(output))

def dict_get(dictionary, keys, default=None):
  return reduce(lambda d, key: d.get(key, default) if isinstance(d, dict) else default, keys.split("."), dictionary)

if __name__ == '__main__':
  sys.stdin.reconfigure(encoding='utf-8')
  sys.stdout.reconfigure(encoding='utf-8')
  sys.stderr.reconfigure(encoding='utf-8')
  main()
```

https://github.com/jlandowner/kustomize-ingress/blob/main/base/krmfunc/ingress_patch.py

これでkustomize buildするとPythonスクリプトが実行され、Ingressのホスト名と証明書ARNが置換されるようになります。

注意点として、kustomize buildする際に外部コマンド実行を明示するオプションが必要となります。

```sh
kustomize build transformers/ --enable-alpha-plugins --enable-exec
```

# 最後に

今回はKustomizeの4つの機能を実用的にIngressを使用して説明しました。

- JSONPatch
- Strategic Merge Patch
- Replacements
- Transformer (KRM Function plugin)

応用すれば、カスタムリソース等のKustomizeで対応していない様々なリソースを柔軟に管理できます。それぞれ使い所を見極めて活用していきましょう。