🚀 요약

SUMMARY

고객사에 솔루션을 반입할 때마다 컨테이너 이미지에 알려진 취약점(CVE)을 검사하는 프로세스를 진행해야 했다. 매 반입마다 되풀이되는 일이라 그때그때 처리하지 않고 프로세스로 굳혔다. trivy로 스캔하고, 무작정 버전을 올리는 대신 공식 업그레이드→하드닝 이미지→직접 빌드→대체 이미지→추적(no-fix)으로 이어지는 대응 사다리를 따르며, 결정을 원장에 남긴다.

우리가 만든 이미지가 아니라 가져다 쓰는 OSS 이미지가 대상이다. CVE 하나에 무작정 최신 버전을 올렸다간 breaking change나 라이선스 문제가 튀어나온다.

1. 왜 프로세스가 필요했나

고객사에 솔루션을 반입할 때 항상 거치는 절차가 있는데, 바로 이미지 취약점(CVE) 검사 및 대응이다. 기존까지는 고객사 프로젝트 담당자가 직접 처리하는 방식으로 진행하다보니 대응 방식이 제각기 달라지고 히스토리 또한 제대로 유지되지 못했다. 때문에 대응 방식을 공통된 방식으로 일원화하고 기록 또한 유지하는 프로세스가 필요하다고 판단했다.

그래서 세 가지를 고정했다 — 무엇을 스캔하는지, 어떤 순서로 대응을 시도하는지, 결정을 어디에 남기는지.

2. 무엇을 스캔하고 결과를 어떻게 읽나

배포하는 이미지 목록을 뽑아 각각 trivy image로 스캔한다. OS 패키지(apt/apk)와 언어 패키지(Go 바이너리·npm·pip 등)를 다 본다. 기본은 CRITICAL만, 필요하면 HIGH까지 넓힌다. 이미지별 상태는 대략 ok(0건)·vuln(검출)·failed(pull 실패)로 갈린다. failed는 대개 사설 레지스트리라 인증이 필요한 경우다(로그인 후 재시도).

vuln으로 뜬 CVE에서 제일 먼저 보는 건 fixed version이 있느냐다.

  • fixed 있음 → 그 버전 이상으로 올리면 해소된다. 대응의 출발점.
  • fixed 없음(no fix) → 업스트림이 아직 안 고친 것. 올려도 안 사라진다. 억지로 뭘 하기보다 추적 대상으로 관리한다.

스캔 결과는 시점 산출물이라는 걸 늘 염두에 둔다. CVE 데이터베이스가 갱신되면 어제 깨끗하던 이미지가 오늘 취약으로 뜬다. 그래서 스캔 결과 자체는 회차별로 쌓되 git엔 안 올리고, 대응 결정만 원장에 남긴다.

(내가 짠 Go 코드와 그 의존성의 취약점은 결이 다른 문제라 govulncheck·gosec으로 따로 본다 — 여기. 이 글은 가져다 쓰는 OSS 이미지 얘기다.)

3. 대응 사다리

원칙은 하나다. 무작정 최신으로 올리지 않는다. 후보를 정해 각각 trivy로 스캔해서 CVE가 실제로 사라지는 최선 옵션을 고른다. 위에서부터 시도하고, 막히면 한 칸 내려간다.

1. 공식 업그레이드   ── 후보(최신 stable·LTS·동일 메이저 최신 패치)를 스캔 → 해소되면 채택
       │ (breaking / license / no-fix 로 막히면)
2. 하드닝 이미지     ── 벤더가 미리 취약점을 줄여둔 대체 이미지를 스캔 → 해소되면 채택
       │ (미수록이거나 여전히 안 되면)
3. self-built        ── upstream 기반 + 취약 패키지만 패치한 Dockerfile → 빌드 → 재스캔으로 검증
       │ (라이선스·재컴파일 제약으로 막히면)
4. replace           ── 유지보수되는 대체 이미지로 갈아탐 (EOL 이미지 탈출)
       │ (전부 소진되면)
5. no-fix            ── 업스트림 미해결. 근거·잔여 CVE·도달성을 적어 추적

1. 공식 업그레이드. 실험적 버전은 피하고 LTS·동일 메이저 최신 패치를 우선한다. 새 메이저로 점프하면 CVE는 사라져도 breaking change가 딸려오기 쉽다. 후보를 스캔해 해소되고 breaking·라이선스 이슈가 없으면 여기서 끝낸다.

