SOPS
암호화된 파일 편집 도구 - YAML/JSON 값을 선택적으로 암호화하여 Git에 안전하게 저장하는 Mozilla 오픈소스
암호화된 파일 편집 도구 - YAML/JSON 값을 선택적으로 암호화하여 Git에 안전하게 저장하는 Mozilla 오픈소스
SOPS(Secrets OPerationS)는 Mozilla에서 개발한 암호화된 파일 편집 도구입니다. YAML, JSON, ENV, INI 파일에서 키(key)는 평문으로 유지하고 값(value)만 선택적으로 암호화하는 것이 특징입니다. 이 덕분에 암호화된 파일도 구조를 파악할 수 있어 코드 리뷰, diff, 병합 충돌 해결이 가능합니다.
SOPS는 다양한 키 관리 시스템(KMS)을 지원합니다. AWS KMS, Google Cloud KMS, Azure Key Vault 같은 클라우드 KMS와 HashiCorp Vault, age, PGP를 모두 지원하며, 여러 키를 동시에 사용할 수 있습니다. 예를 들어 개발팀은 age 키로, 프로덕션 CI/CD는 AWS KMS로 복호화하도록 설정할 수 있습니다. 여러 키 중 하나만 있으면 복호화되므로 키 로테이션이나 팀원 변경 시에도 유연합니다.
SOPS의 큰 장점은 에디터 통합입니다. `sops secrets.yaml` 명령어로 파일을 열면 자동으로 복호화된 상태로 편집기가 열리고, 저장 후 닫으면 다시 암호화됩니다. 따라서 일반 파일 편집과 동일한 워크플로우로 시크릿을 관리할 수 있습니다. Vim, VS Code 플러그인도 있어 IDE에서 직접 편집할 수 있습니다.
GitOps 환경에서 SOPS는 Flux, ArgoCD와 네이티브로 통합됩니다. Flux의 kustomize-controller는 SOPS 암호화된 시크릿 파일을 자동으로 복호화하여 클러스터에 적용합니다. ArgoCD도 SOPS 플러그인을 통해 암호화된 매니페스트를 처리합니다. 이를 통해 Kubernetes 시크릿을 포함한 전체 인프라를 Git으로 관리할 수 있습니다.
# macOS 설치
brew install sops
# Linux 설치
wget https://github.com/getsops/sops/releases/download/v3.8.1/sops-v3.8.1.linux.amd64
chmod +x sops-v3.8.1.linux.amd64
sudo mv sops-v3.8.1.linux.amd64 /usr/local/bin/sops
# age 키 생성 (간단한 로컬 암호화용)
# age 설치: brew install age
age-keygen -o ~/.sops/age/keys.txt
# 출력 예: AGE-SECRET-KEY-1QFSMEH... (이것이 비공개키)
# 공개키: age1... (이것으로 암호화)
# AWS KMS 키 사용 시 IAM 권한 필요
# aws kms create-key --description "SOPS encryption key"
# .sops.yaml - 프로젝트 루트에 위치
# 경로 패턴별로 다른 암호화 키 적용
creation_rules:
# 개발 환경 - age 키 사용
- path_regex: secrets/development/.*\.yaml$
age: >-
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# 스테이징 환경 - AWS KMS 사용
- path_regex: secrets/staging/.*\.yaml$
kms: arn:aws:kms:ap-northeast-2:123456789:key/abc-def-123
# 프로덕션 환경 - AWS KMS + age (둘 중 하나로 복호화 가능)
- path_regex: secrets/production/.*\.yaml$
kms: arn:aws:kms:ap-northeast-2:123456789:key/prod-key-456
age: >-
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# Kubernetes 시크릿 - 특정 키만 암호화
- path_regex: k8s/.*secrets?\.yaml$
kms: arn:aws:kms:ap-northeast-2:123456789:key/k8s-secrets-key
encrypted_regex: ^(data|stringData)$
# Terraform 변수 - .tfvars 파일
- path_regex: terraform/.*\.tfvars$
age: >-
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# 새 파일 암호화 (생성과 동시에)
# .sops.yaml의 creation_rules가 자동 적용됨
sops secrets/production/database.yaml
# 편집기가 열리고, 저장 후 닫으면 암호화됨
# 기존 평문 파일을 암호화
sops -e secrets.yaml > secrets.enc.yaml
# 암호화된 파일을 평문으로 복호화 (stdout으로 출력)
sops -d secrets.enc.yaml
# 암호화된 파일을 제자리에서 복호화 (파일 덮어쓰기)
sops -d -i secrets.enc.yaml
# 특정 키로 명시적 암호화
sops --age age1ql3z7hjy54pw3... -e secrets.yaml > secrets.enc.yaml
# AWS KMS로 암호화
sops --kms arn:aws:kms:ap-northeast-2:123456789:key/my-key -e secrets.yaml
# 편집 모드로 열기 (복호화 → 편집 → 재암호화)
sops secrets.enc.yaml
# 특정 값만 추출 (jq처럼)
sops -d --extract '["database"]["password"]' secrets.enc.yaml
# secrets/production/database.yaml (암호화 후)
# 키는 평문, 값만 암호화됨
database:
host: ENC[AES256_GCM,data:Mj3p...,iv:...,tag:...,type:str]
port: ENC[AES256_GCM,data:NjQ=,iv:...,tag:...,type:int]
username: ENC[AES256_GCM,data:YWRt...,iv:...,tag:...,type:str]
password: ENC[AES256_GCM,data:c3Vw...,iv:...,tag:...,type:str]
redis:
host: ENC[AES256_GCM,data:cmVk...,iv:...,tag:...,type:str]
password: ENC[AES256_GCM,data:cmVk...,iv:...,tag:...,type:str]
sops:
kms:
- arn: arn:aws:kms:ap-northeast-2:123456789:key/prod-key-456
created_at: "2024-01-15T10:30:00Z"
enc: AQICAHj...
age:
- recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24...
-----END AGE ENCRYPTED FILE-----
lastmodified: "2024-01-15T10:30:00Z"
mac: ENC[AES256_GCM,data:...,tag:...,type:str]
version: 3.8.1
# flux-system/gotk-sync.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: infrastructure
namespace: flux-system
spec:
interval: 10m
path: ./infrastructure
prune: true
sourceRef:
kind: GitRepository
name: flux-system
# SOPS 복호화 활성화
decryption:
provider: sops
secretRef:
name: sops-age # age 비공개키를 담은 Secret
---
# SOPS age 키를 Kubernetes Secret으로 저장
# kubectl create secret generic sops-age \
# --namespace=flux-system \
# --from-file=age.agekey=~/.sops/age/keys.txt
apiVersion: v1
kind: Secret
metadata:
name: sops-age
namespace: flux-system
type: Opaque
data:
age.agekey: QUdFLVNFQ1JFVC1LRVkt... # base64 encoded age key
import subprocess
import yaml
import json
from pathlib import Path
from typing import Any, Optional
import os
class SOPSClient:
"""SOPS 암호화된 파일을 다루는 Python 클라이언트"""
def __init__(self, age_key_file: Optional[str] = None):
"""
Args:
age_key_file: age 비공개키 파일 경로 (환경변수 SOPS_AGE_KEY_FILE로도 설정 가능)
"""
if age_key_file:
os.environ['SOPS_AGE_KEY_FILE'] = age_key_file
def decrypt_file(self, file_path: str) -> dict:
"""SOPS 파일을 복호화하여 dict로 반환"""
result = subprocess.run(
['sops', '-d', file_path],
capture_output=True,
text=True
)
if result.returncode != 0:
raise RuntimeError(f"SOPS decryption failed: {result.stderr}")
file_ext = Path(file_path).suffix.lower()
if file_ext in ['.yaml', '.yml']:
return yaml.safe_load(result.stdout)
elif file_ext == '.json':
return json.loads(result.stdout)
else:
return {'content': result.stdout}
def encrypt_data(self, data: dict, output_path: str,
file_type: str = 'yaml') -> None:
"""dict를 SOPS로 암호화하여 파일로 저장"""
if file_type == 'yaml':
content = yaml.dump(data, default_flow_style=False)
input_type = '--input-type=yaml'
output_type = '--output-type=yaml'
else:
content = json.dumps(data, indent=2)
input_type = '--input-type=json'
output_type = '--output-type=json'
result = subprocess.run(
['sops', '-e', input_type, output_type, '/dev/stdin'],
input=content,
capture_output=True,
text=True
)
if result.returncode != 0:
raise RuntimeError(f"SOPS encryption failed: {result.stderr}")
Path(output_path).write_text(result.stdout)
def get_value(self, file_path: str, key_path: str) -> Any:
"""암호화된 파일에서 특정 값 추출"""
# key_path 예: "database.password" 또는 '["database"]["password"]'
json_path = ''.join(f'["{k}"]' for k in key_path.split('.'))
result = subprocess.run(
['sops', '-d', '--extract', json_path, file_path],
capture_output=True,
text=True
)
if result.returncode != 0:
raise RuntimeError(f"SOPS extraction failed: {result.stderr}")
return result.stdout.strip()
def update_value(self, file_path: str, key_path: str, value: Any) -> None:
"""암호화된 파일의 특정 값 업데이트"""
# 복호화
data = self.decrypt_file(file_path)
# 중첩된 키 업데이트
keys = key_path.split('.')
current = data
for key in keys[:-1]:
current = current[key]
current[keys[-1]] = value
# 재암호화
file_ext = Path(file_path).suffix.lower()
file_type = 'yaml' if file_ext in ['.yaml', '.yml'] else 'json'
self.encrypt_data(data, file_path, file_type)
# 사용 예시
if __name__ == "__main__":
# age 키 파일 지정
sops = SOPSClient(age_key_file=os.path.expanduser("~/.sops/age/keys.txt"))
# 전체 파일 복호화
secrets = sops.decrypt_file("secrets/production/database.yaml")
print(f"DB Host: {secrets['database']['host']}")
print(f"DB User: {secrets['database']['username']}")
# 특정 값만 추출
db_password = sops.get_value(
"secrets/production/database.yaml",
"database.password"
)
print(f"DB Password: {db_password}")
# 값 업데이트
sops.update_value(
"secrets/production/database.yaml",
"database.password",
"new_secure_password_123"
)