Sealed Secrets: GitHub Public Repository에 올려도 되는 안전한 Kubernetes Secrets

Kubernetes Secrets, 어떻게 하면 안전하게 관리할 수 있을까?
Jan 16, 2024
Sealed Secrets: GitHub Public Repository에 올려도 되는 안전한 Kubernetes Secrets

안녕하세요. Blux의 MLOps Engineer Shawn입니다. 😀

저는 오늘 Kubernetes Secrets의 보안상의 문제와 이를 극복하기 위한 수단 중 하나인 SealedSecrets을 소개드리고, SealedSecrets을 잘 활용하여 안전하게 Kubernetes Secrets을 관리할 수 있는 방법에 대해 말씀드리려합니다.

Kubernetes Secrets 및 이와 연관된 보안상의 문제

Kubernetes 환경에서 서버를 운영하고 있는 개발자라면 누구나 한번쯤은 Secrets라는 리소스에 대해 들어보셨을겁니다. Kubernetes에 변수를 저장하고 싶을 때, 상대적으로 민감하지 않은 정보는 ConfigMaps, 민감한 정보는 Secrets로 저장을 합니다. 기본적으로 ConfigMaps에는 데이터가 plain text로 저장되고, Secrets에는 암호화된 데이터가 저장되기 때문입니다. 다음은 각각 ConfigMapsSecrets로 저장할 수 있는 대표적인 값들의 예시입니다.

  • ConfigMaps: 서버의 이름, feature flags (True/False) 등

  • Secrets: API keys, DB passwords 등

저희 팀에서는 GitOps 전략을 적용하여 ArgoCD라는 툴을 이용해 Kubernetes 클러스터 내의 모든 핵심적인 어플리케이션을 배포하고 있기 때문에, 어플리케이션의 groundtruth가 되는 manifest 파일들을 GitHub repository에 올려서 관리하고 있습니다. (ArgoCD를 활용하는 CD best practice에 대해서는 추후 다른 블로그 글을 통해서 소개할 예정입니다 😀) 즉, 다음과 같은 YAML 파일들이 GitHub repository에 올라가는 것입니다.

  • my-configmaps.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: my-configmaps
  namespace: shawns-playground
data:
  STAGE: "dev"
  SERVER_ADDRESS: "https://my-server.blux.ai"

그렇다면 Secrets에는 plain text가 아닌 암호화된 데이터가 저장된다고 했으므로 manifest YAML 파일을 repository에 그대로 올려도 보안상 문제가 없을까요? 안타깝게도 그렇지 않습니다. 놀랍게도, Kubernetes의 Secrets에는 데이터가 단순히 base64로 encoding된 값이 들어갑니다.

예시로, 다음과 같이 kubectl 명령어로 Secrets을 생성한 뒤 YAML 파일을 살펴보겠습니다.

shawn@desktop:~$ kubectl create secret generic my-secrets -n shawns-playground --from-literal=MY_PASSWORD=an-ex@mple-p@ssw0rd -o yaml --dry-run=client > my-secrets.yaml && cat my-secrets.yaml
apiVersion: v1
data:
  MY_PASSWORD: YW4tZXhAbXBsZS1wQHNzdzByZA==
kind: Secret
metadata:
  creationTimestamp: null
  name: my-secrets
  namespace: shawns-playground

참고로 -o yaml 옵션은 output을 YAML 형태로 내보내기 위한 것이고, --dry-run=client 옵션은 실제로 Secrets을 생성하진 않고 시뮬레이션해보겠다는 의미로 해석하시면 됩니다.

얼핏 보기에는 MY_PASSWORD의 값 자리에 알아보기 힘든 값이 있어 보안상 안전해보입니다. 그러나 앞서 설명드렸듯이, 이 값은 단순히 an-ex@mple-p@ssw0rd라는 값을 base64로 encoding한 것에 불과합니다. 따라서, 반대로 base64로 decoding하면 원래의 문자열을 얻을 수도 있습니다.

