비밀을 다시는 커밋하지 마세요: AWS Secrets Manager에서 .env 파일 생성
Source: Dev.to
TL;DR
AWS Secrets Manager에 비밀을 저장합니다. Python 스크립트로 필요할 때마다 .env 파일을 생성합니다. 이제 자격 증명을 절대 커밋하지 마세요.
The Problem
모든 팀은 결국 비밀을 커밋하게 됩니다. GitHub은 지난해 비밀 스캔을 통해 1,200만 개 이상의 노출된 자격 증명을 감지했습니다.
일반적인 접근 방식은 모두 실패 가능성이 있습니다:
- **
.gitignore**는 개발자가 추가하는 것을 잊거나, 새로 클론한 뒤 Slack으로 파일을 요청할 때 실패합니다. - SOPS 암호화는 여전히 파일을 git에 두고, 키 관리 오버헤드를 추가하며, 병합 충돌 악몽을 만들 수 있습니다.
.env.example템플릿은 오래되고 수동 복사가 필요합니다.
우리는 더 나은 것이 필요했습니다: 레포지토리 밖에 존재하는 비밀과 마찰 없는 개발자 경험.
The Solution
비밀은 AWS Secrets Manager에 보관됩니다. 개발자는 한 번의 명령으로 .env 파일을 생성합니다:
make env
# .env가 로컬에 생성되고 바로 사용할 수 있습니다
이 파일은 .gitignore에 포함됩니다. 버전 관리와는 전혀 접촉하지 않으며, AWS에서 비밀이 변경되면 개발자는 다시 생성해 최신 값을 얻습니다.
Implementation
1. Organize Secrets in AWS
애플리케이션과 환경별로 비밀을 구조화합니다:
/myapp/dev/database → {"DB_HOST": "...", "DB_PASSWORD": "..."}
/myapp/dev/api-keys → {"STRIPE_KEY": "...", "SENDGRID_KEY": "..."}
/myapp/prod/database → {"DB_HOST": "...", "DB_PASSWORD": "..."}
/myapp/prod/api-keys → {"STRIPE_KEY": "...", "SENDGRID_KEY": "..."}
AWS CLI를 사용해 비밀을 생성합니다:
aws secretsmanager create-secret \
--name /myapp/dev/database \
--secret-string '{"DB_HOST":"localhost","DB_PASSWORD":"devpass123"}'
2. The Python Script
generate_env.py는 Secrets Manager에서 .env 파일을 생성합니다:
#!/usr/bin/env python3
"""
Generate .env file from AWS Secrets Manager.
Usage:
python generate_env.py dev
python generate_env.py prod --force
"""
import argparse
import json
import os
import sys
from pathlib import Path
import boto3
from botocore.exceptions import ClientError, NoCredentialsError
# Configuration
APP_NAME = "myapp"
AWS_REGION = os.environ.get("AWS_REGION", "us-east-1")
ENV_FILE = ".env"
SECRET_KEYS = ["database", "api-keys", "third-party"]
def get_secret(secret_name: str, region: str) -> dict:
"""Fetch a secret from AWS Secrets Manager."""
client = boto3.client("secretsmanager", region_name=region)
try:
response = client.get_secret_value(SecretId=secret_name)
return json.loads(response.get("SecretString", "{}"))
except ClientError as e:
if e.response["Error"]["Code"] == "ResourceNotFoundException":
print(f" Warning: Secret '{secret_name}' not found")
return {}
raise
def validate_aws_credentials() -> bool:
"""Check if AWS credentials are configured."""
try:
sts = boto3.client("sts")
identity = sts.get_caller_identity()
print(f"Authenticated as: {identity['Arn']}")
return True
except NoCredentialsError:
print("Error: AWS credentials not found.")
print("\nFix with one of:")
print(" 1. aws configure")
print(" 2. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY")
print(" 3. Use IAM role (if on AWS)")
return False
def fetch_all_secrets(environment: str, region: str) -> dict:
"""Fetch all secrets for the environment."""
all_secrets = {}
for key in SECRET_KEYS:
secret_path = f"/{APP_NAME}/{environment}/{key}"
print(f" Fetching: {secret_path}")
all_secrets.update(get_secret(secret_path, region))
return all_secrets
def generate_env_content(secrets: dict) -> str:
"""Generate .env content from secrets."""
lines = [
"# Auto-generated from AWS Secrets Manager",
"# DO NOT COMMIT THIS FILE",
"",
]
for key, value in sorted(secrets.items()):
if isinstance(value, str) and " " in value:
value = f'"{value}"'
lines.append(f"{key}={value}")
return "\n".join(lines) + "\n"
def main():
parser = argparse.ArgumentParser()
parser.add_argument("environment", choices=["dev", "staging", "prod"])
parser.add_argument("-f", "--force", action="store_true")
parser.add_argument("-o", "--output", default=ENV_FILE)
args = parser.parse_args()
print(f"Generating .env for '{args.environment}'\n")
if not validate_aws_credentials():
sys.exit(1)
secrets = fetch_all_secrets(args.environment, AWS_REGION)
if not secrets:
print(f"\nError: No secrets found at /{APP_NAME}/{args.environment}/*")
sys.exit(1)
print(f"\nFound {len(secrets)} secret values")
content = generate_env_content(secrets)
path = Path(args.output)
if path.exists() and not args.force:
if input(f"{args.output} exists. Overwrite? [y/N]: ").lower() != "y":
sys.exit(0)
path.write_text(content)
print(f"Generated: {args.output}")
if __name__ == "__main__":
main()
3. Shell Wrapper and Makefile
Shell wrapper (generate-env.sh):
#!/bin/bash
# generate-env.sh
set -e
ENV=${1:-dev}
if ! python3 -c "import boto3" 2>/dev/null; then
pip3 install boto3 --quiet
fi
python3 "$(dirname "$0")/generate_env.py" "$ENV" "${@:2}"
Makefile targets:
.PHONY: env env-dev env-prod env-dry
env:
@./scripts/generate-env.sh dev
env-dev:
@./scripts/generate-env.sh dev
env-prod:
@./scripts/generate-env.sh prod
env-dry:
@./scripts/generate-env.sh dev --dry-run
4. GitHub Actions with OIDC
자격 증명을 저장할 필요가 없습니다. OIDC를 사용해 AWS 역할을 가정합니다:
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions
aws-region: us-east-1
- name: Generate .env
run: |
pip install boto3
python scripts/generate_env.py prod --force
- name: Deploy
run: |
# Your deployment commands
echo "Deploying..."
- name: Cleanup
if: always()
run: rm -f .env
5. GitLab CI
GitLab OIDC와 동일한 패턴:
deploy:
stage: deploy
image: python:3.11-slim
script:
- pip install boto3
- |
export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
$(aws sts assume-role-with-web-identity
--role-arn ${AWS_ROLE_ARN}
--role-session-name "gitlab-${CI_PIPELINE_ID}"
--web-identity-token ${CI_JOB_JWT_V2}
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
--output text))
- python scripts/generate_env.py prod --force
- echo "Deploying..."
after_script:
- rm -f .env
6. IAM Permissions
개발자는 관련 비밀을 읽을 수 있는 정책이 필요합니다. 예시:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:/myapp/*"
]
}
]
}