Published on

GitHub Actions로 Docker Buildx·SBOM·이미지 서명

Authors

서플라이 체인 보안이 기본 요구사항이 된 지금, 컨테이너 이미지는 “잘 빌드된다”를 넘어 “무엇으로 구성됐는지 설명(SBOM)”하고 “누가/어떻게 만들었는지 증명(서명)”해야 합니다. 이 글에서는 GitHub Actions에서 Docker Buildx로 멀티아키텍처 이미지를 빌드하고, SBOM을 생성해 아티팩트로 남기며, Cosign으로 이미지에 서명하는 흐름을 한 번에 묶어봅니다.

특히 다음을 목표로 합니다.

  • Docker Buildx로 linux/amd64, linux/arm64 멀티플랫폼 이미지 빌드 및 푸시
  • 빌드 결과에 대한 SBOM 생성 및 보관(아티팩트/릴리스/레지스트리)
  • OIDC 기반 키리스(keyless) Cosign 서명으로 키 관리 부담 최소화
  • 재현 가능한 빌드와 캐시 최적화로 CI 시간을 줄이기

관련해서 “작은 설정 차이”가 큰 장애로 이어지는 경우가 많습니다. 예를 들어 리소스 누수나 FD 고갈처럼 CI 러너에서 간헐적으로 터지는 문제도 있으니, 빌드가 불안정하다면 Linux EMFILE(Too many open files) 원인과 해결 같은 운영 이슈도 함께 점검해두면 좋습니다.

전체 아키텍처: Buildx + SBOM + Sign

파이프라인을 단계로 쪼개면 다음과 같습니다.

  1. 체크아웃
  2. QEMU 설정(멀티아키텍처 필요 시)
  3. Buildx 빌더 준비
  4. 레지스트리 로그인(GHCR 등)
  5. Buildx로 빌드 및 푸시
  6. SBOM 생성(이미지 또는 소스 기준)
  7. Cosign 서명(키리스 OIDC)
  8. 검증(서명 검증, SBOM 확인)

여기서 중요한 포인트는 “서명은 푸시된 다이제스트에 대해 수행”해야 한다는 점입니다. 태그는 가변적이지만 다이제스트는 불변이기 때문에, 서명/검증 모두 다이제스트 기반이 안전합니다.

준비물

  • GitHub Container Registry(GHCR) 또는 Docker Hub 등 레지스트리
  • GitHub Actions 권한 설정(permissions)
  • Cosign 사용(키리스 서명 시 OIDC 토큰 필요)
  • SBOM 도구(예: Syft)

이 글은 GHCR 기준으로 설명합니다.

Dockerfile 예시: 캐시와 보안 기본기

Node.js 애플리케이션을 예시로 간단한 멀티스테이지 Dockerfile을 작성합니다.

# syntax=docker/dockerfile:1.7

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# 보안: 불필요한 권한 제거(가능하면 non-root)
RUN addgroup -S app && adduser -S app -G app
USER app

COPY --from=build /app/dist ./dist
COPY package.json ./

CMD ["node", "dist/index.js"]
  • # syntax=docker/dockerfile:1.7 는 BuildKit 기능(캐시 마운트 등)을 안정적으로 쓰기 위한 선언입니다.
  • --mount=type=cache 로 CI에서 의존성 설치 시간을 줄일 수 있습니다.

GitHub Actions 워크플로: 실전 구성

아래 워크플로는 다음을 수행합니다.

  • main 브랜치 푸시 시 latest와 커밋 SHA 태그를 함께 푸시
  • linux/amd64, linux/arm64 멀티아키텍처 빌드
  • Syft로 SBOM 생성 후 아티팩트 업로드
  • Cosign으로 이미지 다이제스트에 키리스 서명

주의: 본문에서 -> 같은 기호도 MDX에서 문제가 될 수 있으니 코드 블록 안에만 두었습니다.

name: build-sbom-sign

on:
  push:
    branches: ["main"]

permissions:
  contents: read
  packages: write
  id-token: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=raw,value=latest
            type=sha

      - name: Build and push (multi-arch)
        id: build
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: true

      - name: Install Syft
        uses: anchore/sbom-action/download-syft@v0

      - name: Generate SBOM (SPDX JSON)
        run: |
          IMAGE_REF="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}"
          syft "$IMAGE_REF" -o spdx-json=sbom.spdx.json

      - name: Upload SBOM artifact
        uses: actions/upload-artifact@v4
        with:
          name: sbom
          path: sbom.spdx.json

      - name: Install Cosign
        uses: sigstore/cosign-installer@v3
        with:
          cosign-release: "v2.2.4"

      - name: Cosign sign (keyless)
        env:
          COSIGN_EXPERIMENTAL: "true"
        run: |
          IMAGE_REF="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}"
          cosign sign --yes "$IMAGE_REF"

      - name: Verify signature
        env:
          COSIGN_EXPERIMENTAL: "true"
        run: |
          IMAGE_REF="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}"
          cosign verify \
            --certificate-identity "https://github.com/${{ github.repository }}/.github/workflows/build-sbom-sign.yml@refs/heads/main" \
            --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
            "$IMAGE_REF"

