Published on

vault secrets operator 사용해보기

Authors
  • avatar
    Name
    Jay
    Twitter

GitOps로 Secret을 관리할 때, Secret을 암호화해서 Git에 올리는 방법과 Secret management tool를 사용하여 동기화하는 방법을 생각해볼 수 있다. Red Hat 블로그에서 GitOps에서 Secret을 관리하는 다양한 방법들을 잘 설명하고 있다.

개발자들의 인지부하를 최소한화 하고 개발에 집중할 수 있는 환경을 만드는 것을 중요하게 생각한다. 그래서 개발자들이 쿠버네티스에 대해서 최소한으로 알고 개발을 할 수 있게 환경을 만들고자 했다. Vault는 Web UI를 제공하고, 세부적으로 권한 설정을 할 수 있다. 따라서 개발자들은 Web UI에만 접근하여 자신의 어플리케이션과 관련된 secret 정보를 바꿀 수 있다. 그리고 vault의 다양한 engine을 통해서 임시 credential 발급 하거나 rotation을 하는 것을 쉽게 구현할 수 있다.

올해 Hashicorp가 제공하는 Product들의 License가 BSL(Business Source License)로 변경되었다. 라이센스 변경 때문에 Terraform을 fork한 오픈 소스 프로젝트인 OpenTofu가 탄생하기도 했다. 기존의 Vault version들은 여전히 MPL(Mozilla Public License) 2.0로 사용가능하고, Hashicorp와 경쟁적인 프로덕트를 만드는 곳이 아니라면 여전히 Vault를 라이센스 구매 없이 사용 가능하다.

Vault를 활용하여 Kubernetes에서 secret management를 할 수 있는 방법은 아래와 같이 나열해볼 수 있다.

  • Secrets Store CSI + Vault Provider
  • External Secrets
  • Vault secrets operator

Secrets Store CSI는 vault의 secret과 kubernetes secret의 sync를 맞추는 secret-auto-rotation 기능이 아직도 alpha feature로 남아 있다. 그리고 node-driver-registrar로 kubelet에 binary를 추가하는 설정도 해야 한다. External Secrets은 이전에 소스코드를 살펴본적이 있는데, 이 프로젝트도 좋은 옵션이라고 생각이 들었다. 하지만 Vault만 사용할 계획이고, Hashicorp에서 제공하는 vault secrets operator의 CRD가 더 직관적이고 깔끔했다. 그리고 GA로 제공한다는 공지도 있었고, 앞으로 관리 측면에서도 유리하다고 판단하였다.

vault

HashCorp 문서에서 친절하게 어떻게 Vault을 Production 환경에서 설치할 수 있는지 설명해주고 있다.

Certificate 준비

openssl로 key와 csr를 생성한다. Kubernetes 문서에서 CN과 Organizationd을 아래와 같이 설정해야 된다고 나온다. 따라서 vault-csr.conf에 해당 내용이 반영되어 있다.

Permitted subjects - organizations are exactly ["system:nodes"], common name starts with "system:node:".
openssl genrsa -out vault.key 2048

Helm으로 resource를 생성할 때 headless service로 vault-internal이 생성된다. Raft로 Vault node들이 cluster를 형성할 때 이 headless service의 DNS 이름을 사용하기 때문에 vault-csr.conf에 추가되었다.

vault-csr.conf

[req]
default_bits = 2048
prompt = no
encrypt_key = yes
default_md = sha256
distinguished_name = kubelet_serving
req_extensions = v3_req
[ kubelet_serving ]
O = system:nodes
CN = system:node:*.vault.svc.cluster.local
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment, dataEncipherment
extendedKeyUsage = serverAuth, clientAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = *.vault-internal
DNS.2 = *.vault-internal.vault.svc.cluster.local
DNS.3 = *.vault
DNS.4 = *.vault.svc.cluster.local
DNS.5 = *.vault.svc
IP.1 = 127.0.0.1
openssl req -new -key vault.key -out vault.csr -config vault-csr.conf
cat vault.csr|base64|tr -d '\n'

이제 Kubernetes Root CA로 Sign하기 위해서 CertificateSigningRequest resource를 만든다.

csr.yml

apiVersion: certificates.k8s.io/v1
kind: CertificateSigningRequest
metadata:
  name: vault.svc
spec:
  signerName: kubernetes.io/kubelet-serving
  expirationSeconds: 8640000
  request: '{vault.csr base64 인코딩 값}'
  usages:
    - digital signature
    - key encipherment
    - server auth
