Trivy로 컨테이너 이미지 취약점 스캔을 CI/CD에 붙인 3개월 회고

목차

Trivy는 Aqua Security가 만든 오픈소스 보안 스캐너로, 컨테이너 이미지·파일시스템·Git 저장소에서 알려진 취약점(CVE)을 탐지한다. docker scan 명령이 2024년에 Trivy 기반으로 전환된 이후로는 사실상 컨테이너 이미지 취약점 스캔의 표준 도구라고 봐도 무방하다. 이 글은 프론트엔드에서 백엔드로 전환한 지 2년 된 개발자가, 3개월짜리 프로젝트에서 Trivy 컨테이너 이미지 취약점 스캔을 CI/CD 파이프라인에 처음 붙여본 과정을 시간순으로 풀어본 기록이다.

프로젝트 배경 — npm audit만 알던 사람이 컨테이너 보안을 맡다

프론트엔드를 하던 시절에는 보안 스캔이라고 해봐야 npm audit이 전부였다. 의존성 트리에서 known vulnerability가 뜨면 패치 버전으로 올리거나, 당장 영향 없으면 .npmrc에 audit-level 설정하고 넘어갔다. 그게 내가 아는 보안의 전부였다.

백엔드로 넘어오고 나서 Node.js 기반 API 서버를 Docker로 배포하는 프로젝트를 맡았다. 배포 파이프라인은 GitHub Actions로 이미 구성되어 있었는데, 이미지 빌드 → ECR 푸시 → ECS 배포 순서였다. 보안 스캔은 없었다. PR 리뷰 중에 "이미지 취약점 스캔 넣어야 하지 않나"라는 코멘트가 달렸고, 아무도 손을 안 들어서 내가 맡게 됐다.

처음에는 가볍게 생각했다. GitHub Actions에 step 하나 추가하면 끝이겠거니. 결과적으로 3개월 동안 설정을 네 번 바꿨다.

관련 추천 상품 보기 — 개발자를 위한 추천 장비와 도구를 확인해보세요. 쿠팡에서 보기 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

Trivy를 선택한 이유

컨테이너 이미지 스캐너를 찾아보면 후보가 몇 개 나온다. Trivy, Grype(Anchore), Snyk Container, AWS Inspector 정도가 눈에 들어왔다.

도구 라이선스 CI/CD 통합 난이도 SBOM 생성 비고
Trivy Apache 2.0 GitHub Action 공식 제공 CycloneDX, SPDX 지원 DB 자동 다운로드
Grype Apache 2.0 CLI 기반 수동 구성 Syft 별도 필요 Syft + Grype 조합
Snyk Container 상용 (무료 티어 있음) GitHub App 연동 유료 플랜 월 스캔 횟수 제한
AWS Inspector AWS 종속 ECR 연동만 미지원 (2026-04 기준) ECR 이미지 한정

Snyk은 무료 티어의 월간 테스트 횟수 제한이 걸렸다. 하루에 PR이 10개씩 올라오는 저장소에서는 금방 소진된다. AWS Inspector는 ECR에 푸시된 이미지만 스캔하니까, 빌드 단계에서 차단하는 용도로는 안 맞았다. Grype도 괜찮은 도구인데, Trivy 쪽이 GitHub Action이 공식으로 관리되고 있어서 초기 설정이 훨씬 짧았다.

Trivy의 결정적 장점은 설치가 거의 필요 없다는 거다. aquasecurity/trivy-action을 workflow에 추가하면 바이너리 다운로드부터 취약점 DB 갱신까지 알아서 처리된다. 프론트엔드 출신이라 인프라 도구에 시간 쓰는 게 익숙하지 않은 입장에서는 이게 크게 와닿았다.

1차 설정 — 일단 붙이기

처음 구성한 workflow는 단순했다. 이미지 빌드 후 Trivy로 스캔하고, 취약점이 있으면 실패시키는 구조.

# .github/workflows/security-scan.yml
name: Container Security Scan

on:
  pull_request:
    branches: [main]

