어느 날 전 세계가 한 자바 로깅 라이브러리의 치명적 취약점(Log4Shell)으로 발칵 뒤집혔습니다. 보안팀이 묻습니다. "우리 이미지 중에 그거 들어간 게 어떤 거죠?" 아무도 답하지 못합니다 — 수백 개 이미지 안에 무엇이 들어 있는지 모르니까요. 옆 팀은 SBOM과 스캔으로 30분 만에 대상 12개를 찾아 패치했습니다. 컨테이너 보안은 '내 코드'가 아니라 '내가 가져다 쓴 것들'을 아는 데서 시작합니다.
- 1이미지 스캔이 의존성·OS 패키지의 알려진 취약점을 잡는 원리를 안다
- 2이미지 서명·검증으로 공급망 무결성을 지키는 법을 안다
- 3베이스 이미지 최소화가 공격 표면을 줄이는 이유를 안다
- 4SBOM으로 새 취약점 대응 범위를 빠르게 특정하는 법을 안다
- 5레지스트리·CI 파이프라인에 보안 게이트를 거는 위치를 안다
가져다 쓴 것들을 안다
이미지 스캔 — 내가 짠 게 아니라 가져온 것의 취약점
컨테이너 이미지에는 내 코드뿐 아니라 베이스 OS 패키지, 언어 런타임, 수많은 라이브러리가 들어갑니다. 보안 사고의 상당수는 내 코드가 아니라 가져다 쓴 의존성에 숨은 취약점에서 납니다(Log4Shell이 대표).
이미지 스캔(Trivy, ECR 스캔 등)은 이미지 레이어의 패키지·라이브러리를 취약점 DB(CVE)와 대조해, 알려진 취약점이 있는 버전을 찾습니다. CI 파이프라인(CI/CD 파이프라인)에 스캔을 넣어, 심각 취약점이 있으면 배포를 막는 게이트로 씁니다.
확대
위 그림은 소스코드→빌드→스캔→레지스트리→배포 공급망 전체를 보여줍니다. 각 단계에 보안 게이트를 넣어야 악성 이미지가 프로덕션에 도달하지 못합니다.
확대
위 그림처럼 풀 OS 기반 이미지는 불필요한 패키지가 쌓여 취약점이 늘어납니다. distroless/scratch 기반으로 최소화하면 공격면과 이미지 크기를 동시에 줄일 수 있습니다.
확대
위 그림처럼 CI에서 이미지를 서명하고, 배포 전 검증하면 레지스트리 침해로 악성 이미지가 주입돼도 클러스터가 실행을 거부합니다. 서명 없는 이미지는 배포 차단이 원칙입니다.
이미지 서명 — '바로 그 이미지'임을 보장
최소 이미지 + SBOM — 표면을 줄이고, 목록을 갖는다
베이스 이미지 최소화(distroless·alpine): 안 쓰는 패키지·셸·도구가 없을수록 취약점과 공격 도구가 줄어듭니다. 침해돼도 공격자가 셸·패키지매니저를 못 써 추가 침투가 어렵습니다(Docker의 멀티스테이지 빌드).
SBOM(부품 명세서): 이미지에 어떤 패키지·버전이 들어 있는지의 목록. 새 취약점이 터졌을 때 "우리 이미지 중 그게 든 게 무엇인지"를 즉시 검색해 대응 범위를 분 단위로 특정합니다. SBOM이 없으면 대응이 며칠 늦어집니다.
이미지가 배포되기 전 검증되는 법 — 빌드부터 admission 게이트까지 6단계
파드가 실행하는 이미지는 그냥 통과된 게 아니라 여러 관문을 지나온 것입니다. 성숙한 공급망에서 이미지는 빌드(+SBOM) → 레지스트리 push → 취약점 스캔 → 서명 → admission·정책 게이트 → 배포를 순서대로 밟고, 각 관문이 "이 이미지를 믿어도 되나"를 하나씩 검증합니다. 이 흐름을 알면 "취약 이미지가 어떻게 운영까지 갔지", "서명은 했는데 왜 안 막혔지"를 관문별로 좁혀 진단할 수 있습니다. 컨테이너 보안은 한 도구가 아니라 이 6단계 사슬입니다.
[CI] docker build
│
① 빌드 + SBOM 생성 (이미지 레이어 + 들어간 부품 목록)
│
② 레지스트리 push (ECR·Harbor 등에 태그·다이제스트로 저장)
│
③ 취약점 스캔(CVE) (레이어의 패키지를 CVE DB와 대조)
│
④ 서명(provenance) (신뢰된 파이프라인이 cosign으로 서명)
│
⑤ admission·정책 게이트 (배포 직전 서명·스캔 통과 여부 검증)
│
⑥ 배포 (게이트를 통과한 이미지만 파드로 실행)
▼
[클러스터] 검증된 다이제스트(@sha256:…)만 실행
각 단계에서 무슨 일이 일어나고, 막히거나 뚫리면 어떤 증상인가:
| 단계 | 하는 일 | 여기서 막히면 / 뚫리면 |
|---|---|---|
| ① 빌드·SBOM | 이미지를 만들고 어떤 패키지·버전이 들어갔는지 부품 명세서(SBOM)를 생성 | 취약한 베이스 이미지를 쓰면 → 시작부터 수십~수백 CVE를 물려받음 |
| ② push | 레지스트리에 저장. 가변 태그(latest)가 아니라 다이제스트로 식별하면 불변 | 같은 태그에 다른 이미지가 덮어써지면 → 무엇이 도는지 불확실 |
| ③ 스캔 | 레이어의 OS·라이브러리를 CVE DB와 대조. 심각 취약점이면 빌드 실패로 차단(shift-left) | 스캔 미통과인데 차단 게이트가 없으면 → 취약 이미지가 그대로 운영 도달 |
| ④ 서명 | 신뢰된 CI가 이미지에 서명해 '우리가 만든, 변조 안 된 것'임을 증명 | 미서명·서명 불일치 이미지는 ⑤에서 거부돼야 정상(안 막으면 악성 이미지 유입) |
| ⑤ 게이트 | 배포 직전 admission 컨트롤러가 서명·스캔 결과·다이제스트 핀을 검사 | 정책 우회(게이트 미적용 네임스페이스·예외)면 → 검증 없이 배포됨 |
| ⑥ 배포 | 게이트를 통과한 이미지만 파드로 실행. 다이제스트로 '정확히 그 이미지' 보장 | 가변 태그면 노드마다 다른 시점의 이미지를 받을 수 있음 |
즉 이 6단계는 하나의 사슬이라 가장 약한 고리에서 뚫립니다 — ③스캔을 켜도 ⑤게이트가 없으면 취약 이미지가 그냥 지나가고, ④서명을 해도 ⑤에서 검증하지 않으면 서명은 장식일 뿐입니다. 그래서 "우리 공급망이 안전한가"는 각 관문이 아니라 관문 사이의 연결을 봅니다 — 스캔 결과가 게이트의 차단 조건에 실제로 연결돼 있는지, 서명 검증이 admission 정책에 걸려 있는지, ⑤를 건너뛰는 예외 경로가 없는지. 새 CVE가 터지면 ①의 SBOM으로 영향 이미지를 즉시 특정해 ③부터 다시 돌립니다.
배포 전(또는 CI에서) 이미지를 스캔해 심각 취약점을 확인합니다.
trivy image --severity HIGH,CRITICAL myapp:1.4.2
# 또는 ECR 푸시 시 자동 스캔 결과 조회
aws ecr describe-image-scan-findings --repository-name myapp --image-id imageTag=1.4.2 \
--query "imageScanFindings.findingSeverityCounts"
{ "CRITICAL": 1, "HIGH": 4 } # CRITICAL 1건 → 배포 차단 대상
trivy image운영에서 실제 실행 중인 이미지의 불변 다이제스트를 확인해, 의도한 그 이미지가 맞는지 봅니다.
kubectl get pods -o jsonpath='{.items[*].status.containerStatuses[*].imageID}' | tr ' ' '\n' | sort -u
myapp@sha256:9f2b... # 가변 태그가 아니라 다이제스트로 고정돼 있는지 확인
kubectl/docker inspect- 스캔 결과 CRITICAL/HIGH 취약점이 있는 이미지가 운영에 떠 있는지 — CI 게이트를 통과한 경위 점검, 베이스 이미지 업데이트·패치
- 배포가 latest 등 가변 태그로 돼 있는지 — 태그가 가리키는 이미지가 바뀔 수 있음. 불변 다이제스트(@sha256)로 핀
- 이미지 서명 검증이 배포 단계에 적용돼 있는지 — 없으면 변조·악성 이미지 유입 가능. 서명 정책 도입
- SBOM이 이미지마다 생성·보관되는지 — 없으면 새 CVE 터질 때 영향 범위 파악이 며칠 지연
상황: 새 취약점 대응이 '영향 범위 파악' 단계에서 막혀 패치가 지연됨.
원인: 이미지에 무엇이 들어 있는지의 목록(SBOM)이 없고, 정기 스캔도 없어 일일이 이미지를 까보거나 추측으로 대응. 가변 태그라 어떤 이미지가 실제 실행 중인지도 불분명.
진단: SBOM 보관 여부 확인 → 레지스트리의 이미지별 스캔 결과 조회 → 운영 중 이미지의 실제 다이제스트 목록 확보.
해결: CI에서 이미지마다 SBOM 생성·보관 + 정기 재스캔을 표준화하면, 새 CVE에 SBOM을 검색해 영향 이미지를 분 단위로 특정합니다. 다이제스트 핀으로 '무엇이 실행 중인지'를 명확히. 근본적으로는 베이스 이미지 최소화로 애초에 취약점 수를 줄입니다. 이 대응 속도는 관측과 거버넌스의 사고 대응 체계와 한 묶음입니다.
심화 — 스캔을 켠 다음이 진짜 시작이다
심화: 취약점 300건 앞에서 — 전부 고치기가 아니라 트리아지
스캔을 처음 켜면 대부분 이렇게 됩니다: 이미지 하나에 취약점 수백 건. 여기서 "다 고치기 전엔 배포 금지"로 가면 배포가 영원히 막히고, 팀은 곧 게이트를 꺼버립니다. 스캔의 다음 단계는 트리아지 기준을 세우는 것입니다.
- 고칠 수 있는 것부터: 상당수 CVE는 베이스 OS 패키지에 있고 아직 수정 버전이 없습니다(no fix). 고칠 수 없는 것으로 빌드를 막으면 게이트가 무의미해집니다.
trivy image --ignore-unfixed --severity CRITICAL,HIGH --exit-code 1처럼 수정판이 존재하는 심각 건만 차단하는 것이 현실적인 출발선입니다. - 존재와 악용 가능은 다릅니다: 취약한 라이브러리가 이미지에 있어도 그 코드 경로를 쓰지 않으면 실제 위험은 낮습니다. 전부 P0로 다루지 말고, 인터넷 노출 서비스·인증 관련 이미지부터 우선순위를 매깁니다.
- 예외에는 만료일을: 당장 못 고치는 건은 무기한 무시가 아니라 만료일 있는 예외 목록(.trivyignore 등)으로 관리합니다. 만료가 지나면 다시 게이트에 걸리게 해, 예외가 침묵 속에 영구화되는 것을 막습니다.
- 최고의 패치는 베이스 이미지 갱신: 개별 CVE를 하나씩 쫓는 것보다 베이스 이미지를 정기적으로 최신 버전으로 리빌드하는 편이 한 번에 수십 건을 없앱니다. 어제 통과한 이미지가 새 CVE 발표로 오늘 실패하는 것은 정상입니다 — 그래서 재스캔과 정기 리빌드는 짝입니다.
상황: 낯선 리전에서 고사양 인스턴스가 대량 생성돼 코인 채굴에 쓰이고 있었습니다. 서버가 뚫린 흔적은 없어 침입 경로가 짐작조차 되지 않습니다.
원인: 이미지 레이어에 시크릿이 박혀 있었습니다. COPY . .가 .env 파일(AWS 액세스 키 포함)을 이미지에 함께 담았고, 이미지가 공개 레지스트리에 올라가자 봇이 즉시 레이어를 훑어 키를 수확한 것입니다. 나중 레이어에서 rm으로 지워도 앞 레이어에는 그대로 남고, 빌드 인자(ARG)로 넘긴 시크릿도 이미지 메타데이터에 기록됩니다. 공개 레지스트리의 새 이미지는 올라가는 즉시 자동으로 스캔당한다고 봐야 합니다.
진단: docker history --no-trunc로 각 레이어의 명령·ENV 값을 확인하고, trivy의 시크릿 스캔으로 레이어 안의 키 문자열을 검출합니다. 클라우드 감사 로그에서 해당 키로 수행된 API 호출 범위를 확인합니다.
해결: 즉시 ① 유출 키 비활성화·교체(유출된 키는 이미 침해된 것으로 간주 — 이미지 삭제로는 부족합니다, 이미 복제됐습니다) ② 공개 이미지 내리기 ③ 공격자가 만든 리소스 정리. 재발 방지는 .dockerignore에 .env·키 파일 등록, 빌드 시크릿은 BuildKit의 secret mount로(레이어에 안 남음), 런타임 시크릿은 이미지가 아니라 시크릿·키 관리에서 주입, CI에 시크릿 스캔 게이트 추가. 이미지는 실행 파일이 아니라 누구나 열어 볼 수 있는 압축 파일이라고 생각해야 합니다.
소프트웨어 공급망 보안은 최근 가장 중요해진 영역입니다. "컨테이너 보안 어떻게 하나요?"에 "CI에서 스캔+SBOM, 푸시 시 서명, 배포 시 서명 검증+다이제스트 핀, 베이스 이미지 최소화, 새 CVE는 SBOM으로 즉시 영향 특정"이라 답하면 성숙합니다.
실무 핵심: ① 보안을 배포 직전이 아니라 CI 파이프라인 앞단에 게이트로 — 늦게 막을수록 비싸다(CI/CD 파이프라인), ② 이미지·비밀(시크릿·키 관리)·권한(계정과 IAM)을 함께 봐야 공급망이 닫힌다, ③ '한 번 스캔'이 아니라 새 CVE에 맞춰 재스캔하는 지속 프로세스. 이미지를 만드는 기초는 Docker, 운영은 매니지드 컨테이너·Kubernetes로 이어집니다.
다음으로는 이 모든 보안·운영을 코드로 선언(IaC와 Terraform)하고 Well-Architected 보안 기둥으로 점검하면 클라우드 보안의 큰 그림이 닫힙니다.