kubectl apply -f csr.yml

이렇게 생성하고 나면 CONDITIONPending으로 보이게 된다.

$ kubectl get certificatesigningrequest
NAME        AGE   SIGNERNAME                      REQUESTOR    REQUESTEDDURATION   CONDITION
vault.svc   62s   kubernetes.io/kubelet-serving   example      100d                Pending

최종적으로 approve 명령어를 사용해서 Approved,Issued 상태가 되도록 한다.

kubectl certificate approve vault.svc

이제 Certificate와 Kubernetes CA Certificate를 받는다.

kubectl get csr vault.svc -o jsonpath='{.status.certificate}' | openssl base64 -d -A -out vault.crt
kubectl config view \
--raw \
--minify \
--flatten \
-o jsonpath='{.clusters[].cluster.certificate-authority-data}' \
| base64 -d > vault.ca

Secret으로 준비

Kutomize와 sealed-secret를 사용하여 secret를 GitOps 방식으로 저장한다.

kustomization.yml

secretGenerator:
  - name: vault-ha-tls
    files:
      - vault.ca
      - vault.crt
      - vault.key
kustomize build > secret.yml
cat secret.yml| kubeseal --controller-namespace kube-system --controller-name sealed-secrets-controller --format yaml > sealed-secret.yml

namespace를 추가하고, sealed-secret으로 encryption된 yaml 파일로 secret resource를 추가한다.

kubectl create namespace vault
kubectl ns vault
kubectl apply -f sealed-secret.yml

sealed-secret에서 decrypt하여 secret resource가 정상적으로 생성되었는지 확인한다.

kubectl get secret
NAME                      TYPE     DATA   AGE
vault-ha-tls-ad6d57g7t2   Opaque   3      13s

ArgoCD로 배포

ArgoCD 문서처럼 Helm charts를 통해서 ArgoCD CRD를 정의할 수 있다. Helm으로 배포할 때 values에 override할 설정값들을 정의할 수 있다. Vault의 secret을 template으로 원하는 형식으로 volume에 저장할 수 있는 Vault agent injector는 사용하지 않을 거라 enabledfalse로 지정했다. High Avability Mode로 설정을 하였고, 3개 중에 하나의 Node가 Active 상태로 있고, 나머지들은 Stand By 상태로 있게 된다. Raft Protocol로 모든 노드들에 data를 잘 replication한다. Vault의 UI도 사용할 수 있게 NodePort type으로 service resource를 생성하도록 하였다.

argocd-vault.yml

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: vault
  namespace: argocd
spec:
  project: default
  destination:
    server: 'https://kubernetes.default.svc'
    namespace: vault
  source:
    chart: vault
    repoURL: https://helm.releases.hashicorp.com
    targetRevision: 0.27.0
    helm:
      releaseName: vault
      values: |
        global:
          enabled: true
          tlsDisable: false
        injector:
          enabled: false
          image:
            repository: "hashicorp/vault-k8s"
            tag: "1.3"
        server:
          standalone:
            enabled: false
          image:
            repository: "hashicorp/vault"
            tag: "1.15.2"
          resources:
            requests:
              memory: 1Gi
              cpu: 500m
            limit:
              memory: 2Gi
          affinity: null
          readinessProbe:
            enabled: true
            path: "/v1/sys/health?standbyok=true&sealedcode=204&uninitcode=204"
          livenessProbe:
            enabled: true
            path: "/v1/sys/health?standbyok=true"
            initialDelaySeconds: 60
          extraEnvironmentVars:
            VAULT_CACERT: /vault/userconfig/vault-ha-tls/vault.ca
            VAULT_TLSCERT: /vault/userconfig/vault-ha-tls/vault.crt
            VAULT_TLSKEY: /vault/userconfig/vault-ha-tls/vault.key
          volumes:
            - name: userconfig-vault-ha-tls
              secret:
                defaultMode: 420
                secretName: vault-ha-tls-ad6d57g7t2
          volumeMounts:
            - mountPath: /vault/userconfig/vault-ha-tls
              name: userconfig-vault-ha-tls
              readOnly: true
          auditStorage:
            enabled: true
          ha:
            enabled: true
            replicas: 3
            raft:
              enabled: true
              setNodeId: true

              config: |
                ui = true
                listener "tcp" {
                  tls_disable = 0
                  address = "[::]:8200"
                  cluster_address = "[::]:8201"
                  tls_cert_file = "/vault/userconfig/vault-ha-tls/vault.crt"
                  tls_key_file  = "/vault/userconfig/vault-ha-tls/vault.key"
                  tls_client_ca_file = "/vault/userconfig/vault-ha-tls/vault.ca"
                }

                storage "raft" {
                  path = "/vault/data"
                    retry_join {
                    leader_api_addr = "https://vault-0.vault-internal:8200"
                    leader_ca_cert_file = "/vault/userconfig/vault-ha-tls/vault.ca"
                    leader_client_cert_file = "/vault/userconfig/vault-ha-tls/vault.crt"
                    leader_client_key_file = "/vault/userconfig/vault-ha-tls/vault.key"
                  }
                  retry_join {
                    leader_api_addr = "https://vault-1.vault-internal:8200"
                    leader_ca_cert_file = "/vault/userconfig/vault-ha-tls/vault.ca"
                    leader_client_cert_file = "/vault/userconfig/vault-ha-tls/vault.crt"
                    leader_client_key_file = "/vault/userconfig/vault-ha-tls/vault.key"
                  }
                  retry_join {
                    leader_api_addr = "https://vault-2.vault-internal:8200"
                    leader_ca_cert_file = "/vault/userconfig/vault-ha-tls/vault.ca"
                    leader_client_cert_file = "/vault/userconfig/vault-ha-tls/vault.crt"
                    leader_client_key_file = "/vault/userconfig/vault-ha-tls/vault.key"
                  }
                }
                service_registration "kubernetes" {}
        ui:
          enabled: true
          serviceType: "NodePort"
          externalPort: 8200

