날씨 정보를 불러오는 중...

Trivy로 컨테이너 이미지 취약점 스캔하기 - 홈랩 CI/CD 파이프라인에 실제로 붙여본 후기

2026-09-12 16:38 · IT기술 · 조회 10
Trivy 이미지 스캔

Trivy로 컨테이너 이미지 취약점 스캔하기 - 홈랩 CI/CD 파이프라인에 실제로 붙여본 후기

얼마 전에 홈랩 쿠버네티스(k3s)에 Gitea Actions로 자동 배포 파이프라인을 만들었습니다. 커밋 → 푸시하면 이미지 빌드부터 배포까지 자동으로 되는 게 신기해서 며칠 잘 써봤는데, 문득 이런 생각이 들었습니다. "지금 이 이미지 안에 어떤 라이브러리, 어떤 OS 패키지가 들어있는지, 거기에 알려진 보안 취약점(CVE)이 있는지는 한 번도 확인 안 해봤네." 배포는 자동화했는데 그 배포되는 물건이 안전한지는 아무도 검사를 안 하고 있었던 셈입니다. 그래서 이번엔 오픈소스 컨테이너 이미지 스캐너인 Trivy를 파이프라인에 실제로 붙여서, 진짜로 취약점을 잡아내고 배포를 막아보기까지 한 경험을 정리합니다.

why 왜 이미지 스캔이 필요한가

컨테이너 이미지는 내가 짠 애플리케이션 코드만 들어있는 게 아닙니다. FROM python:3.11-slim 같은 베이스 이미지 안에는 Debian OS 패키지들이 깔려 있고, pip install -r requirements.txt로 설치한 서드파티 파이썬 라이브러리들도 들어갑니다. 이 중 어느 하나에서라도 알려진 취약점이 발견되면, 내가 직접 짠 코드가 완벽해도 그 이미지 전체가 공격 표면이 됩니다. 실제로 Log4Shell(Log4j) 사태 때 전 세계 수많은 서비스가 자기 코드가 아니라 "의존성으로 딸려온 라이브러리" 때문에 뚫릴 뻔했던 게 대표적인 사례입니다.

CI/CD가 자동화될수록 이 문제는 더 커집니다. 사람이 손으로 배포할 때는 그나마 "이 이미지 한번 열어볼까"라는 생각이라도 할 수 있지만, 커밋 한 번에 빌드-배포까지 자동으로 흘러가면 그 사이에 취약점 검사가 끼어들 자리가 없습니다. 그래서 이 스캔 단계를 아예 파이프라인 안에 자동으로 넣어야 의미가 있습니다.

concept Trivy가 하는 일

Trivy는 Aqua Security에서 만든 오픈소스 스캐너로, 컨테이너 이미지 하나를 넣으면 다음을 한 번에 훑어줍니다.

  1. OS 패키지 취약점 — 베이스 이미지의 Debian/Alpine/RHEL 패키지 버전을 읽어서 알려진 CVE 데이터베이스와 대조합니다.
  2. 언어별 의존성 취약점 — 파이썬(pip), Node(npm), Java(Maven/Gradle) 등 언어 패키지 매니저의 설치 메타데이터를 읽어서 마찬가지로 대조합니다.
  3. 설정 오류(misconfiguration) — Dockerfile이나 쿠버네티스 매니페스트의 위험한 설정(예: 컨테이너를 root로 실행)도 검사할 수 있습니다.
  4. 시크릿 스캔 — 이미지 레이어 안에 실수로 박제된 API 키, 비밀번호 같은 걸 탐지합니다.

결과는 심각도별(CRITICAL / HIGH / MEDIUM / LOW / UNKNOWN)로 분류되고, 각 취약점마다 "수정 버전이 나와 있는지(Fixed Version)"까지 같이 알려줍니다. 이 마지막 정보가 실무에서는 굉장히 중요한데, 뒤에서 실제로 겪은 문제로 설명하겠습니다.

install 설치하기

k3s 노드(CI 러너가 돌아가는 호스트)에 Trivy 바이너리를 직접 설치했습니다. 공식 원라이너 설치 스크립트도 있지만, 어떤 파일이 어디에 설치되는지 눈으로 확인하고 싶어서 GitHub 릴리즈에서 압축 파일을 직접 받는 방식을 썼습니다.

# 최신 릴리즈 태그와 아키텍처에 맞는 tar.gz 파일명 확인
curl -s https://api.github.com/repos/aquasecurity/trivy/releases/latest \
  | grep browser_download_url | grep Linux-64bit.tar.gz