shawn@desktop:~$ echo YW4tZXhAbXBsZS1wQHNzdzByZA== | base64 -d
an-ex@mple-p@ssw0rd

즉, 위에서 생성한 my-secrets.yaml 파일을 GitHub repository와 같이 누구든지 접근할 수 있는 곳에 올리면, 누구나 손쉽게 Secrets에 저장된 값으로부터 원래의 값을 유추해 낼 수 있다는 것입니다. Public repository에 올리면 말 그대로 "누구든" 접근할 수 있기 때문에 보안상 매우 취약하고, private repository에 올린다고 하더라도 회사의 규모가 크다면 많은 사람들이 접근할 수 있고, 그러다보면 누군가가 실수로 해당 파일을 외부로 유출시킬 수도 있기 때문에 보안상 좋지 않습니다. 또한 만약 private repository라고 하더라도 이에 접근할 수 있는 SSH key 등이 악의적인 사용자에 의해 탈취되었다면, 그것 역시 큰 문제라고 할 수 있습니다.

Kubernetes Secrets의 보안상의 문제를 해결할 수 있는 SealedSecrets

이와 같은 Kubernetes Secrets의 보안상의 취약점을 극복할 수 있는 HashiCorp Vault, AWS Secrets Manager, Azure Key Vault 등의 다양한 대안이 존재합니다. 하지만 이번 블로그에서는 cloud agnostic하고 CLI만 설치하면 누구나 쉽게 사용할 수 있는 SealedSecrets에 대해 소개해보려고 합니다.

SealedSecrets는 Kubernetes 환경에서 민감한 데이터를 관리하기 위한 솔루션으로, Bitnami에서 개발되었습니다. SealedSecrets의 공식 GitHub repository의 README를 보면 첫머리에 이렇게 써 있습니다.

Problem: "I can manage all my K8s config in git, except Secrets."
Solution: Encrypt your Secret into a SealedSecret, which is safe to store - even inside a public repository. The SealedSecret can be decrypted only by the controller running in the target cluster and nobody else (not even the original author) is able to obtain the original Secret from the SealedSecret.

즉, SealedSecrets은 Kubernetes 클러스터에서 돌아가고 있는 controller에 의해서만 복호화가 가능하고, 따라서 누군가가 SealedSecrets의 값만을 가지고 원래 Secrets의 값을 얻어내는 것은 불가능하다는 것입니다. SealedSecrets은 클러스터에서 돌아가고 있는 controller와 client-side utility로 사용할 수 있는 kubeseal로 구성되어 있는데, 이 kubeseal utility가 Secrets을 암호화할 때 asymmetric crypto 방식을 사용하기 때문에 오직 controller만이 SealedSecrets을 복호화할 수 있는 것입니다.

그렇다면 바로 본론으로 들어가서, 어떻게 SealedSecrets을 생성할 수 있는지, 그리고 이것으로 어떻게 Kubernetes Secrets을 대체할 수 있는지 알아보겠습니다.

SealedSecrets의 설치 및 사용

SealedSecrets을 사용하기 위해서는 위에 설명한 것과 같이 1) Kubernetes 클러스터에서 돌아갈 controller와 2) client-side utility로 사용할 kubeseal CLI를 설치해야 합니다.

설치 방법은 공식 GitHub repository에 잘 나와있는데, 저는 controller의 경우 Helm Chart를 이용해서 설치했고, kubeseal의 경우 Homebrew를 이용해서 설치했습니다.

이 두 가지가 성공적으로 설치되어있다고 가정하고, kubeseal 명령어를 이용해서 SealedSecrets을 생성하는 방법을 알려드리겠습니다. 위의 예시에서와 같이 MY_PASSWORD=an-ex@mple-p@ssw0rd라는 Key 및 Value 값을 갖는 my-secrets라는 Secretsshawns-playground라는 namespace에 생성해보겠습니다. 우선, 위에서 사용했던 것과 같은 -o yaml--dry-run=client 옵션을 이용해서 Secrets의 YAML manifest 파일을 생성합니다.