jobs:
  trivy-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build Docker image
        run: docker build -t myapp:${{ github.sha }} .

      # Trivy 컨테이너 이미지 스캔
      - name: Run Trivy vulnerability scanner
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: 'myapp:${{ github.sha }}'
          format: 'table'
          exit-code: '1'          # 취약점 발견 시 실패
          severity: 'CRITICAL,HIGH'

exit-code: '1'이 핵심이다. 이걸 '0'으로 두면 취약점이 있어도 파이프라인이 통과해버린다. 처음에 테스트한다고 '0'으로 놓고 까먹은 적이 있었는데, 2주 동안 스캔은 돌아가지만 차단은 안 되는 상태로 운영했다. 로그를 열어보기 전까지 몰랐다.

첫 번째 충돌 — base image 취약점

이 설정을 PR에 올리자마자 파이프라인이 빨간불로 바뀌었다. node:18-alpine 이미지 자체에서 Critical 취약점이 3건 나왔다.

node:18-alpine (alpine 3.18.6)
===============================
Total: 3 (CRITICAL: 1, HIGH: 2)

┌──────────────┬────────────────┬──────────┬───────────────┐
│   Library    │ Vulnerability  │ Severity │ Fixed Version │
├──────────────┼────────────────┼──────────┼───────────────┤
│ libcrypto3   │ CVE-2024-5535  │ CRITICAL │ 3.1.7-r0      │
│ libssl3      │ CVE-2024-5535  │ CRITICAL │ 3.1.7-r0      │
│ busybox      │ CVE-2023-42364 │ HIGH     │ 1.36.1-r7     │
└──────────────┴────────────────┴──────────┴───────────────┘

애플리케이션 코드에는 문제가 없는데 base image의 OS 패키지 때문에 빌드가 막힌 거다. 프론트엔드에서 npm audit만 하던 입장에서는 이게 좀 당황스러웠다. 내 코드가 아니라 Alpine Linux에 포함된 OpenSSL 라이브러리의 취약점인데, 내 파이프라인이 막힌다.

해결은 간단했다. Dockerfile에서 apk upgrade를 추가해서 OS 패키지를 최신으로 올렸다.

FROM node:18-alpine

# OS 패키지 보안 패치 적용
RUN apk update && apk upgrade --no-cache

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

EXPOSE 3000
CMD ["node", "server.js"]

이렇게 하면 이미지 빌드 시점에 Alpine 패키지가 최신으로 갱신되니까 알려진 취약점 대부분이 해소된다. 이미지 사이즈가 약간 늘어나지만 체감상 2~3MB 수준이라 무시해도 된다.

2차 설정 — .trivyignore로 false positive 관리

OS 패키지를 올려도 해결 안 되는 취약점이 있었다. Fixed Version이 아직 릴리즈되지 않은 CVE, 또는 우리 서비스에서는 해당 코드 경로를 타지 않아서 실질적 위험이 없는 경우다.

이런 걸 매번 파이프라인에서 막으면 개발 속도가 죽는다. 팀에서 "이건 인정하고 넘어가자"고 합의한 CVE는 .trivyignore 파일에 등록해서 스캔 대상에서 제외할 수 있다.

# .trivyignore
# Fixed version 미출시 — 2026-02-15 확인, 월 1회 재검토
CVE-2024-5535

# busybox awk 취약점, 서비스에서 awk 미사용
CVE-2023-42364

주석으로 왜 무시하는지, 언제 확인했는지를 적어두는 게 중요하다. 이게 없으면 3개월 뒤에 "이거 왜 무시하고 있지?" 하는 상황이 온다. 실제로 겪었다. 내가 쓴 .trivyignore인데 내가 왜 넣었는지 기억이 안 나서 다시 CVE 데이터베이스를 뒤졌다.

무시 정책 수립

.trivyignore를 무분별하게 쓰면 스캔의 의미가 없어진다. 우리 팀에서 정한 규칙은 이랬다.

  • 무시 가능: Fixed version 미출시 + 해당 코드 경로 미사용
  • 무시 불가: Fixed version 존재 + CRITICAL severity
  • 재검토 주기: 월 1회. .trivyignore에 등록된 CVE를 다시 확인해서 패치가 나왔으면 제거

