본문 바로가기
Cloud Computing & MSA

Trivy로 CI 파이프라인에 배포 게이트 걸기

oopennova·2026년 9월 17일·조회 2

보안 스캔을 붙이자는 말은 어느 팀에서나 나오지만, 막상 도입하면 리포트만 쌓이고 배포는 그대로 나가는 경우를 자주 본다. 스캔 결과가 파이프라인을 멈추지 못하면 사실상 없는 것과 같다. 예전에 trivy image 한 줄로 로컬에서 이미지만 훑어보던 글을 쓴 적이 있는데, 이번에는 그걸 CI에 얹어서 심각도 기준으로 배포를 실제로 막는 데까지 정리한다.

핵심부터 말하자면, Trivy는 컨테이너 이미지와 IaC 파일(Dockerfile, 쿠버네티스 매니페스트)에서 CVE와 하드코딩된 시크릿을 찾아내는 오픈소스 스캐너다. CI 단계에서 --severity HIGH,CRITICAL --exit-code 1을 주면 심각한 취약점이 있을 때 종료 코드 1로 파이프라인을 실패시켜 배포를 막을 수 있다. 오탐은 .trivyignore로 걸러내고, 같은 스캔에서 SBOM도 뽑아낸다.

1. Trivy가 무엇을 어떻게 스캔하나

Trivy는 Aqua Security가 만든 스캐너로, 대상 하나에 여러 종류의 검사를 붙인다. 스캐너 종류는 네 가지다. vuln은 알려진 취약점(CVE), secret은 소스나 이미지에 박힌 API 키 같은 비밀값, misconfig는 Dockerfile이나 K8s 매니페스트의 잘못된 설정, license는 라이선스 검사다.

명령마다 기본으로 켜지는 스캐너가 다르다는 점을 처음에 놓치기 쉽다. trivy image취약점과 시크릿 스캔이 기본으로 켜져 있고 미스컨피그는 꺼져 있다. 반대로 Dockerfile이나 매니페스트의 설정 오류는 trivy config라는 별도 명령으로 검사한다.

심각도는 CVSS 점수를 기준으로 다섯 단계로 나뉜다. 9.0 이상이 CRITICAL, 7.0~8.9가 HIGH, 4.0~6.9가 MEDIUM, 0.1~3.9가 LOW다. Trivy는 NVD 점수보다 배포판 벤더가 매긴 심각도를 우선한다. 같은 CVE라도 Alpine과 Debian에서 등급이 다르게 나올 수 있는 이유다.

2. 로컬에서 먼저 돌려본다

CI에 넣기 전에 로컬에서 손으로 돌려봐야 어떤 결과가 나오는지 감을 잡는다. 설치는 바이너리를 받거나 패키지 매니저를 쓴다.

$ curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
$ trivy --version
Version: 0.66.0
Vulnerability DB:
  Version: 2
  UpdatedAt: 2026-09-17 00:00:00 ...

버전 번호는 설치 시점에 따라 다를 수 있다. 이미지부터 스캔한다.

$ trivy image --severity HIGH,CRITICAL nginx:1.25

nginx:1.25 (debian 12.4)
========================
Total: 3 (HIGH: 2, CRITICAL: 1)

┌───────────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┐
│    Library    │ Vulnerability  │ Severity │ Status │ Installed Version │ Fixed Version │
├───────────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┤
│ libssl3       │ CVE-2024-XXXXX │ CRITICAL │ fixed  │ 3.0.11-1          │ 3.0.13-1      │
└───────────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┘

여기서 Statusfixed면 패치 버전이 나와 있다는 뜻이다. 패치가 아직 없는 취약점까지 다 뜨면 노이즈가 많아진다. 그럴 때 --ignore-unfixed를 붙이면 수정 버전이 있는 것만 남는다. 대응할 수 있는 것만 보겠다는 의도라면 이 옵션을 먼저 켜는 편을 권한다.

Dockerfile과 쿠버네티스 매니페스트는 trivy config로 검사한다. 디렉터리를 통째로 넘기면 하위의 IaC 파일을 찾아 미스컨피그를 검사한다.