shawn@desktop:~$ kubectl create secret generic my-secrets -n shawns-playground --from-literal=MY_PASSWORD=an-ex@mple-p@ssw0rd -o yaml --dry-run=client > my-secrets.yaml

그리고 나서, kubeseal 명령어를 이용해서 위에서 생성한 SecretsSealedSecrets로 암호화합니다.

shawn@desktop:~$ kubeseal -o yaml < my-secrets.yaml > my-sealedsecrets.yaml

위 명령어를 보면 my-secrets.yaml 파일을 input으로 받고 (<), kubeseal로 암호화한 결과물을 my-sealedsecrets.yaml 이라는 output 파일(>)로 내보낸다는 것을 알 수 있습니다.

shawn@desktop:~$ cat my-sealedsecrets.yaml
---
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  creationTimestamp: null
  name: my-secrets
  namespace: shawns-playground
spec:
  encryptedData:
    MY_PASSWORD: AyAcxqU55B/Er+ZpcUzKHy3FZEKYpgcNayYCRa4GjJmx4fcZcbcT1YJimbx+EF4dAqpKBF+C/iUGxk4WOO+A2gqu1XIGCHUhhAcIS60ziHHjnma6cKjiS02DMoeUBT1jDPEgrHRzOgF8XjMPbNY7At+7LuGPvBac20RnR0tSecpfNT8Dk9sB8M2XaQQC/VpXLL5osPpmGllu4SdnGB6a97dl+U+nelbB4Nh7kiFwabK3U/D3AwM4N7NJfye/lsyqQvlwPIP22nADI/YhJjg/YhwGcEb/bYhU3/RODNMLeF8GWR8gjMoPTP22nADI/YhJjg/YhwGcEb/bHuTRWD/cYUBe7jKINMCspfQLclj8Zmdiu4nI5/eFexc+av4TwhHy5OTCnoxzBfn4g+sUcUmqJB2ETl9qDTDl7VWkpSgB2lP1FfgJFxj3XyllFi3f4zkJvC8Sq3wNFEAlJv7+euiSzThORGTAJI4W+egf6zqCMvAsfy39gMw55lwHRgebnNT1DV5P5/4KjCcdgAvp2UVIBgyBO/ioN3WkMzZXSeSaeyW98A0JI+wNtpDj+4CHM5fnj1Hffuq72Y4TaE83MFIxGesjwWehEHXQ3uLmje6HjeAnZwPeQbOGe4mfyejeix5GlrRTsapQHfVDAcIS60ziHHjnma6cKjiS02DMoeUBT1jDPEgrHRrlbZMH6Xo2QSoNwVMMSXu6XrXp4szqPLwi6IEBoo4A0719lA9GIiME=
  template:
    metadata:
      creationTimestamp: null
      name: my-secrets
      namespace: shawns-playground

.spec.encryptedDataMY_PASSWORD에 담긴 매우 복잡해보이는 값이 바로 기존의 value인 an-ex@mple-p@ssw0rdSealedSecrets로 암호화한 값입니다. 그리고 위에서 설명했듯이, 이 암호화된 값만을 가지고 원래 Secrets의 값을 얻어내는 것은 불가능합니다. 아래 명령어들을 통해 SealedSecrets로 Kubernetes Secrets을 대체할 수 있다는 것을 보여드리겠습니다.