3. self-built. 1·2로 안 되면 직접 빌드한다. 이미지 전체를 새로 짜는 게 아니라, upstream 이미지를 베이스로 두고 취약한 바이너리 하나만 걷어내는 식이다. 예를 들어 어떤 공식 DB 이미지의 CVE가 DB 본체가 아니라 동봉된 gosu의 Go stdlib에서 나온 적이 있다. 그럴 땐 본체·entrypoint·UID·볼륨은 그대로 두고, 취약한 gosu만 OS 기본 유틸(setpriv) 기반 호환 wrapper로 교체해 최종 이미지에서 제외했다. 그리고 재스캔으로 CVE가 실제로 사라졌는지 검증한 뒤에야 채택한다.

4. replace. 문제가 이미지 하나가 아니라 계열 전체가 EOL이면 갈아탄다. 널리 쓰이던 어떤 벤더 이미지 묶음이 유지보수 종료(legacy/동결)로 전환된 적이 있는데, 이 경우 개별 CVE를 하나씩 잡는 건 밑 빠진 독이다. 유지보수되는 공식 이미지로 차트째 마이그레이션하는 게 답이었다.

5. no-fix. 사다리를 다 소진해도 안 되는 게 있다. 업스트림이 자체 코드의 CVE를 아직 안 고쳤거나, fix는 있지만 바이너리에 내장된 의존성이라 소스 재컴파일이 필요한데 그게 라이선스(예: AGPL 소스 공개 의무)를 건드리는 경우다. 이럴 땐 억지로 건드리지 않고, 왜 못 고치는지·잔여 CVE·실제 도달 가능성(예: 폐쇄망이라 해당 경로가 노출 안 됨)을 근거와 함께 적어 추적으로 남긴다.

4. 하드닝 이미지(DHI)를 언제 쓸까 — 잠정 결론

사다리 2단의 하드닝 이미지는 처음에 제일 기대했던 카드였다. 벤더가 취약점을 미리 깎아둔 이미지(예: Docker Hardened Image)를 최대한 갖다 쓰면 self-built 수고가 없을 줄 알았다. 실제로 굴려보니 공짜가 아니었다.

  • 실행 UID가 강제되는 경우가 있다. 하드닝 이미지가 특정 non-root UID로 고정돼 있어서, 기존 배포의 데이터 볼륨(PVC) 소유권과 안 맞으면 그대로는 못 바꾼다. (CRITICAL 0건짜리 대체 이미지가 있었는데도, UID가 달라 기존 볼륨과 충돌해 단순 교체가 불가능했던 적이 있다.)
  • 일부 패키지가 빠져 있다. 공격면을 줄이려 최소 구성으로 깎다 보니, 원본 이미지가 쓰던 유틸·라이브러리가 없어 동작이 깨지기도 한다.

그래서 잠정 결론은 이렇다. 기능이 단순하고 볼륨 마운트가 없는(상태를 안 들고 UID·소유권 이슈가 없는) 이미지에만 하드닝 이미지를 쓴다. 상태를 들고 볼륨을 붙이거나 부가 패키지에 의존하는 이미지는, 하드닝 이미지로 갈아타기보다 공식 업그레이드나 self-built로 가는 편이 사고가 적었다. DHI를 “만능 치트키”로 밀지 않고 적용 조건을 좁힌 게 이번 프로세스의 실무 교훈이다.

5. 결정을 원장에 남기기

반복 업무를 프로세스로 만드는 핵심은 결국 기록이다. 스캔 결과는 시점마다 바뀌지만, “이 CVE를 이렇게 대응했다”는 결정은 남아야 한다. 그래서 이미지 단위의 원장(YAML)을 두고 항목마다 이렇게 적는다.

  • 대응 전 이미지 ref, 사용처(어떤 차트들이 공유하는지)
  • 대상 CVE 목록
  • 대응 방식(upgrade / hardened / self-built / replace / no-fix)과 결과 ref
  • 사유(breaking·마이그레이션·라이선스·잔여 CVE)와 근거 URL

이게 있으면 반년 뒤 같은 이미지가 다시 취약으로 떠도, 다른 고객사에 같은 이미지를 반입해도 “저번에 왜 이렇게 했는지”를 다시 조사하지 않아도 된다. 여러 차트가 같은 이미지를 공유할 때 한 번 대응하고 사용처 전부에 반영하는 것도 원장 덕에 놓치지 않는다.

남은 것

반복되는 CVE 대응을 사다리와 원장으로 굳히고 나니, 반입 때마다 헤매던 일이 “스캔 → 사다리 적용 → 원장 기록”의 절차가 됐다. CVE 대응은 “버전 올리기”가 아니라 의사결정이라, 어디까지 시도하고 어디서 멈출지를 미리 정해둔 게 컸다. 다음은 스캔·원장 기록을 CI로 더 자동화하고, self-built 이미지의 빌드·검증을 파이프라인에 얹는 것이다.

🔗 참고