이 규칙을 README에 적어뒀는데, 새로 들어온 팀원이 모르고 CVE 5개를 한꺼번에 .trivyignore에 추가한 적이 있다. 코드 리뷰에서 잡았지만, .trivyignore 변경에 대한 CODEOWNERS 설정을 추가하는 게 맞다는 걸 그때 깨달았다.

# CODEOWNERS
.trivyignore @security-team @backend-leads

severity 전략 — CRITICAL만 막을 것인가, HIGH도 막을 것인가

이게 생각보다 논쟁이 됐다. CRITICAL만 차단하면 빌드가 자주 안 막히니까 개발 흐름이 좋다. 대신 HIGH 취약점이 조용히 쌓인다. HIGH도 차단하면 빌드가 자주 깨진다.

처음에는 CRITICAL만 차단했다. 한 달쯤 지나서 Trivy 결과를 모아봤더니 HIGH 취약점이 12개 누적되어 있었다. 이걸 한꺼번에 처리하려니 base image를 바꿔야 하는 건도 있고, 작업량이 한 번에 몰렸다.

결국 두 번째 달부터 HIGH도 차단 대상에 포함시켰다. 대신 .trivyignore를 좀 더 적극적으로 쓰고, PR 설명에 "이 CVE는 ~한 이유로 무시 대상"이라고 명시하는 프로세스를 넣었다. 빌드가 깨지는 빈도는 올라갔지만, 취약점이 조용히 쌓이는 것보다는 이 쪽이 낫다는 게 팀의 결론이었다.

MEDIUM과 LOW는 차단하지 않되, 주간 리포트로 모아서 확인한다. 이건 별도 scheduled workflow로 돌린다.

# 주간 전체 취약점 리포트 (차단 없이 기록만)
- name: Weekly full scan
  uses: aquasecurity/trivy-action@0.28.0
  with:
    image-ref: 'myapp:latest'
    format: 'json'
    output: 'trivy-weekly-report.json'
    exit-code: '0'              # 차단 안 함
    severity: 'CRITICAL,HIGH,MEDIUM,LOW'

SBOM 생성 — 소프트웨어 공급망 가시성 확보

SBOM(Software Bill of Materials)은 소프트웨어에 포함된 모든 구성 요소의 목록이다. 식품의 원재료 표시와 비슷한 개념이라고 보면 된다. 미국 연방 정부에 납품하는 소프트웨어는 SBOM 제출이 의무인데(Executive Order 14028), 국내에서도 공공 프로젝트를 중심으로 요구가 늘고 있다.

Trivy는 취약점 스캔과 별도로 SBOM 생성 기능을 제공한다. --format 옵션으로 CycloneDX 또는 SPDX 포맷을 선택할 수 있다.

CycloneDX vs SPDX

두 포맷 모두 업계 표준이지만 성격이 다르다. CycloneDX는 보안 중심(취약점 정보 포함이 자연스럽다), SPDX는 라이선스 컴플라이언스 중심이다. 우리는 보안 스캔 결과와 함께 관리할 목적이었으니까 CycloneDX를 골랐다.

# SBOM 생성 step
- name: Generate SBOM
  uses: aquasecurity/trivy-action@0.28.0
  with:
    image-ref: 'myapp:${{ github.sha }}'
    format: 'cyclonedx'
    output: 'sbom.cdx.json'

# GitHub Actions 아티팩트로 저장
- name: Upload SBOM
  uses: actions/upload-artifact@v4
  with:
    name: sbom-${{ github.sha }}
    path: sbom.cdx.json
    retention-days: 90

SBOM을 아티팩트로 저장해두면 나중에 특정 CVE가 공개됐을 때 "우리 이미지에 이 라이브러리가 있었나?"를 빠르게 확인할 수 있다. Log4Shell 같은 사건이 터졌을 때 SBOM이 있으면 영향 범위 파악이 몇 시간에서 몇 분으로 줄어든다.