shawn@desktop:~$ kubectl get secrets -n shawns-playground # 현재 shawns-playground namespace에는 아무런 Secrets도 생성되어 있지 않습니다. 만약 해당 namespace가 없다면 kubectl create namespace shawns-playground 명령어로 생성해주시기 바랍니다.
No resources found in shawns-playground namespace.
shawn@desktop:~$ ls
my-secrets.yaml my-sealedsecrets.yaml
shawn@desktop:~$ rm my-secrets.yaml # Kubernetes Secrets을 사용하지 않고 Sealed Secrets만으로 이를 대체할 것이므로 my-secrets.yaml 파일을 삭제합니다.
shawn@desktop:~$ kubectl apply -f my-sealedsecrets.yaml
sealedsecret.bitnami.com/my-secrets created
shawn@desktop:~$ kubectl get secrets -n shawns-playground # 이전에는 없었던 Kubernetes Secrets이 생겼습니다.
NAME        TYPE     DATA   AGE
my-secrets  Opaque   1      13s
shawn@desktop:~$ kubectl get sealedsecrets -n shawns-playground # Sealed Secrets 역시 생겼습니다. Sealed Secrets을 생성하면 수 초 뒤에 Kubernetes Secrets이 자동으로 생성됩니다.
NAME        AGE
my-secrets  15s
shawn@desktop:~$ kubectl get secrets -n shawns-playground my-secrets -o yaml # Secrets을 살펴보니, my-secrets.yaml에 담겨 있었던 an-ex@mple-p@ssw0rd가 base64로 encoding된 값인 YW4tZXhAbXBsZS1wQHNzdzByZA==가 MY_PASSWORD의 value 자리에 들어가 있습니다. 이와 같이 Sealed Secrets을 통해 Kubernetes Secrets을 대체할 수 있습니다.
apiVersion: v1
data:
  MY_PASSWORD: YW4tZXhAbXBsZS1wQHNzdzByZA==
kind: Secret
metadata:
  creationTimestamp: "2024-01-30T05:45:05Z"
  name: my-secrets
  namespace: shawns-playground
  ownerReferences:
  - apiVersion: bitnami.com/v1alpha1
    controller: true
    kind: SealedSecret
    name: my-secrets
    uid: <REDACTED>
  resourceVersion: "<REDACTED>"
  uid: <REDACTED>
type: Opaque

이와 같이 SealedSecrets이 담겨있는 my-sealedsecrets.yaml 파일만을 이용해서 SealedSecrets은 물론, Kubernetes Secrets까지 생성할 수 있음을 확인했습니다. 게다가 my-sealedsecrets.yaml 파일 안에 있는 값만으로는 원래의 Secrets의 값을 유추해 낼 수도 없기 때문에, 이 파일을 GitHub private 혹은 public repository를 포함해 아무 곳에나 올려도 보안상으로 전혀 문제가 없다는 얘기입니다.

끝으로, 저희 팀이 실제 운영 환경에서 쏠쏠하게 활용중인 kubesealscope 옵션에 대해 설명드리고 마치겠습니다. SealedSecrets 생성을 위해 kubeseal 명령어를 사용할 때, 다음 세 가지 scope 옵션 중 하나를 선택할 수 있습니다.

  • strict (default): SealedSecrets을 생성할 때 미리 정한 namespace 안에서 정해진 name으로 생성해야 합니다.

  • namespace-wide: 주어진 namespace 안에서는 SealedSecrets을 자유롭게 rename 할 수 있습니다.

  • cluster-wide: SealedSecrets을 어떤 namespace 안에서 어떤 name으로 생성하든 상관 없습니다.

생성할 Secrets의 이름과 Key 및 Value 값은 미리 알고 있는데 해당 Secrets이 생성될 namespace는 미리 알 수 없는 상황을 가정해봅시다. 그렇다면 해당 Secrets(SealedSecrets)은 namespace가 정해질 때까지 기다렸다가 생성해야만 할까요? Secrets의 이름은 물론 Key 및 Value 값까지 미리 알고 있는데, 그걸 이용해서 template을 미리 만들어놨다가 생성될 namespace가 정해지면 바로 SealedSecrets을 생성할 수는 없을까요? 물론 이 template에는 Secrets의 Value를 유추할 수 있는 보안적으로 안전하지 않은 값이 들어있으면 안 될 것입니다. 이런 상황에서 kubesealscope 옵션을 적절히 활용한다면, namespaceSecrets의 이름을 미리 알지 못하더라도 SealedSecrets을 생성할 준비를 미리 할 수 있습니다.