ArgoCD의 Application CRD 설정에서 source 부분은 아래와 같이 하면 된다. helm command로 repo를 추가할 때 URL이 repoURL에 설정이 되어야 한다.

helm repo add hashicorp https://helm.releases.hashicorp.com

그리고 targetRevisionCHART VERSION을 설정하면 되고, chart는 chart이름을 추가하면 된다.

$ helm search repo
NAME             CHART VERSION   APP VERSION   DESCRIPTION
hashicorp/vault  0.27.0          1.15.2        Official HashiCorp Vault Chart

마지막으로 releaseName은 helm install 명령어를 쓸 때 사용하는 이름을 설정하면 된다.

helm install vault hashcorp/vault

그렇게 반영을 하면 vault의 경우 아래와 같이 작성할 수가 있다.

source:
  chart: vault
  repoURL: https://helm.releases.hashicorp.com
  targetRevision: 0.27.0
  helm:
    releaseName: vault

이제 ArgoCD CRD를 배포하고, argocd application를 Sync한다.

kubectl apply -f argocd-vault.yml
argocd app sync vault

Vault init & unseal

이제 StatefulSet으로 아래처럼 pod가 정상적으로 잘 뜬 것을 확인할 수 있다.

$ kubectl get pod
NAME      READY   STATUS    RESTARTS   AGE
vault-0   1/1     Running   0          57s
vault-1   1/1     Running   0          57s
vault-2   1/1     Running   0          57s

이제 정상적으로 동작하기 위해서 init을 해줘야 한다. 그리고 이렇게 init을 하면 root key를 생성하게 되는데, 예제에서는 편의를 위해서 -key-shares=1로 하여 하나만 발급하게 한다. 실제로 운영할 때는 복수의 key를 분리하고 -key-threshold 갯수만큼 key로 unseal을 하도록 설정할 수 있다. 해당 값으로 unseal까지 완료한다.

kubectl exec vault-0 -- vault operator init -key-shares=1 -key-threshold=1 -format=json > cluster-keys-0.json
kubectl exec vault-0 -- vault operator unseal {unseal_keys_b64 값}

etc/resolve.conf에는 이렇게 작성이 되어 있고, vault-0.vault-internal로만 DNS lookup을 하더라도 해당 pod의 IP를 resolve할 수 있다.

$ cat /etc/resolv.conf
search vault.svc.cluster.local svc.cluster.local cluster.local
nameserver 169.254.25.10
options ndots:5

Raft로 이제 Standby node가 cluster join할 때, https://vault-0.vault-internal:8200로 URL를 정의했다. 그런데 이상하게 아래와 같이 전체 Domain을 lookup할 때는 정상적으로 되었지만,

$ kubectl exec -it vault-0 -- nslookup vault-0.vault-internal.vault.svc.cluster.local
Server:         169.254.25.10
Address:        169.254.25.10:53