# 다운로드 후 바이너리만 추출
curl -sL -o trivy.tar.gz https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_Linux-64bit.tar.gz
tar -xzf trivy.tar.gz trivy
mv trivy /usr/local/bin/trivy
chmod +x /usr/local/bin/trivy

trivy --version

여기서 한 가지 함정이 있었습니다. k3s는 기본 컨테이너 런타임으로 자체 내장 containerd를 씁니다. 소켓 경로가 일반적인 /run/containerd/containerd.sock가 아니라 /run/k3s/containerd/containerd.sock이고, 네임스페이스도 k8s.io를 명시해야 합니다. 그냥 trivy image localhost/mini-portal:latest라고만 실행하면 "이미지를 못 찾겠다"는 에러만 나옵니다. 아래처럼 환경변수 두 개를 잡아줘야 정상적으로 k3s 안의 이미지를 찾습니다.

export CONTAINERD_ADDRESS=/run/k3s/containerd/containerd.sock
export CONTAINERD_NAMESPACE=k8s.io

manual test 먼저 수동으로 스캔해보기

파이프라인에 넣기 전에, 이미 배포돼 있던 mini-portal 이미지 하나를 수동으로 스캔해봤습니다.

trivy image --severity HIGH,CRITICAL --format table \
  localhost/mini-portal:9cdaccaba5b70a7e4c702ff3b9ac3e31dbcdc537

첫 실행은 취약점 DB(약 112MB)를 내려받느라 20초 정도 걸렸고, 이후로는 캐시돼서 몇 초 안에 끝납니다. 결과를 보고 좀 놀랐는데, python:3.11-slim 베이스 이미지 하나에서만 HIGH 81건, CRITICAL 3건이 나왔습니다. 예를 들면 이런 식입니다.

  • perl-base 패키지에서 CRITICAL 등급 CVE — "대형 정규식 처리 중 오류" 취약점, 그런데 Fixed Version이 비어 있음 (아직 업스트림에서 패치가 안 나온 상태)
  • jaraco.context(파이썬 패키지)에서 HIGH 등급 경로 순회(path traversal) 취약점 — 5.3.0 → 6.1.0으로 올리면 해결됨
  • util-linux 계열에서 마운트 관련 TOCTOU 취약점 여러 건 — 일부는 이미 패치 버전 존재

내가 짠 코드는 한 줄도 안 건드렸는데 이 정도 목록이 나온다는 게, 왜 "베이스 이미지도 주기적으로 다시 빌드해야 한다"는 얘기를 하는지 체감하게 해줬습니다.

cicd integration Gitea Actions 파이프라인에 실제로 붙이기

기존 배포 워크플로우는 체크아웃 → podman 빌드 → containerd로 이미지 import → kubectl로 롤아웃 4단계였습니다. 여기서 "이미지 import" 직후, "롤아웃" 직전에 스캔 단계를 하나 끼워 넣었습니다. import가 끝난 뒤에 스캔해야 containerd 소켓으로 방금 만든 이미지를 바로 찾을 수 있기 때문입니다.

      - name: Scan image with Trivy
        env:
          CONTAINERD_ADDRESS: /run/k3s/containerd/containerd.sock
          CONTAINERD_NAMESPACE: k8s.io
        run: |
          echo "## HIGH/CRITICAL 취약점 목록 (참고용, 배포는 막지 않음) ##"
          trivy image --scanners vuln --severity HIGH,CRITICAL --format table \
            localhost/mini-portal:${{ gitea.sha }}

          echo "## 게이트: 수정 버전이 나와 있는 CRITICAL 취약점이 있으면 빌드 실패 ##"
          trivy image --scanners vuln --severity CRITICAL --ignore-unfixed --exit-code 1 --format table \
            localhost/mini-portal:${{ gitea.sha }}

일부러 스캔을 두 번 나눠 실행했습니다. 첫 번째는 HIGH/CRITICAL을 전부 보여주기만 하는 "리포트용"이고, 두 번째가 실제로 빌드를 실패시킬 수 있는 "게이트"입니다. 이렇게 나눈 이유는 바로 다음 문제 때문입니다.

gate problem 실제로 겪은 문제 - "고칠 수 없는 CRITICAL"