$ trivy config .

Dockerfile (dockerfile)
=======================
Tests: 26 (SUCCESSES: 24, FAILURES: 2)
Failures: 2 (HIGH: 1, CRITICAL: 0)

HIGH: Specify a tag in the 'FROM' statement for image 'node'
═══════════════════════════════════════
When using a 'FROM' statement you should use a specific tag to avoid
uncontrolled behavior when the image is updated.
  See https://avd.aquasec.com/misconfig/ds001
───────────────────────────────────────

시크릿 스캔은 소스 트리를 대상으로 trivy fs를 쓰면 확인하기 쉽다. AWS 액세스 키나 프라이빗 키가 커밋에 섞여 들어간 경우를 잡아낸다.

$ trivy fs --scanners secret .

HIGH: AWS Access Key ID
═══════════════════════════════════════
 config/prod.env:12
───────────────────────────────────────
  12 [ AWS_ACCESS_KEY_ID=AKIA****************
───────────────────────────────────────

3. 심각도 기준으로 배포 게이트를 만든다

게이트의 원리는 종료 코드다. Trivy는 기본적으로 취약점을 찾아도 종료 코드 0으로 끝난다. 리포트만 출력하고 성공으로 넘어간다는 뜻이다. 여기에 --exit-code 1을 주면 필터를 통과한 결과가 하나라도 있을 때 종료 코드 1을 반환한다. CI 러너는 이걸 실패로 받아들여 다음 단계를 멈춘다.

이 두 옵션을 조합하는 게 게이트의 전부다.

$ trivy image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed myapp:latest
$ echo $?
1

운영에서는 심각도별로 게이트를 두 단계로 나누는 방식이 실용적이다. CRITICAL은 종료 코드 1로 배포를 막고, HIGH와 MEDIUM은 종료 코드 0으로 리포트만 남긴다. 같은 이미지에 명령을 두 번 돌리면 된다.

# 1) CRITICAL은 빌드를 실패시킨다
trivy image --severity CRITICAL --exit-code 1 --ignore-unfixed myapp:latest

# 2) HIGH 이하는 기록만 남기고 통과시킨다
trivy image --severity HIGH,MEDIUM --exit-code 0 --ignore-unfixed myapp:latest

처음부터 HIGH까지 막으면 기존 이미지에서 무더기로 걸려 파이프라인이 계속 빨갛게 뜬다. CRITICAL만 막고 시작한 뒤 기준을 조여가는 순서가 현실적이다.

4. GitHub Actions 연동

GitHub Actions에서는 공식 aquasecurity/trivy-action을 쓴다. 결과를 SARIF 형식으로 뽑아 GitHub Security 탭에 올리면 코드 스캐닝 알림으로 이어진다.

name: build
on:
  push:
    branches: [main]
  pull_request:

jobs:
  scan:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@v4

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

      - name: Trivy scan (gate)
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: 'myapp:${{ github.sha }}'
          severity: 'CRITICAL'
          exit-code: '1'
          ignore-unfixed: true

      - name: Trivy scan (report to SARIF)
        if: always()
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: 'myapp:${{ github.sha }}'
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@v4
        with:
          sarif_file: 'trivy-results.sarif'

여기서 한 번 걸린다. 게이트 스텝이 exit-code: 1로 실패하면 뒤 스텝이 건너뛰어져 SARIF 업로드가 안 된다. 그래서 리포트와 업로드 스텝에 if: always()를 붙여야 실패한 빌드에서도 결과가 Security 탭에 올라간다. 게이트 스텝과 리포트 스텝을 나눈 이유가 이거다.

SARIF 업로드에는 security-events: write 권한이 필요하다. 이걸 빼먹으면 업로드 단계에서 Resource not accessible by integration 에러가 난다. 잡 상단 permissions에 넣어준다.

5. Jenkins 연동

Jenkins에는 전용 액션이 없으니 파이프라인 셸에서 trivy 바이너리를 직접 호출한다. 종료 코드로 스테이지를 실패시키는 방식은 동일하다.

pipeline {
  agent any
  stages {
    stage('Build') {
      steps { sh 'docker build -t myapp:${BUILD_NUMBER} .' }
    }
    stage('Trivy Gate') {
      steps {
        sh '''
          trivy image \
            --severity CRITICAL \
            --exit-code 1 \
            --ignore-unfixed \
            --format table \
            myapp:${BUILD_NUMBER}
        '''
      }
    }
    stage('Trivy Report') {
      steps {
        sh '''
          trivy image \
            --severity CRITICAL,HIGH \
            --exit-code 0 \
            --format json \
            --output trivy-report.json \
            myapp:${BUILD_NUMBER}
        '''
        archiveArtifacts artifacts: 'trivy-report.json'
      }
    }
  }
}

Jenkins에서 자주 막히는 지점은 취약점 DB 다운로드다. 스테이지마다 Trivy가 DB를 새로 받으면 느리고, GitHub 레이트 리밋에 걸려 failed to download vulnerability DB로 실패하기도 한다. TRIVY_CACHE_DIR를 워크스페이스 밖 고정 경로로 잡아 DB 캐시를 재사용하면 이 문제가 줄어든다. 규모가 커지면 trivy server를 별도로 띄우고 클라이언트 모드로 붙이는 구성으로 간다.

6. 오탐 무시는 .trivyignore로

게이트를 걸면 반드시 오탐이나 지금 당장 못 고치는 취약점을 마주친다. 이걸 코드로 관리하지 않고 심각도 기준만 낮추면 게이트가 무력해진다. 무시할 항목은 .trivyignore 파일에 명시적으로 적는다. 프로젝트 루트에 두면 Trivy가 자동으로 읽는다.

# 위험을 수용하기로 한 CVE
CVE-2023-2650

# 기한을 정해두고 무시 (날짜가 지나면 다시 검출)
CVE-2019-14697 exp:2026-12-31

# 미스컨피그 규칙 무시
AVD-DS-0002

# 시크릿 규칙 무시
aws-account-id

여기서 exp: 문법을 권한다. 무기한 무시는 그대로 방치되기 쉽다. 만료일을 붙여두면 그날 이후 다시 검출되므로 재검토가 강제된다.

패키지 경로나 PURL까지 조건을 걸어 더 좁게 무시하려면 YAML 형식을 쓴다. 이건 자동 로드가 안 되니 --ignorefile로 지정한다.

vulnerabilities:
  - id: CVE-2023-3817
    purls:
      - "pkg:deb/debian/libssl1.1"
    expired_at: 2026-12-01
    statement: 런타임 경로에서 미사용, 위험 수용

misconfigurations:
  - id: AVD-DS-0001
    paths:
      - "docs/Dockerfile"
$ trivy image --ignorefile ./.trivyignore.yaml myapp:latest

무시한 항목이 실제로 걸러졌는지 확인할 때는 --show-suppressed를 붙인다. 무엇을 왜 무시하고 있는지 리뷰에서 드러나야 게이트가 신뢰를 얻는다.

7. 같은 스캔에서 SBOM 뽑기

SBOM은 이미지나 프로젝트에 들어간 구성요소 목록이다. 공급망 보안 요구가 늘면서 배포물마다 SBOM을 남겨두는 팀이 많아졌다. Trivy는 CycloneDX와 SPDX 두 표준을 지원한다.

# CycloneDX(JSON) 형식으로 이미지 SBOM 생성
$ trivy image --format cyclonedx --output sbom.cdx.json myapp:latest

# SPDX(JSON) 형식
$ trivy image --format spdx-json --output sbom.spdx.json myapp:latest

# 소스 트리 대상
$ trivy fs --format cyclonedx --output sbom.cdx.json /app/myproject

기본 SBOM에는 취약점 정보가 빠진다. 취약점까지 SBOM에 담으려면 --scanners vuln을 함께 켠다. 반대로, 이미 만들어둔 SBOM을 나중에 다시 스캔할 수도 있다. 빌드 시점에 SBOM만 저장해 두고, 새 CVE가 공개되면 그 SBOM을 대상으로 재검사하는 방식이다.

$ trivy sbom ./sbom.cdx.json

sbom.cdx.json (debian 12.4)
===========================
Total: 1 (HIGH: 1, CRITICAL: 0)

파이프라인에서는 SBOM 생성 스텝을 게이트와 분리해 항상 실행되게 두고, 산출물로 보관하는 구성이 깔끔하다. 게이트가 실패해도 SBOM은 남는다.

8. 정리와 주의사항

게이트를 걸 때 실무에서 지키면 좋은 것들을 마지막에 모은다.

  • 심각도 단계 도입: 처음에는 CRITICAL만 --exit-code 1로 막고, 이미지 정리가 끝나면 HIGH까지 조인다.
  • 대응 가능한 것만: --ignore-unfixed로 패치가 있는 취약점에 집중하면 초기 노이즈가 줄어 게이트가 유지된다.
  • 무시는 코드로: 심각도를 낮추지 말고 .trivyignore에 만료일과 함께 기록한다. 리뷰에서 근거가 남는다.
  • DB 캐시: TRIVY_CACHE_DIR로 취약점 DB를 재사용해 다운로드 실패와 지연을 줄인다.
  • 게이트와 리포트 분리: 게이트 스텝이 실패해도 SARIF 업로드와 SBOM 산출물이 남도록 리포트 스텝을 나누고 항상 실행되게 둔다.

Trivy는 이미지, IaC, 시크릿, SBOM을 한 도구로 묶어준다. CI에 붙일 때 관건은 스캔 자체가 아니라 종료 코드로 배포를 실제로 멈추게 하고, 무시 규칙을 코드로 관리하는 운영 규칙이다.

자주 묻는 질문

Trivy가 취약점을 찾았는데도 CI가 통과한다. 왜인가?

Trivy는 기본적으로 취약점을 찾아도 종료 코드 0으로 끝난다. 리포트만 출력하고 성공으로 넘어간다. 배포를 막으려면 --exit-code 1을 명시해야 한다. --severity로 대상 등급을 좁힌 뒤 --exit-code 1을 함께 주면, 해당 등급이 하나라도 검출될 때 종료 코드 1을 반환해 CI가 실패한다.

Dockerfile과 쿠버네티스 매니페스트의 설정 오류도 trivy image로 잡히나?

아니다. trivy image의 기본 스캐너는 취약점(vuln)과 시크릿(secret)이고 미스컨피그는 꺼져 있다. Dockerfile이나 K8s 매니페스트의 잘못된 설정은 별도 명령인 trivy config로 검사한다. 디렉터리를 넘기면 하위 IaC 파일을 찾아 규칙 위반을 보고한다.

.trivyignore로 무시한 항목을 나중에 다시 검토하게 만들 수 있나?

가능하다. .trivyignore에서 CVE 뒤에 exp:YYYY-MM-DD를 붙이면 그 날짜까지만 무시하고 이후 다시 검출된다. YAML 형식에서는 expired_at 필드를 쓴다. 무기한 무시는 방치되기 쉬우므로 만료일을 붙여 재검토를 강제하는 방식을 권한다.

SBOM에 취약점 정보가 안 들어간다.

Trivy의 SBOM 생성은 기본적으로 구성요소 목록만 담고 취약점 데이터는 제외한다. 취약점까지 포함하려면 --scanners vuln을 함께 켜면 된다. 또는 SBOM만 먼저 생성해 보관하고, 새 CVE가 공개된 뒤 trivy sbom 명령으로 그 SBOM을 다시 스캔하는 방식도 쓸 수 있다.

GitHub Actions에서 SARIF 업로드가 실패한다.

두 가지를 확인한다. 첫째, 잡에 security-events: write 권한이 없으면 업로드에서 Resource not accessible by integration 에러가 난다. 둘째, 게이트 스텝이 exit-code 1로 실패하면 뒤 스텝이 건너뛰어진다. 리포트와 업로드 스텝에 if: always()를 붙여야 실패한 빌드에서도 결과가 Security 탭에 올라간다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.

Trivy로 CI 파이프라인에 배포 게이트 걸기 · 삵 sarc.io