Name:   vault-0.vault-internal.vault.svc.cluster.local
Address: 198.18.9.169

아래와 같이 줄여서 할 때는 되지 않았다.

$ kubectl exec -it vault-0 -- nslookup vault-0.vault-internal.vault
Server:         169.254.25.10
Address:        169.254.25.10:53

** server can't find vault-0.vault-internal.vault: NXDOMAIN

** server can't find vault-0.vault-internal.vault: NXDOMAIN

CoreDns의 ConfigMap에서 log를 추가했고, 여기에 로그가 남지가 않았다. 아래처럼 dnsutils가 있는 pod를 실행해서 lookup을 해보니 정상적으로 되었다.

apiVersion: v1
kind: Pod
metadata:
  name: dnsutils
  namespace: vault
spec:
  containers:
    - name: dnsutils
      image: registry.k8s.io/e2e-test-images/jessie-dnsutils:1.3
      command:
        - sleep
        - 'infinity'
      imagePullPolicy: IfNotPresent
  restartPolicy: Always
kubectl exec -it dnsutils -- nslookup vault-0.vault-internal.vault
Server:         169.254.25.10
Address:        169.254.25.10#53

Name:   vault-0.vault-internal.vault.svc.cluster.local
Address: 198.18.9.169

왜 Vault pod에서는 정상적으로 되는지 파악할 수 없었고, 아래와 같이 수정을 하였다.

storage "raft" {
  path = "/vault/data"
    retry_join {
    leader_api_addr = "https://vault-0.vault-internal.vault.svc.cluster.local:8200"
    leader_ca_cert_file = "/vault/userconfig/vault-ha-tls/vault.ca"
    leader_client_cert_file = "/vault/userconfig/vault-ha-tls/vault.crt"
    leader_client_key_file = "/vault/userconfig/vault-ha-tls/vault.key"
  }
  retry_join {
    leader_api_addr = "https://vault-1.vault-internal.vault.svc.cluster.local:8200"
    leader_ca_cert_file = "/vault/userconfig/vault-ha-tls/vault.ca"
    leader_client_cert_file = "/vault/userconfig/vault-ha-tls/vault.crt"
    leader_client_key_file = "/vault/userconfig/vault-ha-tls/vault.key"
  }
  retry_join {
    leader_api_addr = "https://vault-2.vault-internal.vault.svc.cluster.local:8200"
    leader_ca_cert_file = "/vault/userconfig/vault-ha-tls/vault.ca"
    leader_client_cert_file = "/vault/userconfig/vault-ha-tls/vault.crt"
    leader_client_key_file = "/vault/userconfig/vault-ha-tls/vault.key"
  }
}

이제 vault-1과 vault-2에서도 unseal을 해주고, raft에 join 한 것도 확인을 한다.

kubectl exec vault-1 -- vault operator unseal {unseal_keys_b64 값}
kubectl exec vault-2 -- vault operator unseal {unseal_keys_b64 값}
$ vault operator raft list-peers
Node       Address                        State       Voter
----       -------                        -----       -----
vault-0    vault-0.vault-internal:8201    leader      true
vault-1    vault-1.vault-internal:8201    follower    true
vault-2    vault-2.vault-internal:8201    follower    true

vault auth, engine, policy, role 설정

이제 vault가 정상적으로 실행이 되었으니, 예제처럼 vault에 필요한 것들을 설정한다.

vault auth enable -path dev-auth-mount kubernetes
vault write auth/dev-auth-mount/config kubernetes_host="https://kubernetes.default.svc"
vault secrets enable -path=dev kv-v2
vault policy write dev - <<EOF
path "dev/*" {
   capabilities = ["read"]
}
EOF
vault write auth/dev-auth-mount/role/dev \
   bound_service_account_names=default \
   bound_service_account_namespaces=dev \
   policies=dev \
   audience=vault \
   ttl=24h
vault kv put dev/webapp/config username="static-user" password="static-password"

vault secrets operator

이제 CRD로 관리하고, vault의 secret을 Kubernetes secret으로 sync해주는 vault-secrets-operator를 설치한다. 먼저 ca.crt가 Secret에 있어야 되서 동일하게 Kustomize로 Secret Manifest를 생성하고, sealed-secret으로 배포한다.

kustomization.yml

secretGenerator:
  - name: vault-ca
    files:
      - ca.crt=vault.ca