처음엔 단순하게 "CRITICAL 등급이 하나라도 있으면 배포를 막자"고 생각하고 trivy image --severity CRITICAL --exit-code 1만 걸었습니다. 그런데 테스트해보니 앞서 발견한 perl-base의 CRITICAL 취약점 때문에 게이트가 바로 걸렸습니다. 문제는 이 취약점의 상태가 "affected"(영향받음)였지 "fixed"가 아니었다는 겁니다. 즉 Debian 쪽에서 아직 패치를 내놓지 않은 취약점이라, 내가 아무리 이미지를 다시 빌드해도 절대 사라지지 않습니다. 이 상태로 게이트를 걸면 영원히 배포가 막히는 상황이 됩니다.

그래서 Trivy의 --ignore-unfixed 옵션을 추가했습니다. 이 옵션을 켜면 "고칠 방법이 아예 없는" 취약점은 게이트 판정에서 제외하고, 실제로 업그레이드하면 없앨 수 있는 CRITICAL만 배포 실패 조건으로 삼습니다. 대신 "고칠 수 없지만 알아는 둬야 하는" 것들은 위쪽의 리포트 단계(HIGH,CRITICAL 전체, exit-code 0)에서 계속 눈에 보이게 남겨뒀습니다. 정리하면:

  1. 리포트 단계: HIGH + CRITICAL 전부 보여줌, 배포는 막지 않음 (지금 뭐가 있는지 기록/모니터링용)
  2. 게이트 단계: CRITICAL 중에서도 --ignore-unfixed로 "고칠 수 있는 것"만 걸러서, 하나라도 있으면 빌드 실패

이렇게 하니 "고칠 수 있는데 방치된 심각한 취약점"은 확실히 막으면서도, "아직 아무도 못 고치는 것" 때문에 배포 자체가 마비되는 일은 없어졌습니다.

result 실제 실행 결과

워크플로우 파일을 커밋 → 푸시하고 실제 파이프라인이 도는 걸 지켜봤습니다. 실행 로그에 이렇게 찍혔습니다.

## HIGH/CRITICAL vulnerabilities (report only) ##
...
Total: 84 (HIGH: 81, CRITICAL: 3)

## Gate: fail build only on CRITICAL vulns that already have a fix available ##
...
Total: 0

Job succeeded

리포트 단계에서는 여전히 84건(HIGH 81 + CRITICAL 3)이 전부 로그에 남아서 나중에 검토할 수 있고, 게이트 단계는 "고칠 수 있는 CRITICAL 0건"으로 통과해서 그대로 kubectl set imagekubectl rollout status까지 이어져 실제 배포까지 성공했습니다. 만약 나중에 정말로 --ignore-unfixed를 걸고도 걸리는 CRITICAL이 생기면, 그건 "당장 패치 버전이 나와 있는데 방치된" 진짜 심각한 문제라는 뜻이니 그때는 빌드가 실패하는 게 맞는 동작입니다.

tips 붙여보고 느낀 점 / 팁

  1. 내 코드가 아니라 베이스 이미지가 취약점의 대부분을 차지한다는 걸 숫자로 확인하니 체감이 확 다릅니다. 정기적으로(예: 매주) 같은 태그로 이미지를 재빌드만 해도 OS 패키지 패치가 반영돼서 취약점 개수가 줄어듭니다.
  2. --ignore-unfixed 없이 게이트부터 걸지 말 것 — 저처럼 "고칠 수 없는 CRITICAL" 때문에 파이프라인이 통째로 멈추는 상황을 겪을 수 있습니다. 처음엔 리포트 모드로만 돌려서 뭐가 나오는지 먼저 파악하고, 게이트는 나중에 걸어도 늦지 않습니다.
  3. 스캐너 옵션은 필요한 것만 켜기 — 기본값은 취약점 스캔에 시크릿 스캔까지 같이 돕니다. CI에서는 --scanners vuln으로 좁혀서 실행 시간을 줄였습니다.
  4. 스캔 위치는 이미지가 실제로 존재하는 런타임 기준으로 — podman으로 빌드했다고 podman 소켓을 스캔 대상으로 잡으면 안 됩니다(이 환경은 rootless podman이라 소켓 자체가 안 떠 있었습니다). k3s는 containerd로 이미지를 import한 뒤이므로, containerd 소켓/네임스페이스를 기준으로 스캔해야 합니다.

거창한 상용 보안 솔루션 없이, 오픈소스 스캐너 하나 CI 스텝에 몇 줄 추가한 것만으로 "이 배포판에 뭐가 들어있는지도 모르고 내보내는" 상태에서 벗어날 수 있었습니다. 이미 CI/CD 파이프라인이 있다면, 이미지 스캔 한 단계 끼워 넣는 데는 큰 공수가 들지 않으니 한 번 시도해볼 만합니다.


댓글 0

로그인 후 댓글을 작성할 수 있습니다.