GitHub OIDC와 ECS Task Role로 AWS 장기 키 없애기
GitHub Actions 배포는 OIDC, 실행 중인 컨테이너는 ECS Task Role로 나눠 AWS 장기 Access Key 없이 임시 자격 증명을 쓰는 구조를 설명한다.
이 글의 목차배포와 실행은 같은 주체가 아니었다7
운영 중인 인증 구조를 살펴보니 배포와 런타임에 각각 장기 AWS Access Key를 없애야 할 경로가 있었다.
하나는 GitHub Actions가 이미지를 올리고 서비스를 배포하는 일이었다.
다른 하나는 배포된 애플리케이션이 S3와 AWS API를 호출하는 일이었다.
점검을 시작할 때 배포와 런타임 모두 장기 키에 의존하고 있었다.
해결은 키를 새 비밀값으로 교체하는 것이 아니었다.
누가 어느 순간 AWS 권한을 필요로 하는지 나눈 뒤 장기 키가 없어도 동작하도록 바꿔야 했다.
업무는 GitHub 배포 인증의 OIDC 전환과 Keyless용 IAM Role, ECS Task Role 구성으로 나누어 진행했다.
Keyless는 애플리케이션이나 CI에 장기 Access Key를 직접 보관하지 않고 필요한 순간에 짧게 유효한 자격 증명을 받는 운영 방식이다.
배포와 실행은 같은 주체가 아니었다
OIDC(OpenID Connect)는 한 시스템이 발급한 신원 토큰을 다른 시스템이 검증해 사용자를 식별하는 표준이다.
GitHub Actions는 workflow가 어느 저장소와 브랜치에서 실행되는지 담은 OIDC 토큰을 발급할 수 있다.
AWS는 그 토큰의 발급자와 조건을 확인한 뒤 IAM Role의 임시 자격 증명을 내준다.
IAM Role은 고정된 사용자 계정이 아니라, 조건을 만족한 주체가 잠시 맡을 수 있는 AWS 권한 묶음이다.
ECS Task Role은 다른 문제를 해결한다.
배포가 끝난 뒤 실행 중인 컨테이너 안의 애플리케이션이 S3나 SQS를 호출할 때 사용하는 역할이다.
CI/CD의 배포 권한과 애플리케이션의 런타임 권한을 하나로 합치면 필요 이상으로 넓어진다.
배포 workflow가 S3의 업무 데이터를 읽을 이유가 없고, 애플리케이션이 새 ECS 서비스를 배포할 이유도 없다.
쇼핑몰 API를 예로 들면 GitHub Actions는 새 컨테이너 이미지를 ECR에 올리고 ECS 서비스를 갱신한다.
배포된 API는 사용자가 올린 영수증을 S3에서 읽을 수 있지만 ECR에 이미지를 밀어 넣거나 서비스를 재배포할 필요는 없다.
둘을 같은 키로 묶으면 배포 경로가 침해됐을 때 업무 데이터까지, 애플리케이션이 침해됐을 때 배포 권한까지 함께 노출될 수 있다.
GitHub OIDC는 AWS 쓰기 권한 그 자체가 아니다
GitHub workflow에는 다음 권한이 필요하다.
permissions:
id-token: write
contents: readid-token: write는 GitHub OIDC 토큰을 요청할 수 있게 한다.
이 줄만으로 AWS 리소스를 수정할 수 있는 것은 아니다.
실제 AWS 권한은 토큰을 신뢰하도록 구성한 IAM Role과 그 Role에 붙인 정책이 결정한다.
contents: read는 workflow가 저장소 코드를 읽을 때 필요한 GitHub 권한이다.
AWS Role의 신뢰 정책에서는 최소한 토큰의 aud와 sub를 확인한다.
aud는 토큰을 받을 대상 서비스인 audience를, sub는 토큰의 주체인 subject를 뜻한다.
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<account-id>:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub":
"repo:<owner>/<repository>:ref:refs/heads/<deploy-branch>"
}
}
}이 코드는 구조를 설명하기 위해 식별자를 제거한 예다.
Principal은 어느 OIDC 공급자를 신뢰할지 정하고, Action은 웹 신원 토큰으로 Role을 맡는 동작만 허용한다.
sub 조건을 저장소 전체 wildcard로 넓히면 포크, 다른 브랜치나 예상하지 않은 workflow가 배포 Role을 맡을 가능성이 커진다.
배포 브랜치 또는 보호된 GitHub environment 단위로 좁히고, environment에는 승인과 브랜치 제한을 함께 둔다.
실제 sub 형식은 저장소의 OIDC 설정과 생성 시점에 따라 달라질 수 있다.
GitHub는 2026년 7월 15일 이후 만들어진 저장소부터 소유자 ID와 저장소 ID가 포함된 변경 불가능한 subject 형식을 기본으로 사용한다.
그 이전 저장소도 설정에 따라 이 형식으로 전환할 수 있다.
따라서 위 예제 문자열을 그대로 붙여 넣지 말고 현재 저장소가 발급하는 claim과 공식 문서를 대조한 뒤 AWS 신뢰 정책을 맞춰야 한다.
STS(Security Token Service)는 제한된 시간만 유효한 Access Key, Secret Key와 Session Token을 발급한다.
만료된 자격 증명은 다시 사용할 수 없어 GitHub secret에 장기 키를 저장하는 구조와 노출 기간이 다르다.
Task Role은 컨테이너 안의 AWS SDK가 사용했다
배포 인증을 OIDC로 바꿔도 애플리케이션 환경 변수에 장기 키가 남아 있으면 Keyless 전환은 끝나지 않는다.
ECS는 실행 중인 Task에 IAM Role을 연결할 수 있다.
Task는 ECS가 실행하는 하나 이상의 컨테이너 묶음이다.
컨테이너 안의 AWS SDK는 기본 자격 증명 공급자 체인을 통해 Task Metadata Endpoint에서 임시 자격 증명을 얻을 수 있다.
그래서 코드에 AWS_ACCESS_KEY_ID와 AWS_SECRET_ACCESS_KEY를 직접 주입하지 않아도 된다.
Node.js용 AWS SDK는 환경 변수의 고정 키를 ECS 컨테이너 자격 증명보다 먼저 확인한다.
기존 키가 환경 변수나 클라이언트 생성 코드에 남아 있으면 Task Role을 붙여도 예전 키가 계속 선택될 수 있다.
그래서 역할 연결과 함께 명시적으로 넣은 자격 증명을 제거하고, CloudTrail에서 실제 호출 주체가 새 Role로 바뀌었는지 확인해야 한다.
const s3 = new S3Client({
region: process.env.AWS_REGION,
});자격 증명 항목을 생략한 것이 핵심이다.
AWS SDK는 ECS 환경에서 Task Role 자격을 찾아 사용하고 만료 전에 갱신한다.
이 설정이 모든 AWS API를 허용하는 것은 아니다.
Task Role 정책에는 애플리케이션이 실제로 사용하는 bucket, queue와 action만 넣는다.
예를 들어 파일을 읽기만 하는 서비스라면 s3:GetObject가 필요할 수 있지만 s3:DeleteBucket까지 줄 이유는 없다.
Task Role을 붙였다고 모든 컨테이너 사이에 강한 보안 경계가 생기는 것도 아니다.
AWS는 EC2 시작 유형의 ECS에서 같은 인스턴스의 컨테이너가 다른 Task의 자격 증명이나 EC2 Instance Metadata Service(IMDS)에 접근할 가능성을 위협 모델에 포함하라고 안내한다.
IMDS는 EC2 인스턴스가 자신의 메타데이터와 인스턴스 프로필 자격 증명을 읽는 내부 주소다.
EC2에서 awsvpc 네트워크 모드를 사용한다면 ECS 에이전트의 ECS_AWSVPC_BLOCK_IMDS=true 설정으로 Task 컨테이너의 IMDS 접근을 차단하고, 권한이 다른 작업에 더 강한 격리가 필요하면 Task별 격리 경계를 제공하는 Fargate를 검토한다.
이 옵션은 ECS 에이전트 동작을 바꾸므로 현재 네트워크 모드와 에이전트 버전을 확인한 뒤 적용하고, Task Role 자격은 계속 사용할 수 있는지 시험해야 한다.
Task Execution Role과도 구분했다
ECS에는 이름이 비슷한 Task Execution Role이 있다.
Task Role은 애플리케이션 코드가 AWS API를 호출할 때 사용한다.
Task Execution Role은 ECS 또는 Fargate 에이전트가 ECR에서 이미지를 가져오고 CloudWatch Logs로 로그를 보낼 때 사용한다.
두 역할을 하나로 합치면 컨테이너 코드가 이미지 pull이나 로그 전송에 필요한 에이전트 권한까지 갖게 될 수 있다.
AWS도 목적에 따라 역할을 분리하도록 안내한다.
배포 IAM Role, Task Execution Role과 Task Role을 다음처럼 나눴다.
| 역할 | 사용하는 주체 | 대표 작업 |
|---|---|---|
| 배포 Role | GitHub Actions | ECR push, ECS 배포 |
| Task Execution Role | ECS/Fargate 에이전트 | 이미지 pull, 로그 전송 |
| Task Role | 실행 중인 애플리케이션 | S3·SQS 등 업무 API |
Role 이름을 나눴다고 최소 권한이 자동으로 완성되지는 않는다.
각 정책의 Action, Resource와 신뢰 주체를 따로 검토해야 한다.
어떤 인증 경로를 써야 하는지 헷갈리면 “누가 AWS API를 호출하는가”부터 적는다.
| 호출 주체 | 적합한 자격 경로 | 피해야 할 구성 |
|---|---|---|
| GitHub Actions 배포 job | GitHub OIDC로 배포 Role을 잠시 맡음 | 저장소 secret에 IAM 사용자 장기 키 보관 |
| ECS가 이미지와 secret을 준비 | Task Execution Role | 애플리케이션 Task Role에 에이전트 권한 합치기 |
| 실행 중인 컨테이너 코드 | ECS Task Role | 환경 변수에 고정 Access Key 주입 |
| 개발자 로컬 작업 | IAM Identity Center 같은 사람용 임시 세션 | 팀이 하나의 IAM 사용자 키 공유 |
마지막 행은 이번 작업에서 변경한 경로가 아니라 같은 원칙을 사람의 로컬 접근에 적용한 권장안이다.
기존 키를 지우는 순서는 따로 설계해야 한다
새 역할이 동작한다고 바로 기존 Access Key를 삭제하면 숨은 소비자가 중단될 수 있다.
현재 같은 전환을 다시 한다면 다음 순서로 진행한다.
- 저장소 secret, 배포 서버, ECS 환경 변수와 설정 저장소에서 장기 키 사용 위치를 찾는다.
- 배포 workflow에 OIDC Role을 연결하고 개발 환경에서 ECR push와 ECS 배포를 확인한다.
- 런타임에는 Task Role을 연결하고 S3 등 실제 기능을 호출한다.
- CloudTrail에서 예상한 Role 세션이 API를 호출하는지 확인한다.
- 기존 키 사용을 비활성화해 숨은 경로가 없는지 관찰한다.
- 복구 조건을 확인한 뒤 장기 키와 관련 secret을 제거한다.
CloudTrail은 AWS API 호출 주체와 시각, 대상 작업을 기록하는 감사 서비스다.
새 Role이 실제 사용되는지와 삭제하려는 IAM 사용자 키가 여전히 호출되는지를 여기서 확인할 수 있다.
비활성화와 삭제를 나눈 이유는 되돌릴 시간을 만들기 위해서다.
키를 비활성화하면 API 호출은 막히지만 필요할 때 같은 키를 잠시 다시 활성화할 수 있다.
관찰을 끝낸 뒤 삭제해야 실수로 남겨 둔 소비자를 찾으면서도 장기 키를 계속 운영하는 기간을 줄일 수 있다.
완료했다고 말할 수 있는 범위
업무 기록에는 GitHub 배포의 OIDC 통합, Keyless용 IAM Role 구성과 S3·AWS SDK 인증의 ECS Task Role 전환이 각각 완료로 남아 있다.
기존 장기 키를 몇 개 제거했는지, 정책 권한을 몇 퍼센트 줄였는지는 기록에 없다.
따라서 이 작업의 결과를 “보안 위험을 몇 퍼센트 줄였다”라고 쓰지 않았다.
확인할 수 있는 변화는 배포와 런타임이 장기 Access Key를 직접 필요로 하지 않는 인증 경로를 만들고, 서로 다른 권한으로 분리했다는 것이다.
처음에는 Keyless라는 한 단어로 묶여 있던 일이었다.
실제로 나눠 보니 배포 주체, 에이전트와 애플리케이션은 다른 신원을 가지고 있었다.
키를 없애는 가장 중요한 단계는 새 인증 기술을 붙이는 것이 아니라, 누가 왜 권한을 쓰는지 먼저 구분하는 일이었다.