위의 예시에서, namespace를 별도로 지정해주지 않고 scope 옵션도 cluster-wide로 주고 SealedSecrets을 생성해보겠습니다.

shawn@desktop:~$ kubectl create secret generic my-secrets --from-literal=MY_PASSWORD=an-ex@mple-p@ssw0rd -o yaml --dry-run=client > my-secrets.yaml && cat my-secrets.yaml # .metadata에 namespace가 명시되어 있지 않습니다.
apiVersion: v1
data:
  MY_PASSWORD: YW4tZXhAbXBsZS1wQHNzdzByZA==
kind: Secret
metadata:
  creationTimestamp: null
  name: my-secrets
shawn@desktop:~$ kubeseal --scope cluster-wide -o yaml < my-secrets.yaml > my-cluster-wide-sealedsecrets.yaml && cat my-cluster-wide-sealedsecrets.yaml # .metadata.annotations에 cluster-wide 옵션임이 명시되고, Secrets와 마찬가지로 별도의 namespace는 명시되어 있지 않습니다.
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  annotations:
    sealedsecrets.bitnami.com/cluster-wide: "true"
  creationTimestamp: null
  name: my-secrets
spec:
  encryptedData:
    MY_PASSWORD: AgAaNcutTBBdjdfkjfsk1j/dfjsJFJFsli+zgIjL1HYRpvNw/uDN2CLtRCnrJhMk/BhbNjIWBr5m5PQVyzQV3sT18yfkpw3Vx+w5LCsAQG7449NzuxyfPewrI+cNZACRwQ7TscTjD1RSkPnPfI4/0a0+8zrrs5b1S8LLXZl/VCwTuslYetutTfqAXWd2twkP1BRC6dz0asrE1CQ56F0iNsJ9bS2hnVp6f7AoCtuR3QgaEKIrmMsZklG2S8S3KanNPAwrF6PvzpZyIq/RiZSyHWPO+mwuRL8TRZ4Np5B3VtwsnOG/Zt31H/yd0c+zZVhI1WJuXSCa+iAo3GqgBz18yyGIaPPGxe2MRtWXCTRqxQ0sPO+5AFg6ZU9VZmFDUIr/ondrIdOsy2pfEeV0ngGXeGOhAgdfjasj23jFdsfsdf+dfTUXNylfKBWsvaVPMa+s7uzl7rBQ+Xdyz2QG5iaQwAQ8YZN7PZIYcI/NBGUI6inraGY4kOU1SxHkzSHqKafchjB3+q9qRSrL+VxNl7WsdY/hqKODhNV7bfytLxpwPArlz5HR1WfA5h0HcpC+SbSYH2hajIbMbg5Tl0hlKcNgRRiDqHDcTC/nVL8Bhcf65f9vfd6a/sd+sdSSB2jTe+8XPjtFUoY/TjoX/T4/jTV3KlsNKvebEfNyrdBVvT3+RuTnFf/lclScCgbYRI2H
  template:
    metadata:
      annotations:
        sealedsecrets.bitnami.com/cluster-wide: "true"
      creationTimestamp: null
      name: my-secrets

이렇게 만들어진 my-cluster-wide-sealedsecrets.yaml은 아무 namespace에나 적용할 수 있습니다.

shawn@desktop:~$ kubectl apply -f my-cluster-wide-sealedsecrets.yaml
sealedsecret.bitnami.com/my-secrets created
shawn@desktop:~$ kubectl apply -f my-cluster-wide-sealedsecrets.yaml -n shawns-playground
sealedsecret.bitnami.com/my-secrets created


