도커 중급 강좌 #5 환경변수와 secrets 관리
지금까지 환경변수와 비밀 값을 적당히 -e / environment:로 흘려 넣어 왔습니다. 이번 글은 그 주제만 따로 떼어 — 어떻게 주입하고, 어떻게 노출되지 않게 두는가를 본격 정리합니다.
도커 중급 강좌 시리즈에서 이번 글의 위치:
- #1 멀티스테이지 빌드와 이미지 슬리밍
- #2 빌드 캐시 — 레이어 순서 최적화
- #3 docker compose 기초 — web + db
- #4 compose 심화 — depends_on, healthcheck, profiles
- #5 환경변수와 secrets 관리 ← 이번 글
- #6 로깅과 디버깅
환경변수가 들어오는 경로 #
도커에서 환경변수가 컨테이너에 도달하는 경로는 의외로 여러 갈래입니다. 한곳에 정리하면:
┌─────────────────────────┐
[Dockerfile] │ │
ENV KEY=value ────────▶ │ │
│ │
[docker run] │ 컨테이너 │
-e KEY=val ──────▶ │ 안의 process │
--env-file file ──────▶ │ KEY 환경변수 │
│ │
[compose.yaml] │ │
environment: ──────▶ │ │
env_file: ──────▶ │ │
│ │
[host shell] │ │
$KEY (보간) ──────▶ │ │
└─────────────────────────┘같은 변수가 여러 곳에서 정의되면 나중에 들어온 것이 이기며, compose의 environment: > env_file: > Dockerfile의 ENV 순서로 우선됩니다.
이걸 머리에 두면 “환경변수가 적용 안 됐습니다” 의 90%는 추적이 끝납니다.
.env 파일 — 가장 흔한 입구
#
같은 디렉터리에 .env 파일이 있으면 Compose가 자동으로 읽어 들여, compose.yaml 안의 ${VAR} 보간에 씁니다.
POSTGRES_PASSWORD=secret
APP_VERSION=1.0.0
DB_HOST=pgservices:
pg:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
web:
image: myapp:${APP_VERSION}
environment:
DB_HOST: ${DB_HOST}
DB_PASSWORD: ${POSTGRES_PASSWORD}여기서 짚어둘 두 사실:
- 이
.env는 compose 파일 자체의 보간에 쓰이며, 컨테이너 안으로 자동 주입되는 게 아닙니다. - 컨테이너에 그 값이 가려면
environment:/env_file:으로 명시해야 합니다.
변수 보간 문법 #
${VAR} # 단순 참조. 없으면 빈 문자열
${VAR:-default} # VAR가 없거나 빈 문자열이면 default
${VAR-default} # VAR가 정의 안 됐을 때만 default (빈 문자열은 그대로)
${VAR:?error message} # VAR가 없거나 빈 문자열이면 에러
${VAR?error message} # VAR가 정의 안 됐으면 에러운영에서 자주 쓰는 패턴:
services:
pg:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}.env가 빠진 채 compose up을 하면 즉시 에러로 알려주니, 빈 비밀번호로 떠버리는 사고를 막습니다.
environment: vs env_file:
#
서비스에 변수를 주입하는 방식은 크게 두 가지입니다.
services:
web:
environment:
DEBUG: "1"
DB_HOST: pg
DB_PASSWORD: ${POSTGRES_PASSWORD}services:
web:
env_file:
- .env.web
- .env.localDEBUG=1
DB_HOST=pg
LOG_LEVEL=info| environment | env_file | |
|---|---|---|
| 정의 위치 | compose.yaml 안 | 별도 파일 |
| 변수 개수가 많을 때 | 길어짐 | 깔끔 |
| 보간 | 가능 (${VAR}) | 보간 없음 — 리터럴 |
| 우선순위 | env_file보다 높음 | environment보다 낮음 |
여러 개의 env_file을 적으면 뒤쪽이 앞쪽을 덮어쓰고, 그 다음에 environment:가 모두를 덮어씁니다. 이 우선순위만 머리에 두면 헷갈리지 않습니다.
흔한 혼동:
env_file안에서KEY=${OTHER_KEY}같은 보간은 동작하지 않습니다.env_file은 단순 dotenv 파일이라 모든 값이 리터럴로 읽힙니다. 보간이 필요하면environment:에서.
변수 우선순위 한 표 #
같은 변수가 여러 경로에서 들어올 때 결과는 가장 위쪽이 이깁니다.
| 순위 | 출처 |
|---|---|
| 1 (최강) | docker compose run -e KEY=val (CLI 명시) |
| 2 | compose.yaml의 environment: |
| 3 | compose.yaml의 env_file: |
| 4 | 호스트 셸의 환경변수 (compose가 시작될 때) |
| 5 | .env 파일 (compose.yaml 보간용 — 컨테이너에 직접 주입 아님) |
| 6 | Dockerfile의 ENV |
docker compose config로 합쳐진 결과를 보면 헷갈림이 빠르게 풀립니다.
비밀 값과 환경변수 — 어디까지 OK 인가 #
DB 비밀번호, API 키, JWT 시크릿 — 이런 값을 환경변수로 주는 건 흔하지만, 보안 등급에 따라 다른 도구가 필요해집니다.
| 등급 | 적절한 도구 |
|---|---|
| 개발 / 로컬 | .env 파일 (커밋 금지, .gitignore) |
| CI / 작은 운영 | CI의 secret store, compose의 environment: |
| 운영 (적당) | compose secrets: 또는 K8s Secret |
| 운영 (높음) | AWS Secrets Manager, Vault, GCP Secret Manager 같은 외부 매니저 |
이번 글은 처음 두 단계에 집중하고, 후자는 마지막에 한 단락으로 짚습니다.
.env의 보안 — .gitignore가 첫 줄
#
대부분의 비밀 누출은 단순한 실수에서 옵니다.
.env
.env.*
!.env.example.env.example은 키 이름만 적힌 템플릿. 새 사람이 클론할 때 어떤 값을 채워야 하는지의 안내서입니다.
POSTGRES_PASSWORD=
DJANGO_SECRET_KEY=
SENTRY_DSN=POSTGRES_PASSWORD=actual-secret
DJANGO_SECRET_KEY=actual-key
SENTRY_DSN=https://...@sentry.io/...이 패턴이 정착하면 동료에게 비밀을 따로 알려주는 일 없이 — “환경변수 빼고 다른 셋업은 OK 인가?” 같은 디버깅이 빨라집니다.
한 가지 더 — pre-commit으로 누출 방지
#
gitleaks, detect-secrets 같은 도구를 pre-commit 훅에 넣으면, 실수로 비밀이 들어간 커밋을 사전에 차단할 수 있습니다. 한 번 누출된 비밀은 git history에서 깨끗이 지우기가 매우 까다롭습니다 (git filter-branch 또는 BFG Repo-Cleaner). 사전 차단이 가장 쌉니다.
환경변수의 노출 — 어디서 새는가 #
비밀이 환경변수로 들어왔다고 해서 자동으로 안전한 건 아닙니다. 도커 환경에선 다음 지점에서 새기 쉽습니다.
docker inspect
#
docker inspect myapp-web-1 --format '{{json .Config.Env}}'
# ["PATH=...", "DB_PASSWORD=secret", ...]컨테이너에 접근 가능한 사용자라면 이 값을 그대로 봅니다. 호스트 보안의 한 부분입니다.
docker history
#
빌드 시점에 환경변수가 박힌 이미지는 docker history로 노출됩니다.
docker history myapp:1.0
# CREATED BY SIZE
# ENV DB_PASSWORD=secret 0B ← 영원히 박힘이미지에는 비밀을 절대 박지 마세요. 이미지를 받은 모든 사람이 평문으로 보고, 레지스트리에 푸시하면 더 넓게 퍼집니다.
프로세스 트리 #
docker exec myapp-web-1 env | grep PASSWORD
docker exec myapp-web-1 cat /proc/1/environ | tr '\0' '\n'컨테이너 내부에 셸로 들어갈 권한이 있으면 환경변수는 다 보입니다. 그래서 컨테이너 안에 셸 접근 권한 자체를 통제하는 게 한 층의 방어선입니다.
Compose secrets: — 한 단계 위 도구
#
환경변수보다 안전한 비밀 주입 방식으로, secrets:는 비밀 값을 파일로 컨테이너 안에 마운트해줍니다. 환경변수가 아닙니다.
services:
pg:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: app
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txtsuper-secret-passwordsecrets/이 패턴의 장점:
- 비밀이 환경변수로 노출되지 않음 —
inspect로 안 보임 /run/secrets/db_password라는 읽기 전용 파일로 마운트- Postgres 같은 공식 이미지들이 이미
*_FILE환경변수 컨벤션을 지원함
외부 secret — Swarm 모드용 #
secrets:
db_password:
external: trueexternal: true는 Compose가 secret을 만들지 않고 이미 존재하는 secret을 참조한다는 뜻인데, 이는 Swarm 모드에서나 진가를 발휘합니다. 일반 compose up에선 file 형태가 일상적입니다.
빌드 시점 비밀 — --mount=type=secret
#
#2에서 한 번 짚었던 BuildKit 시크릿으로, 빌드 도중에만 필요한 비밀(예: 사설 패키지 레지스트리 토큰)을 다룰 때 씁니다.
# syntax=docker/dockerfile:1.7
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=secret,id=pypi_token \
pip install --extra-index-url \
https://__token__:$(cat /run/secrets/pypi_token)@pypi.example.com/simple \
-r requirements.txtservices:
web:
build:
context: .
secrets:
- pypi_token
secrets:
pypi_token:
file: ./secrets/pypi_token.txt빌드 동안에만 /run/secrets/pypi_token으로 읽히고 — 이미지의 어떤 레이어에도 남지 않습니다. docker history로도 안 보입니다. 사설 패키지 인덱스 / GitHub Personal Access Token / 사내 미러 인증 같은 경우에 정석입니다.
외부 secret manager — 한 단락 #
운영 규모가 커지면 비밀을 파일이나 환경변수로 들고 있는 것 자체가 부담입니다. 클라우드 환경에선 외부 매니저로 옮기게 됩니다.
| 환경 | 관용 도구 |
|---|---|
| AWS | Secrets Manager, Parameter Store |
| GCP | Secret Manager |
| Azure | Key Vault |
| Self-hosted | HashiCorp Vault, Bitnami Sealed Secrets |
| K8s | External Secrets Operator + 위 매니저 |
흐름은 보통 컨테이너가 시작 시점에 매니저로부터 시크릿을 받아 환경변수나 파일로 자기 안에 주입하는 방식입니다. 이미지에는 비밀이 없고, 컨테이너 정의에도 비밀이 없고, 매니저의 권한만 잘 통제하면 — 누출 표면이 매우 작아집니다.
도커 / Compose 단독 환경에서는 여기까지 갈 일이 잘 없지만, 운영이 ECS / EKS / GKE로 가면 자연스럽게 만나는 영역입니다.
자주 만나는 실수 #
- 이미지에
ENV API_KEY=...넣기 —docker history에 평문으로 영원히. 빌드 시크릿 / 런타임 주입으로. .env를 git 커밋 — 한 번 push 되면 history에서 빼는 게 매우 까다롭다. pre-commit gitleaks가 가장 싼 방어.docker compose logs가 비밀을 출력 — 앱이 시작 시점에 환경변수 전체를 로그로 찍는 패턴(어떤 라이브러리는 기본이 그래요). 마스킹 또는 출력 끄기.- 호스트 셸의 변수가 의도치 않게 컨테이너에 들어감 —
environment: - MY_VAR(값 없음) 형식은 호스트 환경에서 끌어옵니다. 의도가 아니면 명시 값으로. - secret 파일의 권한 —
secrets/디렉터리는chmod 600으로 묶어두는 편이 안전.
정리 #
이번 글에서 잡은 그림:
- 컨테이너 안 환경변수의 출처는 6가지. compose CLI >
environment:>env_file:> 호스트 셸 >.env보간 > DockerfileENV순으로 이김 - **
.env**는 compose.yaml 보간용. 컨테이너에 직접 가는 게 아님..gitignore+.env.example셋업이 기본 - 이미지에 비밀을 박지 말 것 —
docker history노출.ENV로 적지 마라 - Compose **
secrets:**는 환경변수가 아닌 읽기 전용 파일로 비밀 주입 —*_FILE컨벤션과 잘 어울림 - 빌드 시점 비밀은 BuildKit
--mount=type=secret— 어떤 레이어에도 남지 않음 - 운영 규모가 커지면 외부 secret manager (AWS Secrets Manager, Vault 등)로
다음 글(#6 로깅과 디버깅)에서는 중급 시리즈를 마무리합니다. 여러 컨테이너의 로그를 한곳에서 보고, log driver를 바꾸고, docker compose logs의 자주 쓰는 옵션, 그리고 컨테이너 안 디버깅 도구(exec, inspect, stats, dive)까지 다룹니다.