- Published on
vault secrets operator 사용해보기
- Authors
- Name
- Jay
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
이렇게 생성하고 나면 CONDITION
이 Pending
으로 보이게 된다.
$ 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
는 사용하지 않을 거라 enabled
을 false
로 지정했다. 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
그리고 targetRevision
은 CHART 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 만세! 👍👍