ExternalSecretsOperator(ESO)をEKSとGKEに導入して各SecretsManagerからシークレットを取得する

# はじめに
マルチクラウド構成の検討とか色々とやってる中で、アプリケーションで使用するシークレットを取得するにあたって、マネージドサービスのKubernetes(EKS/GKE)からどうやって呼び出すか？を検討しました。環境の前提条件などから色々とハマったので、導入からシークレットの取得までを整理したいと思います。
※間違ってたらコメントなどで指摘を頂けますとありがたいです。

# 前提条件
## AWS
- EKSクラスタが構築されていること
- ノードグループではなく、Fargateで起動する(ここが色々と制限になりました)
- FargateプロファイルはDefaultとKube-systemを別個に作成
- ParametorStoreへの値の設定は事前に行っている

## Google Cloud
- GKEクラスタが構築されていること
- モードはAutoilotとする
- SecretsManagerへの値の設定は事前に行っている

## いろいろ
- kubectlの認証などは省略してます
- ファイルの中身は記載しますが、applyコマンドは省略してます

# 検討コンポーネント
まず、AWS側でサンプルアプリをデプロイしてシークレットを取得するまでを検討しました。
## SecretStoreSCIDriver
https://secrets-store-csi-driver.sigs.k8s.io/

ボリュームを通してシークレットストアとK8sを統合するドライバ。
ただし、Fargate起動の場合は、ボリュームマウント関連で制限があるため、本ドライバは使えませんでした。
https://github.com/aws/secrets-store-csi-driver-provider-aws/issues/34

## kubernetes-external-secrets(KES)
https://github.com/external-secrets/kubernetes-external-secrets

次に確認したのが上記です。日本語でもSecretsを取得する方法で検索すると良く出てくるものでした。が、正式に非推奨になり、記事タイトルにあるExternalSecretsOperator(ESO)に変わりました。
https://github.com/external-secrets/kubernetes-external-secrets/issues/864


## ExternalSecretsOperator(ESO)
https://external-secrets.io/v0.4.1/

そのため、KESの後継であるESOを使用してSecretsを取得することとしました。が、2021年11月にKESで非推奨のアナウンスが出たので、公式ドキュメントはあるにせよ日本語の情報が多くない。よし、なら導入して書こう(本記事)
クラウド間で違うコンポーネントを使うのは避けるため、Google Cloudも同じESOを使用することとしました。