kubectl create namespace vault-secrets-operator
kubectl ns vault-secrets-operator
cat secret-ca.yml| kubeseal --controller-namespace kube-system --controller-name sealed-secrets-controller --format yaml > sealed-secret-ca.yml
kubectl apply -f sealed-secret-ca.yml

그리고 ArgoCD Application resource를 생성한다.

argocd-vault-secrets-operator.yml

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: vault-secrets-operator
  namespace: argocd
spec:
  project: default
  destination:
    server: 'https://kubernetes.default.svc'
    namespace: vault-secrets-operator
  source:
    chart: vault-secrets-operator
    repoURL: https://helm.releases.hashicorp.com
    targetRevision: 0.4.2
    helm:
      releaseName: vault-secrets-operator
      values: |
        defaultVaultConnection:
          enabled: true
          address: "https://vault.vault.svc.cluster.local:8200"
          skipTLSVerify: false
          caCertSecret: vault-ca-a2dh3972mm
kubectl apply -f argocd-vault-secrets-operator.yml

ArgoCD로 sync를 맞춰서 정상적으로 resource들이 배포된 것을 확인한다.

vault secrets operator test

이제 vault secrets operator가 정상적으로 설치되었으니, CRD를 생성하여 namespace dev에서 vault secret에 따라서 Kubernetes secret이 생성되는 것을 확인해본다.

위에서 vault에 다음과 같이 추가하였다.

  • kubernetes auth: dev-auth-mount
  • role: dev
  • kv-v2: dev/webapp/config

example-auth.yml

apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
  name: static-auth
  namespace: dev
spec:
  method: kubernetes
  mount: dev-auth-mount
  kubernetes:
    role: dev
    serviceAccount: default
    audiences:
      - vault

example-secret.yml

apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
  name: vault-kv-app
  namespace: dev
spec:
  type: kv-v2

  mount: dev

  path: webapp/config

  destination:
    name: dev-secret
    create: true

  refreshAfter: 30s

  vaultAuthRef: static-auth
kubectl apply -f example-auth.yml
kubectl apply -f example-secret.yml

이제 Kubernetes namespace dev에 devSecret Secret resource가 생성된다.

$ kubectl get secret -n dev
NAME              TYPE                             DATA   AGE
dev-secret        Opaque                           3      3s

이제 secret이 변경되면 자동으로 Deployment의 pod를 Rolling Update할 수 있도록, Reloader을 helm으로 설치한다.

argocd-reloader.yml

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: reloader
  namespace: argocd
spec:
  project: default
  syncPolicy:
    syncOptions:
      - CreateNamespace=true
  destination:
    server: 'https://kubernetes.default.svc'
    namespace: reloader
  source:
    chart: reloader
    repoURL: https://stakater.github.io/stakater-charts
    targetRevision: 1.0.56
    helm:
      releaseName: reloader

동일하게 ArgoCD로 배포를 하고, 그냥 secret으로부터 값을 stdout으로 남기는 Pod를 생성하는 Deployment Resource를 배포한다. 이때 annotation을 통해서 설치한 reloader가 변경된 secret를 watch하고 rolling update를 할 수 있도록 한다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ubuntu
  annotations:
    reloader.stakater.com/auto: 'true'
  namespace: dev
spec:
  replicas: 1
  selector:
    matchLabels:
      app: ubuntu
  template:
    metadata:
      labels:
        app: ubuntu
    spec:
      containers:
        - name: ubuntu
          image: ubuntu:latest
          # Just spin & wait forever
          command: ['/bin/bash', '-c', '--']
          args: ['while true; echo ${username}; do sleep 30; done;']
          envFrom:
            - secretRef:
                name: dev-secret

배포하고 나서 log를 확인하면 dev-secret에 있는 username 값이 찍히는 걸 확인할 수 있다.

$ kubectl logs -f --selector app=ubuntu
static-user

이제 vault에서 secret값을 변경하면, rolling update가 되고 static-user3가 log로 남는 걸 확인할 수 있다.

vault kv put dev/webapp/config username="static-user3" password="static-password"

결론

GitOps로 Kubernetes manifest file를 관리하는 상황에서 Secret은 어떻게 관리하는 것이 좋을까 고민을 했었다. 예전부터 Vault를 편리하게 사용해왔고, Kubernetes에서 vault를 사용할 수 있는 옵션들을 확인해보았다. 그중에서 Vault secrets operator가 가장 적합하다고 판단하여, ArgoCD, Kustomize, sealed-secret 등을 사용하여 셋팅을 해보았다. Hashicorp 만세! 👍👍