목차
- 프로젝트 배경 — EC2 모놀리스를 ECS Fargate로
- ARG와 ENV — 빌드 시점과 런타임의 결정적 차이
- 1주차 사고 — ENV에 다 박았다가 docker history에 털린 날
- 2주차 우회 — ARG로 옮겼더니 런타임 변수가 사라졌다
- 3주차 정착 — BuildKit secret과 변수 3분류로 분리
- docker-compose에서의 ARG/ENV/.env — 흔한 혼동 정리
- 언제 ARG, 언제 ENV, 언제 Secret을 쓸 것인가 — 판단 기준
dockerfile ARG ENV 차이를 제대로 정리하지 않고 시작한 ECS 마이그레이션은 빌드 시간 8분 14초, 이미지 레이어 14개, docker history에 평문으로 노출된 환경변수 30개라는 결과로 끝났다. 같은 코드를 3개월 뒤 다시 빌드했을 땐 3분 28초, 레이어 9개, 노출된 변수 0개가 됐다. 베이스 이미지도 그대로, 의존성도 그대로였다. 바뀐 건 변수 분류 한 가지였다.
한편, 이 글은 그 3개월을 시간순으로 풀어본다. 1주차 사고, 2주차 우회 실패, 3주차에 자리 잡힌 구조, 그리고 다음 프로젝트의 시작 규칙까지.
프로젝트 배경 — EC2 모놀리스를 ECS Fargate로
예를 들어, 회사 내부 분석 플랫폼을 ECS Fargate로 옮기는 일정이 3개월로 잡혔다. 기존은 EC2 t3.xlarge 두 대 위에 Django 4.2 + Celery 워커 5개 + Redis + Postgres를 띄운 모놀리스. 빌드 파이프라인은 GitLab CI, 레지스트리는 ECR.
이처럼, 처음 잡힌 빌드 시간은 평균 8분 14초였다. 이미지 사이즈 1.2GB, 레이어 14개. ECS가 ECR에서 풀 받는 시간까지 합치면 배포 한 번에 11~12분이 걸렸다.
게다가, 진짜 문제는 빌드 시간이 아니었다. EC2 시절 /etc/environment에 박아두고 잊었던 값이 30개 가까이 됐다는 점이다. DB URL, S3 키, 외부 API 토큰, Slack 웹훅, 결제 게이트웨이 시크릿, 사내 게이트웨이 인증 토큰. 컨테이너 이미지로 옮길 때 이 30개를 어떻게 분리할지가 첫 의사결정 지점이었고, 그걸 미루다 사고가 났다.
ARG와 ENV — 빌드 시점과 런타임의 결정적 차이
따라서, 사고 이후 문서를 다시 정독했다. 핵심은 두 명령어가 살아있는 "시점"이 다르다는 점이다.
ARG는 docker build가 실행되는 동안에만 유효하다. Dockerfile 안에 ARG KEY=default로 선언하거나, CLI에서 --build-arg KEY=value로 주입한다. 빌드가 끝나는 순간 컨테이너 안에서는 사라진다. docker run으로 띄운 뒤 printenv KEY를 해도 빈 값이 돌아온다.
ENV는 빌드 단계와 런타임 양쪽에 살아있다. Dockerfile의 ENV KEY=value는 이미지 레이어에 굳어진다. 컨테이너가 실행될 때 프로세스 환경변수로 그대로 노출되고, 가장 중요하게 docker history에 평문으로 박힌다. 한 번 푸시되면 같은 태그로 덮어써도 이전 레이어는 ECR에 남는다.
| 항목 | ARG | ENV (Dockerfile) | ENV (런타임 주입) |
|---|---|---|---|
| 유효 시점 | 빌드 중에만 | 빌드 + 런타임 | 런타임만 |
| 이미지 레이어 기록 | 메타에만 (값은 안 박힘) | 평문으로 박힘 | 안 박힘 |
| 컨테이너 안에서 접근 | 불가 | printenv로 조회 |
printenv로 조회 |
| 주입 방법 | --build-arg |
ENV 지시어 |
-e, env_file, ECS environment |
| compose 위치 | build.args |
Dockerfile 안 |
environment, env_file |
| 시크릿 적합도 | 부적합 (CI 로그 노출 위험) | 매우 부적합 | 적합 (단, 평문 환경변수 한계) |
여기까지는 거의 다 안다고 생각했다. 문제는 두 변수가 Dockerfile 안에서 같이 쓰일 때 묘하게 엮이는 패턴이었다.
1주차 사고 — ENV에 다 박았다가 docker history에 털린 날
일단 돌아가는 게 1주차 목표였다. CI에서 Secrets Manager 호출이 익숙하지 않아, GitLab CI 변수에서 빌드 시점에 끌어와 Dockerfile 상단에 ENV로 박는 구조를 선택했다. 30줄짜리 ENV 블록이 만들어졌다.
# 1주차 — 이렇게 하면 안 되는 예시
FROM python:3.12-slim
ENV DATABASE_URL="postgres://app:pw@db.internal:5432/app" \
AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE" \
AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" \
STRIPE_SECRET_KEY="sk_live_51Abc..." \
SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T0/B0/XX" \
INTERNAL_GATEWAY_TOKEN="igw_8f...e2"
# ... 24줄 더
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["gunicorn", "app.wsgi:application", "--bind", "0.0.0.0:8000"]
특히, 배포는 통과했다. 컨테이너도 잘 떴다. 문제는 보안팀 정기 스캔에서 터졌다. ECR에 올라간 이미지를 pull 받은 뒤 docker history --no-trunc를 한 줄 돌리면 30개 시크릿이 평문으로 다 보였다.
$ docker history --no-trunc 1234567890.dkr.ecr.ap-northeast-2.amazonaws.com/app:v1.0.3
IMAGE CREATED CREATED BY SIZE
abc123... 2 hours ago ENV STRIPE_SECRET_KEY=sk_live_51AbcDeFgHiJ... 0B
def456... 2 hours ago ENV AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7M... 0B
ghi789... 2 hours ago ENV DATABASE_URL=postgres://app:pw@db.intern... 0B
ENV 지시어는 레이어 메타데이터에 명령 그대로 박힌다. ECR이 비공개라도 pull 권한을 가진 사람이라면 누구나 평문으로 본다. 사이드카로 띄운 로그 수집기 이미지에 잘못 pull 권한을 준 상태였다는 게 뒤늦게 확인됐다. ECR 이미지 12개를 새 태그로 전부 다시 빌드해 푸시했고, 기존 이미지는 lifecycle policy로 7일 후 자동 삭제로 변경. 노출된 키 30개는 전부 회전했다. 보안팀 사고 보고서 한 장 분량.
이게 ARG/ENV 차이를 다시 정독하게 만든 첫 번째 교훈이었다 — 이미지에 굳어져선 안 되는 값을 ENV로 박지 마라.
2주차 우회 — ARG로 옮겼더니 런타임 변수가 사라졌다
그래서, 사고 다음 대응 회의 끝나고 바로 패치를 올렸다. ENV로 박혀있던 30개를 전부 ARG로 바꾸는 단순한 변경.
# 2주차 — ARG로 바꿨다가 또 터진 케이스
FROM python:3.12-slim
ARG DATABASE_URL
ARG AWS_ACCESS_KEY_ID
ARG AWS_SECRET_ACCESS_KEY
ARG STRIPE_SECRET_KEY
# ...
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["gunicorn", "app.wsgi:application", "--bind", "0.0.0.0:8000"]
GitLab CI에서 --build-arg DATABASE_URL=$DATABASE_URL 식으로 모두 주입했다. 빌드 통과, docker history에 평문 노출 없음. 만족하면서 ECS에 배포를 돌렸다.
물론, 10분 뒤 알람. Django 컨테이너가 시작 직후 죽었다.
django.db.utils.OperationalError: could not translate host name "None"
to address: Temporary failure in name resolution
원인은 단순하다. ARG는 빌드가 끝나면 사라진다. 컨테이너 안의 Django는 os.environ["DATABASE_URL"]을 읽으려 했지만, 런타임에는 그 변수가 존재하지 않았다. dj_database_url.parse(None)이 호스트 이름을 "None" 문자열로 만들면서 DNS 조회가 터진 거다. 빌드 시점 변수를 런타임에 기대한 게 사고의 본질이었다.
그런데, 급한 대로 흔히 쓰는 우회 패턴 하나를 박았다. ARG로 받은 값을 ENV로 재선언하는 방식.
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
예를 들어, 이러면 --build-arg로 주입한 값이 ENV로 다시 박히면서 런타임까지 살아남는다. 동작은 한다. 하지만 결과적으로 1주차랑 똑같다 — docker history에 다시 평문으로 박힌다.
여기서 두 번째 교훈을 얻었다. 빌드 시점 값과 런타임 값은 처음부터 분리해서 설계해야 한다. 두 변수를 한쪽 방식으로 통일하려는 시도 자체가 잘못된 출발점이었다.
3주차 정착 — BuildKit secret과 변수 3분류로 분리
3주차 시작할 때 30개 환경변수를 노트에 옮겨 한 줄씩 두 축으로 분류했다. (1) 빌드/런타임 중 어느 시점이 필요한가 (2) 시크릿인가 비-시크릿인가. 표 한 장이 만들어지자 어디로 보낼지 자명해졌다.
변수 분류 세 가지 카테고리
카테고리 A — 빌드 중에만 필요한 비-시크릿 값. 베이스 이미지 태그, 빌드 시점 옵션, 커밋 SHA, 빌드 일시. → ARG.
카테고리 B — 런타임에 필요한 비-시크릿 값. 환경 식별자(APP_ENV=production), 로그 레벨, 타임존, 기본 워커 수. → Dockerfile에 ENV로 박음. 시크릿이 아니니 이미지에 박혀도 무방하고, 박혀있어야 런타임 디버깅이 쉽다.
카테고리 C — 시크릿 값. DB URL, AWS 키, Stripe 키, Slack 웹훅, 사내 토큰. → 이미지에 절대 박지 않음. 런타임에는 ECS Task Definition의 secrets:로 Secrets Manager에서 주입. 빌드 중에 꼭 필요한 시크릿(예: private PyPI 토큰)은 BuildKit --mount=type=secret로 한 번만 마운트.
BuildKit secret 적용 Dockerfile
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS builder
ARG APP_VERSION=dev
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY requirements.txt .
# private PyPI 토큰을 레이어에 박지 않고 한 번만 마운트해 사용
RUN --mount=type=secret,id=pypi_token \
PYPI_TOKEN=$(cat /run/secrets/pypi_token) && \
pip install --user \
--extra-index-url https://__token__:${PYPI_TOKEN}@pypi.internal/simple \
-r requirements.txt
# ---- 최종 런타임 이미지 ----
FROM python:3.12-slim AS runtime
ARG APP_VERSION=dev
ENV APP_ENV=production \
LOG_LEVEL=info \
TZ=Asia/Seoul \
PYTHONUNBUFFERED=1 \
APP_VERSION=$APP_VERSION
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["gunicorn", "app.wsgi:application", "--bind", "0.0.0.0:8000"]
그러나, GitLab CI 쪽 호출은 이렇게.
build:
image: docker:24.0
services:
- docker:24.0-dind
variables:
DOCKER_BUILDKIT: "1"
script:
- echo "$PYPI_TOKEN" > /tmp/pypi_token
- docker build \
--build-arg APP_VERSION=$CI_COMMIT_SHORT_SHA \
--secret id=pypi_token,src=/tmp/pypi_token \
-t $ECR_REPO:$CI_COMMIT_SHORT_SHA .
- rm -f /tmp/pypi_token
- docker push $ECR_REPO:$CI_COMMIT_SHORT_SHA
다시 docker history --no-trunc를 돌렸다. 평문 시크릿은 사라졌다. APP_VERSION 같은 비-시크릿 ARG만 보이고, RUN --mount=type=secret 명령은 기록되지만 토큰 값 자체는 레이어에 남지 않는다. 이미지 사이즈는 1.2GB → 740MB로 줄었다(멀티 스테이지 분리 효과). 레이어는 14 → 9개.
런타임 시크릿은 ECS Task Definition으로
한편, 런타임 시크릿은 이미지에서 완전히 떼어내 ECS 쪽으로 옮겼다.
{
"containerDefinitions": [{
"name": "app",
"image": "1234567890.dkr.ecr.ap-northeast-2.amazonaws.com/app:abc1234",
"environment": [
{"name": "APP_ENV", "value": "production"},
{"name": "LOG_LEVEL", "value": "info"}
],
"secrets": [
{"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:ap-northeast-2:1234567890:secret:prod/db-AbCdEf"},
{"name": "STRIPE_SECRET_KEY",
"valueFrom": "arn:aws:secretsmanager:ap-northeast-2:1234567890:secret:prod/stripe-GhIjKl"}
]
}]
}
secrets 필드로 주입된 값은 컨테이너 환경변수로 노출되지만, Task Definition JSON과 CloudTrail 로그에는 ARN만 남는다. 회전도 Secrets Manager에서 한 번 돌리면 다음 배포부터 새 값이 들어온다. 이미지 재빌드가 필요 없다.
docker-compose에서의 ARG/ENV/.env — 흔한 혼동 정리
로컬 개발은 docker-compose로 돌렸다. compose에서는 ARG와 ENV가 들어가는 위치가 다른데, 동료 PR 리뷰에서 이걸 헷갈리는 경우를 자주 봤다.
services:
app:
build:
context: .
args: # ← 빌드 시점 — Dockerfile의 ARG로 전달
APP_VERSION: dev-local
PYPI_INDEX_URL: https://pypi.org/simple
environment: # ← 런타임 — 컨테이너 안의 ENV로 노출
APP_ENV: development
LOG_LEVEL: debug
DATABASE_URL: postgres://dev:dev@db:5432/app_dev
env_file: # ← 런타임 — 파일에서 ENV를 일괄 로드
- .env.local
build.args는 Dockerfile의 ARG로 들어가고, environment와 env_file은 컨테이너 환경변수가 된다. 같은 키를 양쪽에 동시에 쓰면 빌드 시점과 런타임에 각각 다른 값이 살아있는 상태가 되고, 디버깅할 때 가장 혼란스럽다.
또 헷갈리는 게 프로젝트 루트의 .env 파일이다. compose가 자동으로 읽는 그 .env는 compose 파일 안의 ${VAR} 치환에만 쓰인다. 컨테이너 환경변수로 자동 주입되지는 않는다. 이걸 모르면 "분명 .env에 박았는데 컨테이너에서 못 읽는다"는 미스터리가 반복된다.
| compose 위치 | 들어가는 곳 | 시점 |
|---|---|---|
build.args |
Dockerfile의 ARG | 빌드 중 |
environment |
컨테이너 환경변수 | 런타임 |
env_file |
컨테이너 환경변수 (파일 일괄) | 런타임 |
루트 .env |
compose 파일의 ${VAR} 치환 |
compose 파싱 단계 |
여기까지 정리하고 나니 팀 위키에 한 페이지로 박았다. 이후 PR에서 이 위키 링크가 자동 코멘트로 달리도록 GitLab CI에 후크 하나 추가.
언제 ARG, 언제 ENV, 언제 Secret을 쓸 것인가 — 판단 기준
실제로, 3개월 끝에 정착된 판단 기준이다. 새 프로젝트 시작할 때 이대로만 결정하면 1주차 사고는 다시 안 난다.
결국, ARG로 처리할 상황
- 빌드 메타데이터 — 커밋 SHA, 빌드 번호, 빌드 일시
- 멀티 스테이지에서 베이스 이미지 태그 변수화 —
ARG BASE_TAG=3.12-slim,FROM python:${BASE_TAG} - 테스트 빌드 vs 프로덕션 빌드 분기 —
ARG BUILD_TARGET=prod - 비-시크릿 외부 인덱스 URL — public PyPI 미러, npm 레지스트리
Dockerfile의 ENV로 박을 상황
- 컨테이너 모든 인스턴스가 공유하는 비-시크릿 기본값 —
PYTHONUNBUFFERED=1,NODE_ENV=production,TZ=Asia/Seoul - 빌드 시점부터 정해진 정적 설정 — 앱 버전, 로그 포맷, PATH 추가 경로
- 시크릿이 아니라면 명시적으로 박는 게 운영 중
docker inspect로 확인하기 편하다
런타임 주입(-e, env_file, ECS secrets, k8s Secret)으로 처리할 상황
- 환경별로 다른 값 — dev/staging/prod의 DB URL, API 엔드포인트
- 모든 시크릿 — API 키, DB 비밀번호, 토큰, 웹훅 URL
- 운영 중 회전(rotate)해야 하는 값
BuildKit --mount=type=secret을 써야 할 상황
- 빌드 중에 시크릿이 꼭 필요한 경우 — private 패키지 레지스트리 인증, private git clone
- 이미지에 절대 박혀선 안 되지만 빌드 단계에서 한 번 써야 하는 값
한 가지 더. ENV로 박은 값을 런타임에 -e 또는 ECS environment로 덮어쓸 수 있다는 점도 활용할 만하다. Dockerfile에 ENV LOG_LEVEL=info로 박아두고, 운영에서 임시로 디버그 로그를 보고 싶을 때 -e LOG_LEVEL=debug로 덮어쓰는 식이다. 기본값은 코드에 박고, 환경별 덮어쓰기는 런타임에 — 이게 가장 헷갈리지 않는 패턴이었다.
오늘 당장 점검할 세 가지
- 운영 중인 이미지에
docker history --no-trunc <image>를 돌려라. 평문 시크릿이 한 줄이라도 보이면 즉시 회전 + 재빌드 + ECR lifecycle policy로 옛 태그 삭제. Dockerfile안에서ARG XXX+ENV XXX=$XXX패턴이 있다면, 그 값은 시크릿일 가능성이 높다. BuildKit secret 또는 런타임 주입으로 옮겨라.docker-compose.yml의build.args와environment에 같은 키가 동시에 있는지 확인. 있다면 그게 의도된 건지 PR에서 따져보라.
참고로 Docker 공식 Dockerfile reference의 ARG 항목과 BuildKit secrets 가이드는 작성 시점(2026-06-23 기준) Docker Engine 26.1과 BuildKit v0.13 기준으로 갱신되어 있다. 버전이 올라가면 --secret 문법 일부와 docker compose(공백 형식)의 secrets 지원 범위가 바뀔 수 있으니, 빌드 도구를 메이저 업그레이드할 때마다 한 번씩 확인하는 게 안전하다.
관련 글
- dockerfile 최적화 레이어 캐시 — 빌드 8분에서 90초로 줄인 실전 가이드 – docker build에서 한 줄짜리 문서 수정만 해도 매번 npm install이 다시 도는 이유는 레이어 캐시가 깨졌기 때문이다. 명령…
- Docker Multi-Stage Build 완벽 가이드 — 이미지 크기 90% 줄이는 실전 방법 – Docker multi-stage build는 빌드 환경과 런타임 환경을 분리해 최종 이미지에서 불필요한 도구를 제거하는 표준 패턴이다. …
- Docker 빌드 캐시 CI 절반 줄이는 Buildx 설정 — TIL – GitHub Actions에서 Docker 빌드 캐시 CI를 손봤더니 12분 걸리던 워크플로가 5분 30초로 떨어졌다. type=gha m…