최종 workflow — 3개월간의 시행착오가 녹아든 설정

네 번의 수정을 거쳐 최종적으로 안착한 workflow 전문이다.

name: Container Security

on:
  pull_request:
    branches: [main, develop]
  push:
    branches: [main]
  schedule:
    - cron: '0 9 * * 1'  # 매주 월요일 09:00 UTC — 주간 리포트용

jobs:
  scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write   # GitHub Security 탭 연동

    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .

      # PR/push 시: CRITICAL + HIGH 차단
      - name: Trivy scan (blocking)
        if: github.event_name != 'schedule'
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: 'myapp:${{ github.sha }}'
          format: 'sarif'
          output: 'trivy-results.sarif'
          exit-code: '1'
          severity: 'CRITICAL,HIGH'
          trivyignores: '.trivyignore'

      # 스케줄 시: 전체 severity 리포트 (차단 없음)
      - name: Trivy scan (reporting)
        if: github.event_name == 'schedule'
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: 'myapp:${{ github.sha }}'
          format: 'sarif'
          output: 'trivy-results.sarif'
          exit-code: '0'
          severity: 'CRITICAL,HIGH,MEDIUM,LOW'

      # GitHub Security 탭에 결과 업로드
      - name: Upload to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: 'trivy-results.sarif'

      # SBOM 생성
      - name: Generate SBOM
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: 'myapp:${{ github.sha }}'
          format: 'cyclonedx'
          output: 'sbom.cdx.json'

      - name: Upload SBOM artifact
        uses: actions/upload-artifact@v4
        with:
          name: sbom-${{ github.sha }}
          path: sbom.cdx.json
          retention-days: 90

SARIF 포맷과 GitHub Security 탭 연동

format: 'sarif'이 핵심이다. SARIF(Static Analysis Results Interchange Format)로 출력하면 github/codeql-action/upload-sarif를 통해 GitHub 저장소의 Security 탭에 결과가 올라간다. 이렇게 하면 PR 화면에서 바로 어떤 취약점이 발견됐는지 확인할 수 있고, 시간별 추이도 볼 수 있다.

처음에 format: 'table'로 콘솔 출력만 했을 때는 로그를 일일이 열어봐야 했다. SARIF로 바꾸고 나서 가시성이 확 좋아졌다. 코드 리뷰하듯이 취약점을 리뷰할 수 있게 된 거다.

Trivy DB 캐싱

CI에서 Trivy를 돌릴 때마다 취약점 DB를 다운로드하면 체감상 30~40초가 추가된다. 네트워크 상태에 따라 1분 넘게 걸리는 경우도 봤다. GitHub Actions 캐시를 활용하면 이 시간을 줄일 수 있다.

- name: Cache Trivy DB
  uses: actions/cache@v4
  with:
    path: ~/.cache/trivy
    key: trivy-db-${{ hashFiles('.github/workflows/security-scan.yml') }}
    restore-keys: |
      trivy-db-

DB 캐시를 적용한 뒤 스캔 step의 실행 시간이 체감상 절반 정도로 줄었다. 정확한 벤치마크를 뜬 건 아니지만, Actions 로그 기준으로 45초 → 15~20초 정도.

중간에 터진 것들

3개월 동안 순탄하지만은 않았다. 시간순으로 기록한다.

1개월차: Trivy DB 다운로드 실패

