쿠버네티스 컨테이너 이미지 취약점 스캔: Trivy vs Snyk vs Grype 비교

목차

$ trivy image --severity CRITICAL,HIGH myapp:1.4.2
2026-05-08T03:21:11Z  INFO  Vulnerability scanning is enabled
2026-05-08T03:21:14Z  INFO  Detected OS: debian
2026-05-08T03:21:14Z  INFO  Detecting Debian vulnerabilities...

myapp:1.4.2 (debian 12.4)
=========================
Total: 47 (CRITICAL: 11, HIGH: 36)

┌──────────┬────────────────┬──────────┬─────────────────┬─────────────────────┐
│ Library  │ Vulnerability  │ Severity │ Installed Ver.  │   Fixed Version     │
├──────────┼────────────────┼──────────┼─────────────────┼─────────────────────┤
│ libssl3  │ CVE-2024-5535  │ CRITICAL │ 3.0.11-1        │ 3.0.13-1+deb12u1    │
│ zlib1g   │ CVE-2023-45853 │ CRITICAL │ 1:1.2.13.dfsg-1 │ —                   │
└──────────┴────────────────┴──────────┴─────────────────┴─────────────────────┘

##[error] Process completed with exit code 1.

결국, 쿠버네티스 컨테이너 이미지 취약점 스캔을 CI에 처음 붙였을 때 받은 결과가 위 출력이다. CRITICAL 11건이 떠서 main 브랜치 머지가 막혔다. 한 번도 안 바꾸고 6개월을 굴린 베이스 이미지의 누적 빚이었다. 이 글은 그 자동화를 깔기 전에 Trivy, Snyk Container, Grype 세 도구를 후보로 놓고 비교했던 기준과 결과를 정리한 것이다.

스캔 자동화를 다시 들여다본 이유

이전 흐름은 단순했다. 이미지를 빌드해서 ECR에 푸시하면 끝. 분기에 한 번씩 외부 보안 진단 업체가 와서 점검하고 리포트를 돌려줬다. 이미지 빌드 빈도가 하루 5~10회였을 때는 이 정도면 충분했다.

그런데 마이크로서비스를 쪼개고 GitHub Actions 기반으로 빌드 트리거가 자동화되면서, 하루 빌드 횟수가 30~50회로 올라갔다. 분기 진단이 따라잡을 수 없는 속도다. 결정적인 사건은 xz-utils 백도어 사건이었다. CVE-2024-3094가 공개된 직후, 우리 베이스 이미지가 영향권인지 즉시 확인할 방법이 없었다 (출처: NVD CVE-2024-3094, 2024-03-29 등록). 사람이 일일이 dpkg -l 떠보는 동안 누군가는 이미 자동 스캐너로 30초 안에 답을 내고 있었다.

즉, 자동화를 하기로 결정한 다음 단계가 도구 선정이다. 새 기술을 추가할 때는 보수적으로 접근하는 편이라, 가능하면 이미 검증된 OSS 한두 개로 좁혀서 비교했다.

후보를 추리며 잡은 5가지 기준

세 후보의 공통점은 OS 패키지, 애플리케이션 의존성, 시크릿, 미스컨피그까지 검출한다고 홍보한다는 점이다. 비교가 의미 있으려면 광고가 아닌 측정 가능한 기준이 필요했다.

  • 스캔 속도: 동일 이미지 기준 처음/캐시 후
  • 오탐과 무시 정책 관리 편의성
  • 취약점 DB 갱신 주기와 데이터 소스
  • GitHub Actions 통합 난이도와 SARIF 지원
  • 라이선스/비용 (조직 도입 시 결재 라인 영향)

세 도구를 같은 이미지에 대고 돌려서 결과를 비교했다. 테스트 이미지는 사내에서 가장 큰 Node.js 백엔드 이미지(압축 해제 기준 약 480MB, debian 12.4 베이스, 운영 중 버전)였다.

Trivy vs Snyk Container vs Grype 항목별 비교

스캔 속도와 DB 갱신

한편, 세 도구를 GitHub Actions의 ubuntu-22.04 러너에서 같은 이미지에 대해 5회씩 돌렸다. DB 캐시 없는 초기 실행과 캐시 적중 후의 차이가 컸다.

도구 버전 첫 실행 평균 캐시 후 평균 DB 크기 갱신 주기
Trivy v0.51.4 41초 12초 약 380MB 6시간
Snyk Container snyk CLI 1.1290 28초 22초 (서버 측) 실시간
Grype v0.79.6 36초 9초 약 220MB 12시간

이처럼, 캐시가 적중하면 Grype가 가장 빠르고, 첫 실행은 Snyk가 가장 빨랐다. Snyk는 DB를 로컬에 안 받아서 그렇다. 다만 회사 네트워크가 Snyk 서버로 외부 호출하는 걸 보안 정책상 막아둔 곳이라면 이건 그대로 단점이 된다.

