컨테이너는 격리되어 있다는 착각이 가장 위험하다. 도커 컨테이너는 호스트 OS 커널을 공유하고, 이미지 안에 취약한 라이브러리가 숨어 있거나 root로 프로세스가 실행된다면 침해 사고가 터졌을 때 피해가 호스트 전체로 퍼질 수 있다. 이 글에서는 컨테이너 보안의 두 축인 이미지 스캔과 최소권한 원칙(Least Privilege)을 실무 예시와 함께 정리한다. DevSecOps를 처음 도입하는 팀도 바로 적용할 수 있는 수준으로 구체적으로 다룬다.
왜 이미지 스캔이 필수인가
컨테이너 이미지는 베이스 이미지 레이어 위에 애플리케이션 코드와 의존성을 쌓은 구조다. 문제는 베이스 이미지 자체에 이미 CVE(공통 취약점 및 노출)가 존재하는 경우가 많다는 점이다. 예를 들어 python:3.11 공식 이미지도 빌드 시점에 따라 수십 개의 알려진 취약점을 포함할 수 있다.
이미지 스캔 도구는 이미지의 레이어를 분해해 OS 패키지(apt, apk, rpm)와 언어별 패키지(pip, npm, go modules 등)의 버전을 CVE 데이터베이스와 대조한다. 대표적인 오픈소스 도구로는 Trivy(Aqua Security)와 grype(Anchore)가 있다.
# Trivy로 이미지 스캔 (로컬 이미지)
trivy image --severity HIGH,CRITICAL myapp:latest
# grype로 이미지 스캔
grype myapp:latest --fail-on high
# CI/CD 파이프라인 적합: SARIF 포맷 출력 (GitHub Code Scanning 연동)
trivy image --format sarif --output trivy-results.sarif myapp:latest
# 특정 CVE 무시 파일(.trivyignore) 활용
# .trivyignore
# CVE-2023-12345 # false positive, 미사용 코드 경로
Trivy는 설치가 단순하고 빠른 속도로 CI 파이프라인에 통합하기 좋다. grype는 SBOM(소프트웨어 자재 명세) 생성과 연계가 강점이다. 두 도구 모두 GitHub Actions, GitLab CI, Jenkins에 플러그인/액션이 존재한다.
스캔 결과 해석 팁: CRITICAL/HIGH 등급 CVE 중에서도 실제로 해당 코드 경로가 사용되지 않거나, 컨테이너 외부로 익스플로잇 경로가 없는 경우는 리스크가 낮다. 모든 CVE를 즉시 패치하려다 보면 배포가 막힌다. 정책을 세울 때는 ‘CVSS 9.0 이상 + 인터넷 노출 + 실제 익스플로잇 존재’ 3가지 조건을 AND로 걸어 우선순위를 정하는 방식이 현실적이다.
최소권한 원칙: Non-root 컨테이너
컨테이너 보안에서 가장 빠른 개선 효과를 얻는 방법 중 하나가 root 대신 일반 사용자로 프로세스 실행이다. 기본적으로 도커 이미지는 root(UID 0)로 실행되고, 컨테이너 탈출 취약점이 발생하면 호스트 root 권한을 얻을 수 있다.
# Dockerfile: non-root 사용자 적용 예시
FROM python:3.12-slim
# 앱 의존성 설치 (root 권한 필요 구간)
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 전용 사용자/그룹 생성
RUN groupadd --gid 10001 appgroup &&
useradd --uid 10001 --gid appgroup --no-create-home appuser
COPY --chown=appuser:appgroup . .
# 이 시점부터 non-root로 전환
USER 10001
EXPOSE 8080
CMD ["python", "app.py"]
주의할 점은 애플리케이션이 쓰기 권한이 필요한 디렉터리(로그, 임시 파일 등)를 /app/logs처럼 명시적으로 지정하고, COPY --chown으로 소유권을 미리 설정해야 한다는 것이다. 일부 레거시 이미지는 /etc/passwd 수정이나 특정 포트(1024 미만) 바인딩 때문에 root가 필요한 경우가 있으니, CAP_NET_BIND_SERVICE capability만 선택적으로 부여하는 방식으로 해결할 수 있다.
읽기 전용 파일시스템과 Capabilities 드롭
최소권한 원칙은 사용자뿐 아니라 파일시스템 접근 권한과 Linux Capabilities에도 적용된다.
읽기 전용 파일시스템: 컨테이너 루트 파일시스템을 read-only로 마운트하면 공격자가 악성 파일을 심거나 설정을 변조하기 어려워진다. 쓰기가 필요한 경로(예: /tmp, 로그 디렉터리)만 emptyDir 볼륨으로 별도 마운트한다.
Capabilities 드롭: Linux 커널은 root 권한을 세분화한 Capabilities를 제공한다. 컨테이너에는 기본적으로 여러 capabilities가 부여되는데, 대부분의 웹 애플리케이션에는 불필요하다. ALL을 drop하고 필요한 것만 add하는 것이 원칙이다.
# Kubernetes securityContext 예시 (deployment.yaml)
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: myapp
image: myapp:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE # 1024 미만 포트 바인딩이 필요한 경우만
volumeMounts:
- name: tmp-dir
mountPath: /tmp
- name: log-dir
mountPath: /app/logs
volumes:
- name: tmp-dir
emptyDir: {}
- name: log-dir
emptyDir: {}
시크릿 관리: 이미지에 비밀을 담지 말 것
이미지 스캔의 또 다른 중요한 역할은 하드코딩된 시크릿 탐지다. DB 비밀번호, API 키, 인증서 등이 Dockerfile이나 소스 코드에 그대로 박혀 이미지가 빌드되는 사례가 실무에서 빈번하다. 이런 이미지가 레지스트리에 푸시되면 이미지를 pull할 수 있는 누구나 시크릿을 추출할 수 있다.
| 시크릿 주입 방식 | 보안 수준 | 운영 복잡도 | 권장 환경 |
|---|---|---|---|
| 이미지에 하드코딩 | 매우 낮음 | 낮음 | 절대 사용 금지 |
| 환경 변수 (평문) | 낮음 | 낮음 | 로컬 개발 한정 |
| Kubernetes Secret | 중간 | 중간 | 기본 K8s 환경 |
| Sealed Secrets / External Secrets | 높음 | 중간 | GitOps 환경 |
| Vault Agent / CSI Driver | 매우 높음 | 높음 | 엔터프라이즈 |
Kubernetes Secret은 기본적으로 etcd에 base64 인코딩(암호화 아님)으로 저장된다. 프로덕션에서는 etcd 암호화(EncryptionConfiguration)를 활성화하거나, HashiCorp Vault나 AWS Secrets Manager 같은 외부 시크릿 관리 도구와 연동하는 것이 권장된다. 빌드 타임에 시크릿이 필요한 경우(예: 사설 npm 레지스트리 토큰)는 Docker BuildKit의 --secret 플래그를 사용해 레이어에 흔적이 남지 않도록 해야 한다.
실전 팁: CI/CD에 보안 게이트 심기
컨테이너 보안은 개발자가 신경 쓰지 않아도 파이프라인이 자동으로 잡아내는 구조가 이상적이다. GitHub Actions 예시를 참고하자.
# .github/workflows/container-security.yml
name: Container Security Scan
on:
push:
branches: [main, dev]
pull_request:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
exit-code: 1 # CRITICAL/HIGH 발견 시 파이프라인 실패
- name: Upload Trivy scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
- name: Lint Dockerfile with Hadolint
uses: hadolint/[email protected]
with:
dockerfile: Dockerfile
exit-code: 1을 설정하면 CRITICAL/HIGH CVE가 발견됐을 때 파이프라인이 실패하며 머지를 막는다. 단, 처음 도입할 때는 exit-code: 0으로 모니터링 모드로 운영하면서 기존 취약점 목록(.trivyignore)을 정리한 뒤 점진적으로 강화하는 것이 실용적이다.
마무리
컨테이너 보안은 한 번에 완성되는 것이 아니라 레이어를 쌓아가는 과정이다. 이미지 스캔으로 알려진 취약점을 차단하고, non-root + readOnlyRootFilesystem + capabilities drop으로 공격 표면을 줄이며, 시크릿을 코드와 분리하는 세 가지를 먼저 적용하면 보안 수준이 눈에 띄게 올라간다. 여기에 네트워크 정책, Pod Security Standards, OPA/Gatekeeper 정책을 순차적으로 더하면 심층 방어 체계가 완성된다. 완벽한 보안은 없지만, 공격자가 포기하게 만드는 것이 목표다.
자주 묻는 질문
Q. Trivy와 grype 중 어떤 것을 먼저 써야 하나요?
A. CI 파이프라인 통합 속도를 우선시한다면 Trivy를 권장합니다. 설치가 단순하고(바이너리 하나), GitHub Actions 공식 액션이 잘 관리되어 있으며, OS·언어 패키지를 모두 스캔합니다. SBOM 기반 워크플로우가 필요하거나 Anchore Enterprise와 연동 계획이 있다면 grype를 검토하세요. 두 도구를 함께 돌리면 탐지 커버리지가 높아지지만, 파이프라인 속도와의 트레이드오프를 고려해야 합니다.
Q. readOnlyRootFilesystem을 설정하면 애플리케이션이 깨지지 않나요?
A. 쓰기 작업이 필요한 경로를 파악하지 않고 바로 적용하면 깨집니다. 먼저 readOnlyRootFilesystem: false 상태로 컨테이너를 실행한 뒤 strace나 Falco 같은 도구로 쓰기 시스템 콜이 발생하는 경로를 수집하세요. 그 경로만 emptyDir 볼륨으로 마운트하고 나서 true로 전환하면 대부분의 애플리케이션이 정상 동작합니다. 로그를 파일이 아닌 stdout/stderr로 출력하도록 설정 변경도 병행하면 훨씬 깔끔해집니다.
Q. Kubernetes Secret은 얼마나 안전한가요?
A. 기본 설정에서는 etcd에 base64 인코딩(암호화 아님)으로 저장되므로, etcd 접근 권한이 있으면 평문으로 읽힙니다. 최소한 etcd 암호화(EncryptionConfiguration)를 활성화하고, RBAC으로 Secret 조회 권한을 최소화해야 합니다. 규제 환경이나 민감 데이터를 다룬다면 Vault나 AWS Secrets Manager 같은 전문 시크릿 관리 도구를 도입하는 것이 안전합니다.