Workload Identity를 사용한 GCP에 대한 키 없는 GitHub Actions 배포
서비스 계정 JSON 키를 GitHub Secrets에 붙여넣는 것을 멈추세요. Workload Identity Federation은 Actions가 수명이 짧은 OIDC 토큰으로 GCP에 인증할 수 있게 해줍니다.
GitHub Actions가 Google Cloud에 배포하도록 하는 일반적인 방법은 서비스 계정을 만들고, 해당 계정의 JSON 키를 다운로드한 다음, 그 키를 GitHub Secret에 붙여넣는 것입니다. 이 방법은 처음 시도할 때 바로 작동하기 때문에 널리 퍼져 있습니다. 또한 이 방법은 만료 기간이 없고 감사 추적이 쉽지 않은 수명이 긴 자격 증명을 사용자가 완전히 제어할 수 없는 시스템에 전달합니다. Workload Identity Federation은 키를 완전히 제거합니다. GitHub Actions는 모든 실행마다 서명된 OIDC 토큰을 이미 발급하며, GCP가 특정 리포지토리와 브랜치에 대해 해당 토큰을 신뢰하도록 설정할 수 있습니다. 키가 생성되지 않으므로 키가 유출되거나, 교체되거나, 비밀 저장소에 잊힌 채로 방치될 수 없습니다. 이 게시물에서는 전체 설정 방법, 보안을 좌우하는 한 가지 속성 조건, 그리고 여전히 일반 키를 사용하는 것이 더 빠른 경우를 보여줍니다.
시크릿에 있는 서비스 계정 키의 문제점
서비스 계정 키는 무기명 자격증명(bearer credential)입니다. JSON 파일을 가지고 있는 사람은 누구나 누군가 키를 삭제할 때까지 해당 서비스 계정으로 활동할 수 있으며, 기본적으로 만료되지 않습니다. 그 단 하나의 사실이 대부분의 고통을 유발합니다.
손상된 종속성, 잘못된 범위의 로그, 시크릿 접근 권한이 있는 포크 또는 노트북 백업을 통해 키가 유출되면, 공격자는 키가 살아있는 동안 배포 ID를 갖게 됩니다. 호출이 CI의 호출과 동일하게 보이기 때문에 종종 그 사실을 알지 못할 것입니다. 교체는 아무도 원하지 않는 수동 작업입니다. 새 키를 생성하고, 시크릿을 업데이트하고, 아무것도 손상되지 않았는지 확인한 다음, 이전 키를 삭제해야 합니다. 팀들은 이를 건너뛰므로 키가 누적됩니다. 감사 또한 취약합니다. 키는 어디에서 사용되었는지 기록하지 않으므로, 오직 CI만이 해당 키로 호출했다는 것을 쉽게 증명할 수 없습니다.
이 중 어느 것도 키 형식의 결함이 아닙니다. 정적 자격증명은 단지 정적 자격증명의 위험을 수반할 뿐이며, 폭발 반경이 프로덕션 배포 경로이기 때문에 CI는 이를 보관하기에 좋지 않은 장소입니다.
Workload Identity Federation 작동 방식
Workload Identity Federation은 외부 ID 공급자와 GCP 간의 제휴 신뢰입니다. GCP가 키를 발급하여 GitHub로 가져가는 대신, GitHub가 GCP가 이미 신뢰하는 토큰을 발급합니다.
모든 GitHub Actions 실행은 GitHub의 ID 공급자로부터 OIDC 토큰을 요청할 수 있습니다. 해당 토큰은 GitHub가 서명한 수명이 짧은 JWT이며, 토큰의 클레임(claims)은 실행에 대한 정보(어떤 리포지토리, 어떤 브랜치 또는 태그, 어떤 워크플로, 어떤 환경)를 설명합니다. GCP는 GitHub의 발급자(issuer)를 신뢰한다고 지정하는 공급자를 포함하는 Workload Identity Pool을 호스팅합니다. 워크플로가 GitHub 토큰을 제시하면, GCP는 서명을 확인하고 설정한 조건에 대해 클레임을 검사하며, 통과하면 토큰을 사용자가 선택한 서비스 계정을 가장하는 수명이 짧은 Google 액세스 토큰으로 교환합니다.
이 연계 과정을 명확히 설명할 가치가 있습니다. GitHub는 실행의 ID를 증명하는 토큰에 서명합니다. GCP는 GitHub의 서명과 사용자가 설정한 조건을 신뢰합니다. 서비스 계정은 실제 배포 권한을 부여합니다. 어떤 보안 비밀도 경계를 넘지 않으며, 반환되는 Google 토큰은 몇 분 안에 만료됩니다.
gcloud로 풀 및 공급자 설정하기
풀, 그 안의 OIDC 공급자, 서비스 계정에 대한 권한 바인딩, 이 세 가지를 한 번 생성합니다. 먼저 프로젝트 변수를 설정하세요.
PROJECT_ID="my-project"
PROJECT_NUMBER="$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')"
REPO="my-org/my-repo"
DEPLOY_SA="deploy@${PROJECT_ID}.iam.gserviceaccount.com"
풀을 만든 다음 GitHub의 발급자를 신뢰하는 공급자를 만듭니다. 속성 매핑은 GitHub 토큰의 클레임을 나중에 참조할 수 있는 속성으로 복사합니다.
gcloud iam workload-identity-pools create github-pool \
--project="$PROJECT_ID" \
--location="global" \
--display-name="GitHub Actions"
gcloud iam workload-identity-pools providers create-oidc github-provider \
--project="$PROJECT_ID" \
--location="global" \
--workload-identity-pool="github-pool" \
--display-name="GitHub OIDC" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
--attribute-condition="assertion.repository == '${REPO}' && assertion.ref == 'refs/heads/main'"
attribute-condition은 보안 경계이며 다음 섹션에서 전적으로 이에 대해 다룹니다. 지금은 main 브랜치의 한 리포지토리에 신뢰를 고정한다는 점에 유의하세요. 다른 리포지토리나 브랜치의 토큰은 서비스 계정에 도달하기 전에 교환에 실패합니다.
신뢰를 하나의 리포지토리와 브랜치로 고정하기
전체 설정에서 가장 중요한 단 한 줄은 속성 조건입니다. 너무 느슨하게 설정하면 키 없는 배포를 구축한 것이 아니라, 전 세계 모든 리포지토리가 사칭할 수 있는 서비스 계정을 만든 것입니다.
느슨한 버전도 여전히 작동하기 때문에 실패 모드는 미묘합니다. attribute.repository를 매핑하지만 특정 값을 요구하지 않으면, 낯선 사람의 리포지토리를 포함한 모든 GitHub 리포지토리가 유효한 GitHub 서명 토큰을 제시하고 통과할 수 있습니다. 서명은 진짜입니다. 여러분이 건너뛴 것은 그 서명이 누구의 것인지 확인하는 것이었습니다. GitHub는 플랫폼의 모든 리포지토리에 올바르게 서명된 토큰을 기꺼이 발급하므로, ID 공급자의 서명만으로는 소유권에 대해 아무것도 증명하지 못합니다. 여러분의 조건이 신뢰를 여러분에게 묶는 것입니다.
항상 정확한 리포지토리를 요구하십시오. 하나의 브랜치만 배포해야 할 때는 브랜치를 추가하십시오.
# Good: exactly your repo, main branch only.
--attribute-condition="assertion.repository == 'my-org/my-repo' && assertion.ref == 'refs/heads/main'"
# Dangerous: any repo whose name happens to match a prefix.
--attribute-condition="assertion.repository.startsWith('my-org/')"
# Broken: no ownership check at all, every GitHub repo passes.
--attribute-condition="assertion.repository != ''"
서비스 계정의 바인딩은 동일한 범위를 일치시켜야 합니다. 풀에 workloadIdentityUser 역할을 부여하되, 멤버를 정확한 리포지토리 속성으로 제한하여 가장 권한이 풀 전체에 적용되지 않도록 하십시오.
gcloud iam service-accounts add-iam-policy-binding "$DEPLOY_SA" \
--project="$PROJECT_ID" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/${REPO}"
이제 두 개의 계층이 같은 문을 지킵니다. 공급자 조건은 교환 시 외부 토큰을 거부하고, 멤버 바인딩은 풀 내부에서조차 어떤 ID가 이 서비스 계정을 사칭할 수 있는지 제한합니다. 서비스 계정 자체도 최소한으로 유지하십시오. 만약 Cloud Run에만 배포해야 한다면, 사용하는 Cloud Run 및 Artifact Registry 역할을 부여하고 더 넓은 범위의 역할은 부여하지 마십시오. 서비스 계정에 대한 최소 권한 원칙은 CI 로직이 속아서 예기치 않은 것을 실행하게 될 경우 피해를 제한합니다.
워크플로 측면
GitHub 측에서는 하나의 권한과 하나의 인증 단계가 필요합니다. id-token: write 권한은 작업이 OIDC 토큰을 요청할 수 있게 해주는 것입니다. 이것이 없으면 인증 단계가 혼란스러운 오류와 함께 실패하며, 사람들이 가장 흔하게 잊는 것입니다.
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
service_account: ${{ vars.GCP_DEPLOY_SA }}
- uses: google-github-actions/setup-gcloud@v2
- run: |
gcloud run deploy my-service \
--image "$IMAGE" \
--region asia-northeast3
두 입력이 secrets가 아닌 vars에서 온다는 점에 유의하세요. 이것이 핵심입니다. 두 값 모두 민감하지 않습니다. 공급자 이름과 서비스 계정 이메일은 자격 증명이 아닌 식별자이므로, 워크플로를 검토하는 누구나 읽을 수 있는 일반 리포지토리 변수로 존재합니다.
CLI에서 한 번 설정하세요:
gh variable set GCP_WIF_PROVIDER \
--body "projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/providers/github-provider"
gh variable set GCP_DEPLOY_SA --body "$DEPLOY_SA"
auth 액션은 GitHub OIDC 토큰을 요청하고, 이를 공급자에게 보내고, 수명이 짧은 Google 액세스 토큰을 받은 다음, setup-gcloud와 SDK가 자동으로 인식하는 자격 증명을 작성합니다. auth 이후의 모든 단계는 리포지토리 어디에도 키 없이 인증됩니다.
보안 트레이드오프, 나란히 비교하기
단순히 깔끔해지는 것만이 장점은 아닙니다. 이는 유출 시 공격자가 얻는 것과 여러분이 유지보수해야 하는 것을 바꿉니다.
| 속성 | 서비스 계정 키 | 워크로드 아이덴티티 연동 |
|---|---|---|
| 자격 증명 수명 | 수동으로 삭제할 때까지, 만료 없음 | 분 단위, 실행 시마다 발급 |
| 비밀 유출 시 노출되는 것 | 전체 SA 액세스 권한, 무기한 | 없음, 저장된 자격 증명 없음 |
| 신뢰 범위 | 파일을 가진 누구나 | 지정한 하나의 리포지토리 및 브랜치 |
| 순환(Rotation) | 수동, 잊기 쉬움 | 없음, 토큰은 일시적임 |
| GitHub에 저장되는 비밀 | JSON 키 | 없음, 공개 식별자만 있음 |
| 감사 | 취약, 키에 출처가 기록되지 않음 | 토큰 클레임에 리포지토리, ref, 워크플로 기록 |
수명이 짧은 토큰이 핵심입니다. 몇 분 안에 만료되는 GitHub 토큰과 그 직후에 만료되는 Google 토큰은 실행이 끝나면 거의 가치가 없습니다. 훔칠 만한 영구적인 아티팩트가 없습니다. 리포지토리 및 브랜치로 범위를 지정하면 조직의 다른 곳에서 유출이 발생하거나 악의적인 포크가 있더라도 해당 토큰은 다른 repository 클레임을 전달하고 조건을 충족하지 못하므로 이 아이덴티티를 빌려 쓸 수 없습니다. 그리고 키를 저장하지 않기 때문에 순환할 것도 없으며, 이는 운영에서 반복적인 전체 작업을 조용히 제거해 줍니다.
솔직한 비용은 설정의 복잡성입니다. 키는 create, download 두 가지 명령어면 됩니다. 연동은 풀, 신중하게 작성된 조건이 있는 공급자, 멤버 바인딩, 그리고 두 개의 워크플로 입력으로 구성됩니다. 특히 조건은 느슨한 방향으로 미묘하게 잘못 설정하기 쉬우며, 느슨한 조건은 작동하는 것처럼 보입니다. 조건을 정확하게 작성하고 다른 브랜치의 토큰이 실제로 거부되는지 테스트할 시간을 확보해야 합니다. 하지만 일단 처음 설정을 마치고 나면, 관리해야 할 키가 없기 때문에 유지보수 비용은 거의 0에 가깝습니다.
서비스 계정 키가 여전히 쉬운 선택일 때
페더레이션은 프로덕션에 배포하는 CI의 올바른 기본값이지만, 비용이 들며 몇몇 경우에는 진정으로 키를 사용하는 것이 더 유리합니다.
호출자가 OIDC를 지원하는 ID 공급자가 아니라면 페더레이션은 신뢰할 대상이 없습니다. 임의의 컴퓨터에 있는 cron 작업, 개발자 노트북 스크립트 또는 토큰 발급자가 없는 레거시 시스템은 교환에 필요한 서명된 어설션을 제시할 수 없습니다. 이러한 것들을 워크로드 아이덴티티 풀로 연결하는 것은 절약되는 것보다 더 많은 작업이 필요한 경우가 많으며, 범위가 지정되고 교체 주기가 짧은 키가 실용적인 해답이 될 수 있습니다.
실제 데이터가 없고 수명이 며칠인 일회용 샌드박스 프로젝트도 또 다른 경우입니다. 전체 프로젝트가 다음 주에 삭제될 예정이라면 유출된 키의 영향 반경은 작으며, 설정에 드는 수고로 얻는 이점은 거의 없습니다. 에뮬레이터를 사용한 로컬 개발은 클라우드 자격 증명이 전혀 필요하지 않으므로 두 가지 방법 모두 적용되지 않습니다.
대략적인 기준은 다음과 같습니다. 워크로드가 OIDC 토큰을 발급하는 곳(GitHub Actions, GitLab CI 및 대부분의 최신 플랫폼이 그렇듯이)에서 실행된다면 페더레이션을 선호하고 키를 삭제하십시오. 신뢰할 수 있는 토큰 발급자가 없는 곳에서 실행되거나 프로젝트가 일회용인 경우, 범위가 엄격하게 지정되고 교체 주기가 짧은 키는 옹호할 수 있는 선택입니다. 하지만 GitHub Actions를 사용하여 GCP에 배포하는 일반적인 경우, 키를 사용하면 오늘 당장 몇 분의 시간을 벌 수 있지만 키가 존재하는 한 지속적인 책임이 됩니다. 페더레이션은 처음에 한나절 정도의 시간이 더 걸리지만, 그 후에는 더 이상 신경 쓸 필요가 없습니다.
관련 글
Cloudflare 뒤에서 Cloud Run 트래픽을 유지하는 세 가지 계층
공개 run.app URL을 사용하면 누구나 CDN을 우회하여 Cloud Run에 직접 도달할 수 있습니다. 세 가지 레이어가 그 간극을 메웁니다: 인그레스 제한, 부하 분산기, 보안 비밀 헤더.
Cloud Run 서비스 vs 잡: 하나의 Go 바이너리, 두 개의 진입점
Cloud Run 서비스와 작업은 하나의 Go 이미지와 하나의 빌드를 공유할 수 있습니다. 여기서는 단일 바이너리가 서브 모드와 배치 작업으로 분할되는 방법과 그렇게 하지 말아야 할 경우를 설명합니다.
모든 사용자를 로그아웃시키지 않고 JWT 서명 키를 교체하는 방법
JWT 서명 키를 단순하게 교체하면 모든 활성 토큰이 무효화되고 모든 사용자가 로그아웃됩니다. 이를 방지하는 중첩 기간(overlap window)과 `kid` 디자인이 여기에 있습니다.