만약 scope 옵션을 strict로 줬거나 아무것도 안 줬다면 (default 옵션은 strict) 미리 정한 namespace 안에서 정해진 이름으로 SealedSecrets을 생성해야합니다.

shawn@desktop:~$ kubeseal --scope strict -o yaml < my-secrets.yaml > my-strict-sealedsecrets.yaml && cat my-strict-sealedsecrets.yaml # my-secrets.yaml 파일을 만들 때 별도의 namespace를 지정해주지 않았기 때문에 자동으로 default namespace로 적용되었습니다.
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  creationTimestamp: null
  name: my-secrets
  namespace: default
spec:
  encryptedData:
    MY_PASSWORD: AgBh7/gwLlMJhtYOjP+CtLkuyraNeYAzY1g7oD5bnEnkRby3JDiG601GOmETX3TilZ/whpNJxV8ZXnIDzce/2foVhTl+KL9PCqZoPEruIcmOMwzDcE/lE3JHYZqgid198suGiLtIAzocz9+eY3jqO6MzvaGWQJZPiKLvCqz5MPUG2G6iQCkB40OvGcSTdiFyzUhkLnB3wASh9oZ1SthyMp9TLrrUUVs34xFAWDgW/ac9R16RXoAlmP17e9OhUx/qxSnaNH28+cr+/zpyI9Ko6OHG4rb1+eL2bKJxEq/qDRNDUNEwRhPNvT7oqQhJWE9ZIncVKb0thM3xFAe2E4XvqwvIW94shHmLmx9ylNR2kBBlB+fbn62ZhlWgRtaUTFjWHpZUToWaOirFTkZzY3B/OHJVbBdDBReldIQgz87UYbQRFNsIJ5E4OfLxvIuVYcCe1IJqcT6/UAeAm4lS+lrDXwp65V8S+TaenvcyVL8Y/JARUccRG8VBvqG3IAflw4bcY3fiJazukkZfh+WIyei78ySdYI+MP5AVf4Ohy6GSZ7BMeAWyqM3Po2zUyPYCPO/LQqqqyKogYyTH+FPI39kA/HirYmX4Yh7gQz+d30pFPVjAh3gFIB08FrxXdOPy1wGmtYMQM/PyRJZpXWAh8M39oqHr8qS1EXKNfpvKW96DUr39Dk7YHe6GTdCR5DxbgYqkqLEZbyVBD8jF/cu/kTv4xR+M+JFh
  template:
    metadata:
      creationTimestamp: null
      name: my-secrets
      namespace: default
shawn@desktop:~$ kubectl apply -f my-strict-sealedsecrets.yaml # default namespace에서는 Sealed Secrets이 잘 생성됩니다.
sealedsecret.bitnami.com/my-secrets created
shawn@desktop:~$ kubectl apply -f my-strict-sealedsecrets.yaml -n shawns-playground # 그 외의 namespace에서는 Sealed Secrets이 생성되지 않습니다.
error: the namespace from the provided object "default" does not match the namespace "shawns-playground". You must pass '--namespace=default' to perform this operation.

이상으로 SealedSecrets의 사용법부터 이를 이용해서 어떻게 Kubernetes Secrets을 대체할 수 있는지, 그리고 kubesealscope 옵션을 어떻게 활용할 수 있는지까지 살펴보았습니다.

Kubernetes Secrets을 보다 안전하게 관리하고 싶은 모든 개발자 분들에게 이 글이 도움이 되었으면 좋겠습니다. 🙏 추가적으로 궁금하신 내용이나 논의하고 싶으신 사항이 있다면 제 이메일로 (shawn@blux.ai) 편하게 연락주세요! 😀

감사합니다.


블럭스에서 구축한 기술에 대해 더 궁금하신 분들은 아래 글을 읽어주세요 🛠️

- 쿠버네티스 환경에서 Autoscaling 시스템 구축하기

- Kinesis Data Streams 도입기

Share article
Subscribe Newsletter
Stay connected for the latest news and insights.
RSSPowered by inblog