GitHub Actions에서 Trivy가 취약점 DB를 다운로드하지 못해서 스캔이 통째로 실패하는 일이 있었다. Aqua Security의 DB 서버 쪽 이슈였는데(GitHub Issue aquasecurity/trivy#5000번대에서 비슷한 보고가 여러 건 있다), 이럴 때 캐시된 DB라도 있으면 스캔 자체는 돌아간다.

--skip-db-update 옵션을 쓰면 DB 다운로드를 건너뛰고 로컬 캐시만 사용하는데, 이건 캐시가 너무 오래되면 최신 CVE를 놓칠 수 있어서 평소에는 쓰지 않는 게 맞다. 우리는 DB 다운로드 실패 시에만 캐시 fallback으로 돌아가는 구조를 넣었다.

2개월차: multi-stage build에서 스캔 대상 혼동

Dockerfile이 multi-stage build로 되어 있으면, 최종 이미지만 스캔해야 하는데 빌드 stage까지 스캔하는 실수를 했다. 빌드 stage에는 gcc, make 같은 빌드 도구가 들어있어서 취약점이 우수수 쏟아졌다. 이건 Trivy의 문제가 아니라 docker build의 target 설정 문제다.

# 빌드 stage — 여기는 스캔 대상이 아님
FROM node:18-alpine AS builder
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 프로덕션 stage — 이것만 스캔
FROM node:18-alpine AS production
RUN apk update && apk upgrade --no-cache
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]

docker build --target production으로 빌드한 이미지를 Trivy에 넘기면 된다. 이걸 인지하기까지 이틀 정도 헤맸다.

3개월차: node_modules 취약점 폭탄

OS 패키지 취약점은 정리가 됐는데, node_modules 안의 npm 패키지 취약점이 갑자기 많이 잡히기 시작했다. Trivy v0.50 이후로 언어별 패키지 스캔 정확도가 올라갔다는 릴리즈 노트를 봤다(출처: Trivy GitHub Release Notes). 스캐너가 똑똑해진 건 좋은데, 하루아침에 HIGH 취약점이 8개 추가로 잡혔다.

이건 npm audit과 Trivy 결과를 교차 확인하면서 하나씩 처리했다. npm audit에서는 안 잡히는데 Trivy에서만 잡히는 경우가 있었는데, advisory DB가 다르기 때문이다. Trivy는 NVD(National Vulnerability Database)와 GitHub Advisory Database를 모두 참조한다.

필요한 장비가 있다면 쿠팡에서 찾아보기 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

Trivy 컨테이너 이미지 취약점 스캔 자동화에서 놓치기 쉬운 것들

.dockerignore와 보안의 관계

.dockerignore.env, .git, node_modules(빌드 시 재설치하는 경우)를 빠뜨리면 이미지에 불필요한 파일이 포함된다. .git 디렉토리가 이미지에 들어가면 커밋 히스토리에 포함된 시크릿이 노출될 수 있다. Trivy가 시크릿 스캔 기능도 제공하긴 하지만(--scanners secret), 애초에 이미지에 안 넣는 게 맞다.

distroless 이미지로 공격 표면 줄이기

Alpine 대신 Google의 distroless 이미지(gcr.io/distroless/nodejs18-debian12)를 쓰면 쉘도 없고 패키지 매니저도 없어서 취약점이 확 줄어든다. 우리 프로젝트에서는 디버깅 편의상 Alpine을 유지했지만, 프로덕션 환경에서는 distroless가 보안 관점에서 유리하다. 이건 아직 안 해봐서 체감 비교는 못 하겠다.

GitHub Actions에서의 권한 설정

SARIF 업로드를 위해 security-events: write 권한이 필요하다. 이걸 빠뜨리면 업로드 step에서 403 에러가 난다. 처음 설정할 때 permissions 블록 자체를 안 넣어서 에러를 만난 적이 있는데, GitHub Actions의 기본 토큰 권한은 contents: read만 포함하고 있기 때문이다(출처: GitHub Docs — Automatic token authentication, 2026-04 기준).

다음 프로젝트에서는 Trivy 결과를 Slack으로 알림 보내는 것과, trivy config 명령으로 Dockerfile 자체의 misconfiguration(예: USER root로 실행, HEALTHCHECK 미설정)까지 스캔하는 걸 추가할 생각이다. aquasecurity/trivy-actionscanners: 'vuln,config'를 넣으면 되는데, config 스캔은 false positive가 꽤 많다고 해서 규칙 튜닝이 필요할 것 같다.

관련 글

Chiko
Chiko

Platform Engineer. Python, AI, Infra에 관심이 많습니다.