빌드, 스캔, 서명, 배포, 런타임 모니터링까지 전체 컨테이너 보안 파이프라인을 GitHub Actions와 쿠버네티스 기반으로 통합 구축하는 실전 프로젝트입니다.
이번 장에서는 간단한 웹 애플리케이션을 대상으로, 빌드부터 런타임까지 완전한 보안 파이프라인을 구축합니다. 이 시리즈에서 다룬 도구와 개념을 하나로 엮는 통합 프로젝트입니다.
secure-app/
cmd/
server/
main.go
Dockerfile
.github/
workflows/
secure-pipeline.yml
k8s/
deployment.yaml
networkpolicy.yaml
kyverno-policy.yaml
falco-rules.yaml
.trivyignore2장에서 배운 멀티스테이지 빌드, 최소 베이스 이미지, 루트 없는 컨테이너를 모두 적용합니다.
# syntax=docker/dockerfile:1
# === 빌드 스테이지 ===
FROM golang:1.22-alpine@sha256:ace6cc3fe58d0c7b12303c57afe6d6724851152df55e08b713571e8a3e2e3753 AS builder
RUN apk add --no-cache ca-certificates
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w -extldflags=-static" \
-o /app/server ./cmd/server
# === 런타임 스테이지 ===
FROM gcr.io/distroless/static-debian12:nonroot@sha256:8dd8d3ca2cf283383304fd45a5c9c74d5f2cd9da8d3b077d720e264880077c65
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]핵심 보안 포인트를 정리합니다.
@sha256:...를 명시하여 이미지 변조를 방지합니다.3장(스캐닝), 4장(SBOM), 5장(서명)에서 배운 내용을 하나의 워크플로우로 통합합니다.
name: Secure Container Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs:
# === 빌드 및 보안 검증 ===
build-scan-sign:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
security-events: write
outputs:
image-digest: ${{ steps.build.outputs.digest }}
steps:
# 1. 체크아웃
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# 2. 도구 설치
- name: Install Cosign
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0
- name: Install Syft
uses: anchore/sbom-action/download-syft@78fc58e266e87a38d4194b2137a3d4e9bcaf7ca1 # v0.17.0
# 3. 이미지 빌드 및 푸시
- name: Login to GHCR
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 # v5.1.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: |
${{ env.IMAGE_NAME }}:${{ github.sha }}
${{ env.IMAGE_NAME }}:latest
# 4. 취약점 스캐닝 (Trivy)
- name: Trivy vulnerability scan
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE_NAME }}:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: "1"
ignore-unfixed: true
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
# 5. SBOM 생성
- name: Generate SBOM
if: github.event_name != 'pull_request'
run: |
syft ${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} \
-o cyclonedx-json=sbom-cyclonedx.json \
-o spdx-json=sbom-spdx.json
- name: Upload SBOM artifacts
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: sbom-${{ github.sha }}
path: |
sbom-cyclonedx.json
sbom-spdx.json
retention-days: 90
# 6. 이미지 서명 (Cosign Keyless)
- name: Sign image
if: github.event_name != 'pull_request'
run: |
cosign sign --yes \
${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
# 7. SBOM 첨부 및 서명
- name: Attach and sign SBOM
if: github.event_name != 'pull_request'
run: |
cosign attach sbom \
--sbom sbom-cyclonedx.json \
${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
cosign sign --yes \
--attachment sbom \
${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
# 8. 보안 검증 요약
- name: Security summary
if: always()
run: |
echo "=== 보안 파이프라인 요약 ==="
echo "이미지: ${{ env.IMAGE_NAME }}:${{ github.sha }}"
echo "다이제스트: ${{ steps.build.outputs.digest }}"
echo "취약점 스캔: 완료"
echo "SBOM 생성: 완료"
echo "이미지 서명: 완료"
echo "SBOM 서명: 완료"모든 서드파티 GitHub Actions의 버전을 커밋 해시로 고정했습니다. 9장에서 배운 공급망 공격 방어의 기본 원칙입니다. 해시 옆의 주석으로 실제 버전을 표기하면 가독성을 유지할 수 있습니다.
2장(SecurityContext)과 8장(시크릿 관리)의 내용을 반영합니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: secure-app
namespace: production
labels:
app: secure-app
spec:
replicas: 3
selector:
matchLabels:
app: secure-app
template:
metadata:
labels:
app: secure-app
monitoring: enabled
spec:
serviceAccountName: secure-app-sa
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: ghcr.io/myorg/secure-app@sha256:abc123...
ports:
- containerPort: 8080
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
volumeMounts:
- name: tmp
mountPath: /tmp
- name: secrets
mountPath: /etc/app/secrets
readOnly: true
volumes:
- name: tmp
emptyDir:
sizeLimit: 10Mi
- name: secrets
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: secure-app-secrets핵심 보안 설정을 정리합니다.
@sha256:...로 이미지를 지정합니다.7장에서 배운 기본 거부 정책과 최소 허용 규칙을 적용합니다.
# 기본 거부 — 인그레스
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: production
spec:
podSelector: {}
policyTypes:
- Ingress
---
# 기본 거부 — 이그레스
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-egress
namespace: production
spec:
podSelector: {}
policyTypes:
- Egress
---
# DNS 이그레스 허용
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: production
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# Ingress 컨트롤러에서 secure-app으로의 인그레스 허용
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-app
namespace: production
spec:
podSelector:
matchLabels:
app: secure-app
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx
ports:
- protocol: TCP
port: 80805장(서명 검증)과 9장(공급망 방어)의 내용을 적용합니다.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: secure-app-policy
spec:
validationFailureAction: Enforce
background: true
rules:
# 허용된 레지스트리만 사용
- name: restrict-registries
match:
any:
- resources:
kinds:
- Pod
namespaces:
- production
validate:
message: "production 네임스페이스에서는 ghcr.io/myorg/ 이미지만 허용됩니다."
pattern:
spec:
containers:
- image: "ghcr.io/myorg/*"
# 이미지 서명 검증
- name: verify-image-signature
match:
any:
- resources:
kinds:
- Pod
namespaces:
- production
verifyImages:
- imageReferences:
- "ghcr.io/myorg/*"
attestors:
- entries:
- keyless:
subject: "https://github.com/myorg/*"
issuer: "https://token.actions.githubusercontent.com"
rekor:
url: "https://rekor.sigstore.dev"
# 보안 컨텍스트 필수
- name: require-security-context
match:
any:
- resources:
kinds:
- Pod
namespaces:
- production
validate:
message: "runAsNonRoot, readOnlyRootFilesystem 설정이 필수입니다."
pattern:
spec:
containers:
- securityContext:
runAsNonRoot: true
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false6장에서 배운 런타임 감지 규칙을 프로젝트에 적용합니다.
customRules:
secure-app-rules.yaml: |-
- rule: Shell in secure-app container
desc: secure-app 컨테이너에서 셸 실행 감지
condition: >
spawned_process and
container and
k8s.ns.name = "production" and
k8s.deployment.name = "secure-app" and
proc.name in (sh, bash, ash, zsh)
output: >
secure-app 컨테이너에서 셸 실행 감지
(user=%user.name command=%proc.cmdline
pod=%k8s.pod.name namespace=%k8s.ns.name)
priority: CRITICAL
tags: [secure-app, shell_access]
- rule: Unexpected network connection from secure-app
desc: secure-app에서 예상치 못한 외부 네트워크 연결
condition: >
outbound and
container and
k8s.deployment.name = "secure-app" and
not fd.sip.name in (allowed_dns_servers) and
not fd.sport in (8080, 443, 53)
output: >
secure-app에서 예상치 못한 외부 연결
(command=%proc.cmdline connection=%fd.name
pod=%k8s.pod.name)
priority: WARNING
tags: [secure-app, network]
- rule: File write attempt in secure-app
desc: 읽기 전용 파일시스템에서의 쓰기 시도
condition: >
open_write and
container and
k8s.deployment.name = "secure-app" and
not fd.name startswith /tmp
output: >
secure-app에서 파일 쓰기 시도
(file=%fd.name command=%proc.cmdline
pod=%k8s.pod.name)
priority: ERROR
tags: [secure-app, filesystem]Distroless 이미지를 사용하므로 셸 실행 규칙이 발동하면 이는 거의 확실한 침해 징후입니다. Distroless에는 셸이 없으므로, 셸이 실행되었다는 것은 공격자가 별도로 주입했다는 의미이기 때문입니다.
모든 단계를 통합한 전체 보안 파이프라인입니다.
운영 중인 보안 상태를 한눈에 파악하기 위한 대시보드 구성입니다.
| 메트릭 | 데이터 소스 | 시각화 도구 |
|---|---|---|
| 이미지 취약점 현황 | Trivy | Grafana |
| 서명 검증 결과 | Kyverno PolicyReport | Grafana |
| Falco 알림 수 | Falcosidekick | Grafana |
| 차단된 네트워크 연결 | Hubble (Cilium) | Grafana |
| SBOM 의존성 현황 | Dependency-Track | 자체 UI |
| 시크릿 로테이션 상태 | Vault | Vault UI |
# Kyverno PolicyReport를 Prometheus로 내보내기
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: kyverno-metrics
namespace: monitoring
spec:
selector:
matchLabels:
app.kubernetes.io/name: kyverno
endpoints:
- port: metrics
interval: 30s
path: /metrics이 시리즈의 전체 내용을 체크리스트로 정리합니다.
이 체크리스트의 모든 항목을 한 번에 적용할 필요는 없습니다. 현재 보안 수준을 평가하고, 가장 영향이 큰 항목부터 단계적으로 적용하세요. 빌드 시점 보안(스캐닝, 서명)부터 시작하는 것을 권장합니다.
10장에 걸쳐 컨테이너 보안과 공급망 무결성의 전 영역을 다루었습니다.
1장에서 위협 모델을 이해하는 것으로 시작하여, 이미지 보안 기초(2장), 취약점 스캐닝(3장), SBOM(4장), 이미지 서명(5장)으로 빌드 시점 보안을 다졌습니다. 이어서 런타임 감지(6장), 네트워크 정책(7장), 시크릿 관리(8장)로 운영 시점 보안을 강화했고, 공급망 공격 방어(9장)로 전체적인 방어 전략을 완성했습니다.
보안은 목적지가 아니라 여정입니다. 새로운 위협은 계속 등장하고, 도구와 프레임워크도 끊임없이 발전합니다. 이 시리즈에서 구축한 보안 파이프라인을 기반으로, 자신의 환경에 맞게 지속적으로 개선해 나가시기 바랍니다.
핵심 원칙을 다시 한번 상기합니다.
이 글이 도움이 되셨나요?
의존성 혼동, 타이포스쿼팅, CI 침투 등 공급망 공격 유형을 분석하고, SLSA 프레임워크와 어드미션 컨트롤러 기반의 제로 트러스트 방어 전략을 구축합니다.
쿠버네티스 Secrets의 한계를 이해하고, HashiCorp Vault, External Secrets Operator, Sealed Secrets로 안전한 시크릿 관리 체계를 구축합니다.
쿠버네티스 NetworkPolicy로 기본 거부 정책을 구현하고, Calico/Cilium 네트워크 정책과 Istio mTLS로 컨테이너 간 통신을 안전하게 제어합니다.