Dockerfile ARG ENV 차이 완전 정복 — 빌드 시점·런타임 변수와 보안 사고 회고

목차

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 — 빌드 시점과 런타임의 결정적 차이

따라서, 사고 이후 문서를 다시 정독했다. 핵심은 두 명령어가 살아있는 "시점"이 다르다는 점이다.

ARGdocker build가 실행되는 동안에만 유효하다. Dockerfile 안에 ARG KEY=default로 선언하거나, CLI에서 --build-arg KEY=value로 주입한다. 빌드가 끝나는 순간 컨테이너 안에서는 사라진다. docker run으로 띄운 뒤 printenv KEY를 해도 빈 값이 돌아온다.

ENV는 빌드 단계와 런타임 양쪽에 살아있다. DockerfileENV 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), 로그 레벨, 타임존, 기본 워커 수. → DockerfileENV로 박음. 시크릿이 아니니 이미지에 박혀도 무방하고, 박혀있어야 런타임 디버깅이 쉽다.

카테고리 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개.

BuildKit secret은 Docker 18.09+에서 지원하지만 자동 활성화는 아니다. `DOCKER_BUILDKIT=1` 환경변수를 주거나 `/etc/docker/daemon.json`에 `”features”: {“buildkit”: true}`를 박아야 한다. `Dockerfile` 첫 줄의 `# syntax=docker/dockerfile:1.7` 주석은 BuildKit이 최신 문법을 쓰도록 명시하는 매직 라인이다 — 이게 빠지면 `–mount` 옵션이 그냥 무시될 수 있다.

런타임 시크릿은 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.argsDockerfileARG로 들어가고, environmentenv_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 레지스트리

DockerfileENV로 박을 상황

  • 컨테이너 모든 인스턴스가 공유하는 비-시크릿 기본값 — 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로 덮어쓸 수 있다는 점도 활용할 만하다. DockerfileENV LOG_LEVEL=info로 박아두고, 운영에서 임시로 디버그 로그를 보고 싶을 때 -e LOG_LEVEL=debug로 덮어쓰는 식이다. 기본값은 코드에 박고, 환경별 덮어쓰기는 런타임에 — 이게 가장 헷갈리지 않는 패턴이었다.

오늘 당장 점검할 세 가지

  1. 운영 중인 이미지에 docker history --no-trunc <image>를 돌려라. 평문 시크릿이 한 줄이라도 보이면 즉시 회전 + 재빌드 + ECR lifecycle policy로 옛 태그 삭제.
  2. Dockerfile 안에서 ARG XXX + ENV XXX=$XXX 패턴이 있다면, 그 값은 시크릿일 가능성이 높다. BuildKit secret 또는 런타임 주입으로 옮겨라.
  3. docker-compose.ymlbuild.argsenvironment에 같은 키가 동시에 있는지 확인. 있다면 그게 의도된 건지 PR에서 따져보라.

참고로 Docker 공식 Dockerfile reference의 ARG 항목BuildKit secrets 가이드는 작성 시점(2026-06-23 기준) Docker Engine 26.1과 BuildKit v0.13 기준으로 갱신되어 있다. 버전이 올라가면 --secret 문법 일부와 docker compose(공백 형식)의 secrets 지원 범위가 바뀔 수 있으니, 빌드 도구를 메이저 업그레이드할 때마다 한 번씩 확인하는 게 안전하다.

관련 글