특히, DB 갱신 주기는 Trivy가 6시간, Grype가 12시간이다. Trivy DB는 GitHub Container Registry의 ghcr.io/aquasecurity/trivy-db에 OCI 아티팩트로 올라온다 (출처: aquasecurity/trivy-db README, v2 스키마 기준). 캐싱 전략을 짜기 좋다.

오탐과 무시 정책

오탐은 도구 자체의 정밀도와 데이터 소스 차이에서 나온다. 같은 이미지에 대해 세 도구가 잡아낸 CRITICAL/HIGH 건수를 비교했다.

:::stats { "items": [ {"label": "Trivy", "value": 47, "unit": "건"}, {"label": "Snyk", "value": 52, "unit": "건"}, {"label": "Grype", "value": 43, "unit": "건"} ] } :::

게다가, 수치만 보면 비슷해 보이지만, 겹치는 항목을 빼고 보면 각자 고유 검출이 5~9건씩 있었다. Trivy와 Grype는 모두 NVD와 OS 벤더 어드바이저리를 함께 본다. Snyk는 자체 큐레이션한 Snyk Intel DB를 쓴다 (출처: docs.snyk.io, Vulnerability database 페이지).

무시 정책 관리 측면에서는 Trivy의 .trivyignore 파일 방식이 가장 단순했다. CVE ID를 줄 단위로 적으면 끝이다. 만료일을 붙일 수도 있다.

# .trivyignore
# zlib1g - 패치 미공개. 다음 베이스 업데이트 시 재검토
CVE-2023-45853 exp:2026-08-31

# libheif - 우리 앱은 HEIF 디코딩 경로 안 탐
CVE-2023-49463 exp:2026-07-15

반면, Snyk는 웹 UI에서 무시 정책을 관리한다. 깔끔하지만 IaC처럼 git으로 추적이 안 된다. Grype는 YAML 기반의 .grype.yaml이 있어서 무시 규칙을 구조적으로 쓸 수 있다. 다만 CVE 단위가 아니라 패키지+버전 매칭 방식이라 표현력이 다르다.

라이선스와 비용

도구 라이선스 무료 한도 유료 시작가
Trivy Apache-2.0 무제한
Snyk Container 상용 (SaaS) 월 100회 스캔 Team $25/dev/월부터
Grype Apache-2.0 무제한

Snyk 가격은 2026년 5월 기준 공식 페이지 표기를 옮긴 것이다. 협상 단가는 다를 수 있다 (출처: snyk.io/plans). 엔터프라이즈 플랜은 별도 견적이다.

한편, 조직 결재 관점에서 OSS 두 개와 SaaS 하나는 결이 다르다. Snyk는 대시보드, 리포트, 정책 자동화가 묶여 있어서 보안팀이 단독 운영하기 좋다. Trivy/Grype는 그 정책 레이어를 별도로 만들어야 한다.

GitHub Actions에 Trivy 붙이기

그러나, 비교 결과만 놓고 본다면 Snyk의 단일 패키지화가 매력적이긴 하다. 물론 우리 환경은 외부 SaaS 호출 정책이 까다롭고, 이미 GitHub의 Code Scanning(Security 탭)을 쓰고 있어서 SARIF를 그대로 끌어올릴 수 있는 OSS 쪽이 운영 비용이 더 낮았다. 그래서 Trivy로 좁혔다.

워크플로우 골격

기본 워크플로우는 다음과 같다. aquasecurity/trivy-action@0.24.0 기준이다.

# .github/workflows/image-scan.yml
name: image-scan
on:
  pull_request:
    paths: ["Dockerfile", "package*.json"]
  push:
    branches: [main]

jobs:
  scan:
    runs-on: ubuntu-22.04
    permissions:
      contents: read
      security-events: write  # SARIF 업로드용
    steps:
      - uses: actions/checkout@v4

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

      - name: Trivy DB 캐시
        uses: actions/cache@v4
        with:
          path: ~/.cache/trivy
          key: trivy-db-${{ runner.os }}-${{ github.run_id }}
          restore-keys: |
            trivy-db-${{ runner.os }}-

      - name: 취약점 스캔 (SARIF 출력)
        uses: aquasecurity/trivy-action@0.24.0
        with:
          image-ref: myapp:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
          exit-code: '1'
          ignore-unfixed: true

      - name: SARIF 업로드
        if: always()
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: trivy-results.sarif

exit-code: '1'로 두면 CRITICAL/HIGH가 잡히는 즉시 PR이 빨간색이 된다. ignore-unfixed: true도 같이 쓴다. 수정 버전이 아직 공개 안 된 항목은 막아도 못 막으니까 일단 제외한다.

SARIF로 Security 탭에 올리기

또한, 처음엔 Trivy 결과를 PR 코멘트로 봇이 뿌리게 했는데, 코멘트가 너무 길어져서 리뷰가 묻혔다. SARIF로 GitHub의 Code Scanning에 올리니까 같은 항목이 PR 파일 뷰의 인라인 어노테이션으로 붙는다. Files Changed 탭에서 바로 보여서 누적 무시도 깔끔하게 된다.

