マルチプラットフォーム対応のソフトウェアのマニフェストをKustomizeでなるべく抽象化する

この記事はOpenSaaS Studio Advent Calendar 2019の2日目の記事。今日も@stormcat24 が書きます。

背景

弊部署で開発しているSaaSプロダクトは基本的にOpenSaaS戦略に則り開発されている。OpenSaaSとはManagedのSaaSとして提供するという面と、OSSとして公開し、技術コミュニティの力を借りながらもその技術ドメインのデファクトの一つを目指すという考え方である。代表的なモデルとしてはKubernetesであったり、mongoDBやRedis、最近だとGrafanaもそうだ。彼らはOpenSaaSという言葉を標榜してはいないが、基本的に目指す方向は同じと思ってもらっていい。

OpenSaaSを標榜していると、マルチプラットフォーム(パブリッククラウド・プライベートクラウド問わず)対応からは逃れることはできない。とはいえ最初からマルチクラウドでやるのは開発リソース上無理なので、GCPが対応できたら次はAWS、その次はAzure・・・みたいな感じになる。

今取り組んでいるプロダクトのほとんどはKubernetesベースで構築されているため、様々なプラットフォームとワークロードに対応したマニフェストを上手く管理するのが重要になる。我々中の人の運用効率を確保することと、OSSとして利用しやすくするための適度な抽象化が求められる。

Kustomize

マニフェスト管理を助けるツールといえばHelmとKustomize。

Helmはテンプレートを記述して、値を埋め込んだり分岐して描画したり、historyを持てたりとなかなか強力である。また、サーバコンポーネントであるTillerが必要であり、Tiller自身を管理する必要があった(これは、Helm3でtillerが廃止されたため解消されたと言ってよい)。

Helmは強力であるが、「Docker/Kubernetes 実践コンテナ開発入門」においても解説がそこそこのボリュームになるくらいは難しい。

そこで最近はKustomizeを活用している。

KustomizeはHelmのようにテンプレートを用意するわけではなく、複数のKubernetesのマニフェストをマージしたり、パッチ的に上書きしたりして一つの完成形のマニフェストを構築するというものである。Kustomizeはマニフェストの構築までが役割であり、成果物を kubectl apply するだけで良いというシンプルさも良い。

マニフェストのディレクトリ構成

今取り組んでいるプロダクトはmonorepoで開発している。Microservices構成であるが、1つのリポジトリで複数のサービスのDockerイメージを構築していて、ベースとなるマニフェストもこのリポジトリで管理している。リポジトリ名はとりあえず nekotan として説明しよう。

nekotan -- manifests --- base 
                     |- local (baseにlocal固有の設定を上書き・追加したもの)
                     |- gcp (baseにgcp固有の設定を上書き・追加したもの)
                     |- aws (baseにaws)固有の設定を上書き・追加したもの

各Microservicesであったり、その他アプリケーションが依存する横断的なコンポーネント(SealedSecretやIstioの設定)も含めてbaseに配置する。baseに配置するものは、基本的にはプラットフォームを問わず必要になるものと考えていい。

例えばRedisの利用が必須で、この接続情報をSecretで管理していて環境変数で渡すのであれば、baseディレクトリに配置するアプリケーションのマニフェストは次のようになる。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: account 
spec:
  template:
    spec:
      containers:
      - name: server
        image: asia.gcr.io/your-project/nekotan-account:latest
        env:
        - name: REDIS_HOST
          valueFrom:
            secretKeyRef:
              name: redis-credentials
              key: host 
        - name: REDIS_PASSWORD
          valueFrom:
            secretKeyRef:
              name: redis-credentials
              key: password

このアプリケーションではプラットフォームを問わずRedisを必須としているため、より具象的なマニフェスト群である、local, gcp, awsでこれを定義する必要はなくなる。

プラットフォーム固有のものがある

プラットフォームごとに分ける必要あるんかいなーと思う人もいるかもしれないが、残念ながらプラットフォーム固有な要素も多い。

例えばGCPでRDBをCloudSQLで運用するような場合、RDBにアクセスするためのProxyとなるサイドカーコンテナが必要となる(Cloud SQL Proxy)。この場合、gcpディレクトリに配置されるbaseを上書きするマニフェストは次のようになる(ブログのために簡略化しているので雰囲気を感じ取ってくれ)。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: account 
spec:
  template:
    spec:
      containers:
      - name: server
        args:
          - server
          - --port=8080
          // hogehoge...
      - name: cloudsql-proxy
        image: gcr.io/cloudsql-docker/gce-proxy:1.16
        command:
          - "/cloud_sql_proxy"
          - "-instances=$(RDB_ADDRESS)"
          - "-credential_file=/secrets/cloudsql/credentials.json"
        securityContext:
          runAsUser: 2
          allowPrivilegeEscalation: false
        env:
        - name: RDB_ADDRESS
          valueFrom:
            secretKeyRef:
              name: account-db-credentials
              key: address 
        volumeMounts:
          - name: cloudsql-instance-credentials
            mountPath: /secrets/cloudsql
            readOnly: true
      volumes:
        - name: gcp-credentials
          secret:
            secretName: account-gcp-credentials
        - name: cloudsql-instance-credentials
          secret:
            secretName: cloudsql-instance-credentials

このマニフェストファイルを、次のようにkustomization.yamlbaseを上書きするようにし、patchesStrategicMergeでマージする。

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

bases:
- ../../../base/apps/account

patchesStrategicMerge:
- deployment.yaml

サイドカーとして追加されたのがcloudsql-proxyである。完全にGCP固有であり、serverコンテナはcloudsql-proxy経由でRDBにアクセスする。server側にはDBのユーザーとパスワードを渡す必要があるが、これはRedisのときと同様にbaseで抽象化し、Secretで渡すという方法が通用する。

細かい制御はやっぱり難しい

「クラウドプラットフォームレベルの抽象化」という意味であればこの管理でも上手くいくかもしれない。しかし、Helmではテンプレートと分岐によって細かく制御できる仕組みがあるが、overlayしていくKustomizeでは辛みがある。例えばフラグ制御。

アプリケーションのフラグは配列で定義するので、フラグで色々出し分けするケースは辛みがある。

        args:
          - server
          - --port=8080
          // hogehoge...

この場合はargs配列をまるっと上書きするか、次のようなpatchesという仕組みを使う。

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

bases:
- ../../../base/apps/account

patchesStrategicMerge:
- deployment.yaml

patches:
- path: server-args.yaml
  target:
    group: apps
    version: v1
    kind: Deployment
    name: account
- op: add
  path: /spec/template/spec/containers/0/args/-
  value: --log-level=debug

args配列に1つフラグを追加するという定義だ。追加は確かに可能だが、ベースのものを更新や削除はできないので厳しみが強い。

オプショナルな制御をしようとすればするほど厳しくなってくる。例えばメトリクスでDatadogとPrometheus両方対応しような!みたいなことを安易に考えてしまいがちだが、そもそもPush型とPull型でメトリクスの収集方法が違うため、マニフェストの構造も大きく変わってしまう。ああ、茨の道が見える。

結論

Helm3やっていこうな!でもターゲットがシンプルであればKustomizeはいいぞ!