워크플로 핵심 설명

  • permissions.id-token: write

    • GitHub OIDC 토큰 발급에 필요합니다. 키리스 서명은 이 토큰을 통해 “누가 서명했는지”를 증명합니다.
  • docker/build-push-action@v6outputs.digest

    • 푸시된 이미지의 콘텐츠 주소(다이제스트)입니다.
    • 서명은 반드시 ${image}@${digest} 형태로 고정된 대상에 수행해야 합니다.
  • provenance: true

    • BuildKit provenance(출처/빌드 정보) 생성에 도움 됩니다. SLSA 관점에서도 유리합니다.
  • SBOM 생성 위치

    • 예시는 “레지스트리에 푸시된 이미지”를 대상으로 SBOM을 뽑습니다.
    • 대안으로는 소스 디렉터리 기준 SBOM도 가능하지만, 최종 이미지와 일치성이 떨어질 수 있습니다.

SBOM을 어디에 저장할 것인가

SBOM은 “만들었다”보다 “어디서 쉽게 찾을 수 있나”가 더 중요합니다. 선택지는 보통 3가지입니다.

  1. GitHub Actions artifact

    • 가장 간단하지만 배포 파이프라인과 연결성이 약합니다.
  2. GitHub Release 자산

    • 버전 릴리스 중심 운영이면 좋습니다.
  3. OCI Artifact로 레지스트리에 첨부

    • 이미지와 함께 이동하므로 운영에 가장 실용적입니다.

OCI Artifact로 올리려면 도구 체인이 필요합니다(예: ORAS, cosign의 attestation 기능, 또는 레지스트리의 SBOM 지원). 팀의 운영 성숙도에 맞춰 단계적으로 도입하는 편이 좋습니다.

Cosign 키리스 서명: 운영 관점에서의 장점과 주의점

키리스는 “키 파일을 비밀로 보관”하는 부담을 크게 줄입니다.

  • 장점

    • 키 유출 위험 감소
    • 키 회전/보관 정책 단순화
    • GitHub Actions의 워크플로 정체성으로 서명 주체를 고정 가능
  • 주의점

    • 검증 시 certificate-identity 를 워크플로 파일 경로와 브랜치까지 포함해 정확히 고정해야 합니다.
    • 워크플로 파일을 이동하거나 이름을 바꾸면 검증 정책도 함께 갱신해야 합니다.

실무에서는 “특정 리포지토리의 특정 워크플로가 main에서 실행된 결과만 신뢰” 같은 정책을 두고, 프로덕션 배포 단계에서 cosign verify 를 게이트로 걸어두는 방식이 흔합니다.

멀티아키텍처 빌드에서 자주 터지는 이슈

QEMU로 인한 빌드 속도 저하

linux/arm64 를 x86 러너에서 에뮬레이션하면 느려질 수 있습니다.

  • 해결
    • 가능하면 네이티브 arm64 러너를 별도 운영
    • 빌드 캐시(type=gha) 적극 사용

캐시가 안 먹는 문제

  • npm ci 같은 단계는 package-lock.json 변경에 민감합니다.
  • Dockerfile에서 COPY . . 이전에 의존성 정의 파일만 먼저 복사해야 캐시 효율이 유지됩니다.

간헐적 실패와 리소스 문제

빌드/스캔/서명 도구가 한꺼번에 돌면 파일 핸들, 네트워크 소켓, 프로세스 수가 늘어나는 경우가 있습니다. 러너에서 간헐적으로 EMFILE 류가 뜬다면 OS 제한을 의심해볼 만합니다. 관련 트러블슈팅은 Linux EMFILE(Too many open files) 원인과 해결도 참고하세요.

로컬에서 검증하기

CI에서 생성한 결과를 로컬에서도 재현 가능하게 점검하는 습관이 중요합니다.

서명 검증

cosign verify \
  --certificate-identity "https://github.com/OWNER/REPO/.github/workflows/build-sbom-sign.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/OWNER/REPO@sha256:YOUR_DIGEST

SBOM 확인

SPDX JSON은 크고 복잡하니, 필요한 정보만 추려보는 편이 빠릅니다.

cat sbom.spdx.json | jq '.packages | length'

운영에 바로 쓰는 체크리스트

  • 이미지 태그가 아니라 다이제스트를 기준으로 서명/검증하는가
  • permissionsid-token: write 가 설정되어 있는가
  • 빌드 캐시(cache-to, cache-from)가 켜져 있는가
  • SBOM이 “어딘가에 남는가”가 아니라 “배포/운영에서 찾기 쉬운 위치”에 저장되는가
  • 배포 단계에서 cosign verify 를 필수 게이트로 두었는가

마무리

Docker Buildx로 멀티아키텍처 이미지를 안정적으로 빌드하고, SBOM으로 구성 요소를 투명하게 공개하며, Cosign 키리스 서명으로 신뢰 체인을 확보하면 “배포 가능한 컨테이너”의 기준이 한 단계 올라갑니다. 처음부터 모든 것을 완벽히 하려 하기보다, 우선 CI에서 다이제스트 기반 서명과 SBOM 아카이빙을 자동화하고, 이후 OCI Artifact 첨부나 배포 게이트 강화로 확장하는 접근이 현실적입니다.

추가로, CI 파이프라인이 복잡해질수록 사소한 설정 오류가 큰 장애로 이어질 수 있습니다. 이런 류의 디버깅 감각은 JSON 스키마나 자동화 도구에서도 동일하게 중요하니, 자동화 실패를 다루는 관점에서는 Claude Tool Use 400 에러 - JSON Schema 디버깅도 함께 읽어두면 도움이 됩니다.