Table of contents
- 설정을 왜 분리하나
- ConfigMap 기본
- ConfigMap을 Pod에 주입하기
- Secret — ConfigMap의 민감 정보 버전
- Secret을 Pod에 주입하기
- Secret은 정말 안전한가
- 외부 시크릿 관리
- 실습 — ConfigMap + Secret 같이 쓰기
설정을 왜 분리하나
애플리케이션을 컨테이너로 만들어 배포하다 보면 한 가지 질문이 계속 따라붙는다. DB 접속 정보나 API 키 같은 설정은 어디에 둬야 하나?
코드에 직접 박아넣으면 당장은 편하다. 그런데 개발 환경, 스테이징, 프로덕션마다 값이 달라지기 시작하면 바로 골치 아파진다. 이미지를 환경별로 따로 빌드하는 건 낭비고, 값 하나 바꾸려고 배포 전체를 다시 돌리는 것도 이상하다. 무엇보다 비밀번호 같은 민감 정보가 이미지 레이어에 남는 건 보안 관점에서 심각한 문제다.
이 문제를 해결하려고 쿠버네티스는 두 가지 리소스를 따로 만들어뒀다. ConfigMap은 평범한 설정을 담고, Secret은 민감 정보를 담는다. 둘 다 Pod에 환경변수나 파일로 주입할 수 있어서, 이미지는 그대로 두고 설정만 갈아끼울 수 있다.
flowchart LR
A[컨테이너 이미지] -->|변경 없음| D[Pod]
B[ConfigMap<br/>일반 설정] -->|주입| D
C[Secret<br/>민감 정보] -->|주입| D
D -->|환경별로 다른 값| E[Dev/Stage/Prod]
이미지는 한 번 만들고, 설정만 환경별로 갈아끼우는 구조. 이게 12 Factor App(클라우드 네이티브 앱을 설계하는 12가지 원칙 모음)이 말하는 “설정을 환경에 저장한다(Store config in the environment)“의 쿠버네티스 버전이다.
ConfigMap 기본
ConfigMap은 키-값 쌍으로 이루어진 설정 저장소다. YAML에서 data 블록 아래에 문자열 값을 나열하면 된다.
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
namespace: default
data:
LOG_LEVEL: "info"
APP_MODE: "production"
MAX_CONNECTIONS: "100"
application.yaml: |
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://db-service:3306/myapp
위 예제를 보면 두 가지 사용 패턴이 섞여 있다. LOG_LEVEL 같은 단순한 키-값은 환경변수로 바로 쓸 수 있고, application.yaml 같은 멀티라인 문자열은 파일 전체를 하나의 엔트리로 담을 수 있다. 파일 전체를 담을 때는 |를 써서 블록 스칼라로 선언한다.
만든 ConfigMap은 kubectl로 확인할 수 있다.
kubectl apply -f app-config.yaml
kubectl get configmap app-config -o yaml
kubectl describe configmap app-config
YAML 없이 명령어로 만들 수도 있다. 실습이나 임시 작업에서 편하다.
# 리터럴 값으로 생성
kubectl create configmap app-config \
--from-literal=LOG_LEVEL=info \
--from-literal=APP_MODE=production
# 파일에서 생성
kubectl create configmap app-config \
--from-file=application.yaml
# 디렉토리 전체에서 생성 (각 파일이 하나의 엔트리가 됨)
kubectl create configmap app-config \
--from-file=./config/
ConfigMap을 Pod에 주입하기
만들어진 ConfigMap은 두 가지 방법으로 Pod에 연결된다. 환경변수로 넣거나, 볼륨으로 마운트하거나.
두 방식의 차이를 그림으로 정리하면 이렇다. 환경변수는 Pod 시작 시점에 값이 고정되고, 볼륨은 파일시스템을 통해 실시간 갱신이 가능하다는 게 가장 큰 차이다.
flowchart LR
CM["ConfigMap: app-config\ndata:\n LOG_LEVEL: info\n app.yaml: ..."]
subgraph ENV["방식 1: 환경변수 주입"]
E_POD["Pod 환경변수\nLOG_LEVEL=info"]
E_APP["앱: os.getenv()"]
end
subgraph VOL["방식 2: 볼륨 마운트"]
V_FILE["/etc/config/app.yaml\n(ConfigMap 업데이트 시 자동 반영)"]
V_APP["앱: 파일 읽기 + Watch"]
end
CM -->|"Pod 시작 시 주입\n(변경 시 재시작 필요)"| E_POD --> E_APP
CM -->|"심볼릭 링크 갱신\n(변경 시 자동 반영)"| V_FILE --> V_APP
환경변수로 주입
가장 직관적인 방식이다. 컨테이너 스펙의 env 또는 envFrom에 ConfigMap을 참조한다.
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
containers:
- name: app
image: myapp:1.0
env:
# 특정 키만 골라서 주입
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: app-config
key: LOG_LEVEL
envFrom:
# ConfigMap의 모든 키를 한 번에 주입
- configMapRef:
name: app-config
env는 하나씩 지정하는 방식이고, envFrom은 ConfigMap의 모든 키를 한꺼번에 환경변수로 풀어준다. 키가 많을 때는 envFrom이 편하지만, 어떤 환경변수가 들어가는지 매니페스트만 봐서는 알 수 없다는 단점이 있다. 상황에 맞게 골라 쓰면 된다.
볼륨으로 마운트
설정 파일 전체를 컨테이너 안에 마운트하고 싶을 때 쓰는 방식이다. 특히 Spring Boot의 application.yaml이나 Nginx의 nginx.conf 같은 파일 단위 설정에 적합하다.
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
containers:
- name: app
image: myapp:1.0
volumeMounts:
- name: config-volume
mountPath: /etc/config
readOnly: true
volumes:
- name: config-volume
configMap:
name: app-config
이렇게 하면 ConfigMap의 각 키가 /etc/config/ 아래에 파일로 생긴다. application.yaml 키는 /etc/config/application.yaml 파일로 나타난다.
볼륨 방식의 숨은 장점 하나. ConfigMap을 수정하면 마운트된 파일도 자동으로 갱신된다. 환경변수 방식은 Pod를 재시작해야 반영되지만, 볼륨은 잠시 뒤 알아서 바뀐다. 물론 애플리케이션이 파일 변경을 감지해서 리로드할 수 있어야 의미가 있다.
Secret — ConfigMap의 민감 정보 버전
Secret은 겉보기엔 ConfigMap과 거의 똑같다. 차이는 값이 base64로 인코딩되어 저장된다는 점과, API 서버가 민감 정보로 취급한다는 점이다.
apiVersion: v1
kind: Secret
metadata:
name: db-secret
type: Opaque
data:
username: YWRtaW4= # base64로 인코딩된 "admin"
password: UEBzc3cwcmQh # base64로 인코딩된 "P@ssw0rd!"
base64는 암호화가 아니라 단순 인코딩이다. echo "YWRtaW4=" | base64 -d를 돌리면 원래 값이 그대로 나온다. 그래서 Secret을 YAML에 직접 써서 Git에 올리는 건 매우 위험하다.
평문으로 쓰고 싶다면 stringData를 쓴다. 쿠버네티스가 알아서 base64로 변환해준다.
apiVersion: v1
kind: Secret
metadata:
name: db-secret
type: Opaque
stringData:
username: admin
password: "P@ssw0rd!"
명령어로 만들 때는 쿠버네티스가 자동으로 인코딩해주니 편하다.
kubectl create secret generic db-secret \
--from-literal=username=admin \
--from-literal=password='P@ssw0rd!'
Secret의 타입
Secret에는 type 필드가 있다. 용도별로 정해진 타입이 있어서, 쿠버네티스가 값의 구조를 검증해준다.
| 타입 | 용도 |
|---|---|
Opaque | 일반 키-값 (기본값) |
kubernetes.io/tls | TLS 인증서와 키 (tls.crt, tls.key) |
kubernetes.io/dockerconfigjson | 프라이빗 이미지 레지스트리 인증 정보 |
kubernetes.io/service-account-token | ServiceAccount 토큰 (자동 생성) |
예를 들어 TLS 인증서를 담을 때는 이렇게 쓴다.
kubectl create secret tls my-tls \
--cert=./server.crt \
--key=./server.key
Ingress에서 HTTPS를 쓸 때 바로 참조하는 Secret이 이 타입이다.
Secret을 Pod에 주입하기
주입 방식은 ConfigMap과 거의 같다. configMapRef 대신 secretRef를, configMapKeyRef 대신 secretKeyRef를 쓴다.
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
containers:
- name: app
image: myapp:1.0
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-secret
key: password
volumeMounts:
- name: secret-volume
mountPath: /etc/secrets
readOnly: true
volumes:
- name: secret-volume
secret:
secretName: db-secret
볼륨으로 마운트하면 각 키가 파일로 나타난다. 다만 Secret 볼륨은 기본적으로 tmpfs(메모리 파일시스템)에 저장된다. 노드 디스크에 기록되지 않아서 조금이라도 더 안전하다.
Secret은 정말 안전한가
Secret의 안전성은 몇 겹의 보안 계층을 어떻게 쌓느냐에 달렸다. 각 계층에서 어떤 위협을 막아주는지 그림으로 살펴보자.
flowchart TB
L0["기본 Secret\n(base64 인코딩만)"] --> T0["⚠ etcd 덤프 시 노출\n⚠ Git에 올리면 그대로 노출"]
L0 --> L1["+ etcd 암호화\n(EncryptionConfiguration)"]
L1 --> T1["✓ etcd 덤프 방어\n⚠ 클러스터 admin은 여전히 접근 가능"]
L1 --> L2["+ RBAC 최소 권한\n(secrets get/list 제한)"]
L2 --> T2["✓ 일반 사용자 접근 차단\n⚠ Pod 띄울 수 있으면 환경변수로 읽기 가능"]
L2 --> L3["+ 외부 시크릿 저장소\n(Vault, AWS SM)"]
L3 --> T3["✓ 중앙 감사 로그\n✓ 동적 시크릿/키 회전\n✓ Git에 값 자체가 없음"]
여기서 솔직하게 짚고 가야 할 부분이 있다. 기본 설정의 Secret은 생각만큼 안전하지 않다.
- etcd에 저장될 때 기본값은 평문이다. 명시적으로 암호화 설정을 켜야 한다
- base64는 암호화가 아니다
- 클러스터 admin 권한을 가진 사람은 모든 Secret을 볼 수 있다
- Pod를 띄울 수 있는 사람은 Secret을 환경변수로 주입해서 읽을 수 있다
쿠버네티스는 EncryptionConfiguration을 통해 etcd 레벨 암호화를 제공한다. kube-apiserver를 실행할 때 암호화 프로바이더를 설정하면, etcd에는 암호화된 값이 저장된다.
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: <32-byte-base64-key>
- identity: {}
여기서 aescbc가 실제 암호화 프로바이더고, identity는 평문 저장(fallback)이다. 프로바이더 순서가 중요하다. 맨 위에 있는 게 쓰기에 쓰이고, 읽기는 위에서부터 차례로 시도한다.
외부 시크릿 관리
etcd 암호화는 필요한 최소한이지만, 실제 운영에서는 더 엄격한 요구가 생긴다. 시크릿의 감사 로그가 필요하거나, 주기적으로 키를 회전해야 하거나, 쿠버네티스 외부의 시스템(예: RDS)에서도 같은 값을 참조해야 할 때다.
이럴 때 쓰는 게 외부 시크릿 저장소 연동이다. 대표적인 조합이 몇 가지 있다.
- HashiCorp Vault: 가장 많이 쓰이는 시크릿 매니저. 동적 시크릿, 리스 기반 회전이 강점
- AWS Secrets Manager / Parameter Store: AWS 환경에서 IAM 기반으로 접근 제어
- GCP Secret Manager / Azure Key Vault: 각 클라우드의 관리형 시크릿 서비스
이들을 쿠버네티스에 연결할 때 표준처럼 쓰이는 게 External Secrets Operator다. ExternalSecret이라는 CRD로 “이 외부 시크릿을 이 네임스페이스의 Secret으로 동기화해라”고 선언하면, 오퍼레이터가 주기적으로 외부 저장소를 조회해서 쿠버네티스 Secret을 만들어준다.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: db-credentials
namespace: backend
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: db-secret # 이 이름으로 쿠버네티스 Secret이 생성됨
creationPolicy: Owner
data:
- secretKey: password
remoteRef:
key: secret/data/prod/db
property: password
이 방식의 가치는 Git에 시크릿을 올리지 않아도 된다는 것이다. Git에 들어가는 건 “Vault의 이 경로를 가져와라”는 참조뿐이고, 실제 값은 Vault에만 존재한다. GitOps와 시크릿 관리를 동시에 해결할 수 있다.
flowchart LR
A[Git 리포<br/>ExternalSecret 정의] -->|배포| B[쿠버네티스]
B --> C[External Secrets Operator]
C -->|조회| D[Vault / AWS SM]
D -->|값 반환| C
C -->|Secret 생성| E[Pod에 주입]
실습 — ConfigMap + Secret 같이 쓰기
지금까지 배운 걸 한 번에 엮어보자. Nginx에 커스텀 설정을 ConfigMap으로 주입하고, Basic Auth 비밀번호를 Secret으로 주입한다.
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
data:
default.conf: |
server {
listen 80;
location / {
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/auth/htpasswd;
return 200 "Hello from configured nginx\n";
}
}
---
apiVersion: v1
kind: Secret
metadata:
name: nginx-auth
type: Opaque
stringData:
# htpasswd -nb admin secret 으로 생성한 값
htpasswd: "admin:$apr1$abc123$xyz..."
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.27
ports:
- containerPort: 80
volumeMounts:
- name: config
mountPath: /etc/nginx/conf.d
- name: auth
mountPath: /etc/nginx/auth
readOnly: true
volumes:
- name: config
configMap:
name: nginx-config
- name: auth
secret:
secretName: nginx-auth
적용하고 확인해본다.
kubectl apply -f nginx-demo.yaml
kubectl port-forward deploy/nginx 8080:80
# 다른 터미널에서
curl -u admin:secret http://localhost:8080/
설정과 자격 증명을 완전히 분리해서 이미지는 공식 nginx:1.27 그대로 쓴다는 점이 핵심이다. 환경이 바뀌어도 이미지는 그대로, ConfigMap과 Secret만 갈아끼우면 된다.
다음 편에서는 Pod가 재시작되어도 데이터가 남아있게 하는 스토리지 시스템을 다룬다. PV와 PVC가 어떻게 연결되고, StorageClass로 동적 프로비저닝이 어떻게 동작하는지 살펴본다.




Loading comments...