security-events: write 권한이 빠지면 업로드가 403으로 떨어진다. 처음 깔 때 이 권한이 없어서 한참을 헤맸다.

PR 정책과 main 보호 규칙

Code Scanning에 결과가 올라가면 Code scanning results / Trivy 라는 체크가 자동으로 등록된다. 이걸 main 브랜치 보호 규칙의 Required status checks에 추가하면, CRITICAL이 새로 들어왔을 때 머지 자체가 막힌다 (출처: GitHub Docs, Code scanning > Defining the severities causing pull request check failure).

운영 중 부딪힌 부분

도입 후 2~3주 사이에 마주친 함정 몇 가지가 있었다. 자동화는 켜는 것보다 운영 정책을 잡는 게 더 오래 걸렸다.

베이스 이미지가 빚을 키운다

또한, 처음 스캔을 돌렸을 때 CRITICAL이 11건, HIGH가 36건 나왔다는 출력은 도입부에 보여준 그대로다. 이 중 대부분은 우리 코드 잘못이 아니라 베이스 이미지 누적 패치 부재가 원인이었다. node:20-bullseye를 6개월 안 바꾸면 OS 레벨 CVE가 그만큼 쌓인다.

실제로, 해결책은 두 갈래였다.

# Before
FROM node:20-bullseye
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "server.js"]

# After
FROM node:20-bookworm-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
USER nonroot
CMD ["server.js"]

distroless 베이스로 옮기고 멀티스테이지로 빌더와 런타임을 분리했더니, CRITICAL 11건이 0건, HIGH 36건이 4건으로 줄었다. distroless가 만능은 아니다. sh가 없으니 디버깅이 까다롭고, debug 변형 이미지를 따로 빌드해야 한다. 운영 이미지에는 효과적이지만 개발 단계에는 부담이다.

무시 정책이 쓰레기통이 되는 문제

게다가, CRITICAL을 빨리 0으로 만들려는 압박 때문에, 초창기에 .trivyignore에 만료일 없이 CVE를 잔뜩 박아 넣는 흐름이 생기기 쉽다. 이러면 두 달 뒤 패치가 나와도 영원히 가려진다.

해법은 두 가지를 강제하는 것이다.

  • 만료일(exp:YYYY-MM-DD) 없는 ignore는 PR에서 막기
  • 매월 만료된 ignore 항목을 자동으로 다시 검토 PR로 띄우기

물론, 만료일 강제는 간단한 grep 스크립트로 막을 수 있다.

# scripts/check-ignore.sh
#!/usr/bin/env bash
# 만료일 없는 CVE 라인 검출
if grep -E '^CVE-[0-9]+-[0-9]+\s*$' .trivyignore; then
  echo "::error::.trivyignore 항목에 exp: 만료일이 필요하다"
  exit 1
fi

캐시 키가 어긋나면 매번 380MB 다운로드

즉, 처음 워크플로우에서 캐시 키를 github.run_id로만 잡았더니 매 PR마다 캐시 미스가 났다. DB가 380MB 정도라 매번 다운로드하면 30~40초가 통째로 날아간다. restore-keys를 prefix로 둬서 항상 가장 최근 캐시를 끌어오게 하니 캐시 적중률이 90% 이상으로 올라갔다.

작게 끝나는 정책 하나

게다가, 스캔 정책은 한 번에 완벽하게 만들려고 하면 도입 자체가 무산된다. 처음엔 main 브랜치에서만 fail-on-CRITICAL을 켜고, feature 브랜치는 경고만 띄우게 시작하는 게 현실적이었다. 2주 뒤에 HIGH도 차단에 넣었고, 한 달 뒤에 베이스 이미지 자동 갱신 PR(renovatebot)을 붙였다.

게다가, 이 부분은 아직 손대지 못한 곳이 남아 있다. 런타임에서 Falco로 컨테이너 행위 기반 탐지를 붙이는 건 아직 안 해봤다. 클러스터 부하 영향이 어느 정도일지 가늠이 안 돼서 그렇다.

개인 의견

개인적으로는 SaaS의 대시보드 편의성이 큰 조직이 아니라면 Trivy + GitHub Code Scanning 조합이 가장 무난한 것 같다. OSS라서 도입 결재가 빠르고, SARIF가 Security 탭에 그대로 꽂혀서 별도 UI 운영 비용이 거의 안 든다.

당장 시작한다면 이 순서를 권한다.

  • aquasecurity/trivy-action@0.24.0을 PR 트리거로 붙여서 일주일 동안 결과만 수집한다 (차단 없이)
  • 베이스 이미지 한 번 갱신해서 누적 빚을 털어낸다 (distroless 또는 alpine 슬림 계열)
  • main 브랜치 보호 규칙에 Code Scanning 체크를 Required로 걸고, CRITICAL부터 차단을 시작한다

관련 글