# ESO概念図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2348781/3d181e0a-35a4-c84e-501b-0c59d3ec4ac8.png)
※公式サイト(https://external-secrets.io/v0.4.1/) から引用

ExternalSecretsOperatorのPodが起動し、各クラウドサービスの権限を設定したServiceAccountを使用してシークレットを取得します。KESではアクセスに使う設定と、K8s内Secretsの設定がまとまっていましたが、ESOではアクセス設定(SecretStore/ClusterSecretStore)とSecretsの紐づけ(ExternalSecret)が別となっているので、役割分担もわかりやすいかなと思います。

* SecretStore
    * アクセスに使用する設定
    * K8sにあるServiceAccountを指定する
    * 名前空間を考慮する必要がある
* ClusterSecretStore
    * アクセスに使用する設定
    * K8sにあるServiceAccountを指定する
    * 名前空間を横断して共通で使用できる
* ExternalSecrets
    * フェッチするデータの宣言
    * どのSecretStoreを使用して取得するかを指定する
    * K8s Secretsの定義、どこから取得するかを指定する
    * 名前空間を考慮する必要がある

# 構築する
https://external-secrets.io/v0.4.1/guides-getting-started/

を参照しつつ、色々と前提条件の環境に合うように修正を実施しました。

## AWS
### Terraform
#### OIDCプロバイダとRole関連
権限のリソース周りなどは読み替えてください。個人的には*ですべての権限などを与えたくなかったので、下記のようにしています。あと、コード化したいので、可能な限りTerraformに寄せました。

<details><summary>Terraformコード</summary><div>

``` iam.tf
##
# Create OIDC Provider (K8s Cluster)
##
resource "aws_iam_openid_connect_provider" "k8s_oidc" {
  url = "[ClusterのOpenID Connect プロバイダー URL]"

  client_id_list = [
    "sts.amazonaws.com",
  ]

  # 証明書のサムプリントはEKS Clusterで基本は共通
  thumbprint_list = [
    "[値を手動で確認するなどして入れる]",
  ]
}

data "aws_iam_policy_document" "secrets_inline_policy" {
  statement {
    actions = [
      "kms:GenerateDataKey",
      "kms:Decrypt",
    ]
    resources = ["*"]
  }
  statement {
    actions = [
      "secretsmanager:GetSecretValue",
      "secretsmanager:DescribeSecret",
      "secretsmanager:ListSecretVersionIds",
    ]
    resources = [
      "arn:aws:secretsmanager:[REGION]:[ACCOUNT]:secret:[RESOURCE]"
    ]
  }
  statement {
    actions = [
      "ssm:GetParameters",
      "ssm:GetParameter",
    ]
    resources = [
      "arn:aws:ssm:[REGION]:[ACCOUNT]:parameter[RESOURCE]"
    ]
  }
}

data "aws_iam_policy_document" "secrets_assume_role" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]

    # K8sのOIDCを設定する
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.k8s_oidc.arn]
    }

    # 信頼関係を制限する
    condition {
      test     = "StringEquals"
      variable = "[ClusterのOpenIDConnectプロバイダーURLからhttps://を削除した値]:sub"
      values = [
        "system:serviceaccount:default:test-eso-sa",
      ]
    }
  }
}

## ESO向けにRoleを作成する
resource "aws_iam_role" "k8s_serviceaccount_secrets" {
  name               = "ExternalSecretOperator-role"
  description        = "Managed by Terraform / K8s ESO ROLE"
  assume_role_policy = data.aws_iam_policy_document.secrets_assume_role.json
  inline_policy {
    name   = "ExternalSecretOperator-inlinepolicy"
    policy = data.aws_iam_policy_document.secrets_inline_policy.json
  }

  depends_on = [
    aws_iam_openid_connect_provider.k8s_oidc,
  ]
} 
```
</div></details>

### kubernetes
#### helm
ガイドでは新しくNamespaceを作成しているが、Fargateプロファイル上存在しないので、Defaultで動かすこととします。FargateでHelmInstallを行う場合は、regionとvpcIDが必要になります。

```
helm install external-secrets external-secrets/external-secrets \
  -n default \
  --set clusterName=[クラスタ名] \
  --set serviceAccount.create=false \
  --set serviceAccount.name=test-eso-sa \
  --set region=[REGION] \
  --set vpcId=[ClusterがあるVPCのID]
```
上手く動くと、ワークロードにexternal-secretsのDeploymentが存在し、Podが起動します。

#### ServiceAccount

``` serviceaccout.yaml
###
# ESOに対してSSM/SecretsManagerからSecretsを取得することを許可するServiceAccount(IAM紐づけ)
###
apiVersion: v1
kind: ServiceAccount
metadata:
  labels:
    app.kubernetes.io/component: secrets
    app.kubernetes.io/name: external-secrets
  name: test-eso-sa
  annotations:
      eks.amazonaws.com/role-arn: arn:aws:iam::[ACCOUNT]:role/ExternalSecretOperator-role
```
#### SecretStore / ExternalSecret

``` secretstore.yaml
###
# Secretの認証方法の設定
###
apiVersion: external-secrets.io/v1alpha1
kind: SecretStore
metadata:
  name: secretstore-sample
spec:
  provider:
    aws:
      service: ParameterStore
      region: [REGION]
      auth:
        jwt:
          serviceAccountRef:
            name: test-eso-sa
```

``` externalsecrets.yaml
apiVersion: external-secrets.io/v1alpha1
kind: ExternalSecret
metadata:
  name: example
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: secretstore-sample
    kind: SecretStore
  target:
    name: multicloud-db-info
    creationPolicy: Owner
  data:
  - secretKey: dburl
    remoteRef:
      key: [ParametorStoreのPath]
  - secretKey: user
    remoteRef:
      key: [ParametorStoreのPath]
  - secretKey: password
    remoteRef:
      key: [ParametorStoreのPath]
```

### ハマった点
公式のHelmだと名前空間を新しく作るが、Fargateなのでkube-systemで良いかと軽く考えると、各リソース間の検索ができずにErrorとなりました(Notfound)
ExternalSecret > SecretStore > ServiceAccount > IAM
と呼び出すので、同じ名前空間でないと見れないという単純なミスでした。

## Google Cloud
### Terraform
#### ServiceAccountとRoleBinding関連
OIDCプロバイダーは内部のためかAWSと違い不要でした。Secretsを読むためのServiceAccountを作成し、必要なRoleをBindingしています。Roleはあまり制限かけてませんが、AWSとの習熟度の違いですのでご容赦ください。本当なら制限かけるべきかと思っています。公式からリンクされているGoogle Cloudのドキュメントだとコマンドベースで記載されていますが、こちらも、コード化したいので、Terraformに寄せました。

<details><summary>Terraformコード</summary><div>

``` iam.tf
###
# Service Account(ExternalSecretsOperator用ユーザ)
###
resource "google_service_account" "eso" {
  account_id   = "eso-sa"
  display_name = "ExternalSecrets Account"
  description  = "ExternalSecretsOperator Use SecretManager"
}

###
# Role Binding(ESOへSecretsを読み込む権限を)
###
resource "google_project_iam_binding" "secret-pull" {
  project = local.const.google.project
  role    = "roles/secretmanager.secretAccessor"

  members = [
    "serviceAccount:${google_service_account.eso.email}",
  ]
}

###
# GKEに対して、WorkloadIdentityUserを付与する
###
resource "google_service_account_iam_binding" "gkec" {
  service_account_id = google_service_account.eso.name
  role               = "roles/iam.workloadIdentityUser"

  members = [
    "serviceAccount:[project].svc.id.goog[default/external-secrets]",
  ]
}
```

</div></details>

### kubernetes
#### helm
ガイドでは新しくNamespaceを作成しているが、AWSに合わせてDefaultで動かすこととします。AWSより引数が少なく簡単にインストールできます。

```
helm install external-secrets \
   external-secrets/external-secrets \
   -n default
```

#### ServiceAccount

``` serviceaccout.yaml
###
# ESOに対してSSM/SecretsManagerからSecretsを取得することを許可するServiceAccount(IAM紐づけ)
###
apiVersion: v1
kind: ServiceAccount
metadata:
  labels:
    app.kubernetes.io/component: secrets
    app.kubernetes.io/name: external-secrets
  name: external-secrets
  annotations:
      iam.gke.io/gcp-service-account: [Terraformで作ったGCPのServiceAccountEmail]
```
### SecretStore / ExternalSecret

``` secretstore.yaml
apiVersion: external-secrets.io/v1alpha1
kind: ClusterSecretStore
metadata:
  name: example
spec:
  provider:
    gcpsm:
      projectID: [project]
      auth:
        workloadIdentity:
          clusterLocation: [REGION]
          clusterName: [Cluster名]
          serviceAccountRef:
            name: external-secrets
            namespace: default
```

``` externalsecrets.yaml
apiVersion: external-secrets.io/v1alpha1
kind: ExternalSecret
metadata:
  name: example
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: example
    kind: ClusterSecretStore
  target:
    name: multicloud-db-info
    creationPolicy: Owner
  data:
  - secretKey: dburl
    remoteRef:
      key: [SecretManagerの名前]
  - secretKey: user
    remoteRef:
      key: [SecretManagerの名前]
  - secretKey: password
    remoteRef:
      key: [SecretManagerの名前]
```

## 確認方法
上手く設定ができれば、以下のように見えます。ただ、Base64でHash化されているだけなので、EKSやGKEではsecretを暗号化する設定を作成時に可能なので、入れておく方が良いと思います。

`kubectl get secrets`

```
NAME                                     TYPE                                  DATA   AGE
multicloud-db-info                       Opaque                                3      2d1h
```

`kubectl describe secrets multicloud-db-info`

```
Name:         multicloud-db-info
Namespace:    default
Labels:       <none>
Annotations:  reconcile.external-secrets.io/data-hash: XXXXXXX

Type:  Opaque

Data
====
dburl:     10 bytes
password:  32 bytes
user:      5 bytes
```

## 個人的に考える分界点
Terraformで環境を管理することを考えると、取得するアクセス方法まではプラットフォームチームなどのクラスタを作成・管理しているチームが準備した方が楽なのかな？と思います。セクションごとにアクセスできるシークレットを制限することが多いと考えるためです。


# さいごに
Pod自体に権限を付与して取得する方法とか、ConfigMapを使う方法など、色々とやりかたはあるとは思います。ただ、アプリケーション部門が使いかつマルチクラウド構成と考えたら、準備する各種Deploymentなどのファイルの記述はできうる限り統一した方が負荷が少ないかなと感じました。定期的に更新をしてくれますし。
ノードの管理をしないサーバレスのKubernetesでは、EKSよりやはりGKEが統合が進んでいて使いやすいというのも感じました。