git push 한 번으로 끝나는 백엔드 개발 서버 CI/CD 구축
git push한 번으로 이미지 빌드 → ECR 푸시 → 배포 태그 기록 → Auto Scaling Group이 인스턴스를 새로 띄워 교체까지 자동으로 이어지는 개발 서버 배포 파이프라인을 만들었다. 장기 액세스 키 없이 OIDC로 그때그때 임시 자격증명을 받고, 각 역할은 필요한 권한만 갖도록 최소 권한으로 나눴다.
- 리포지토리
ASM-17-NS/mvp-backend(develop) · 리전ap-northeast-2· 상태 dev 파이프라인 완성 - 스크린샷마다 무엇(이 화면이 무엇인지)과 왜(이 단계가 왜 필요한지 / 왜 이렇게 했는지)를 붙였다. 코드에 계정 번호가 들어가는 자리는
<계정ID>로 적는다.
전체 흐름 — push 이후 누가 무엇을 하나
핵심은 일하는 주체가 둘이라는 점이다. 앞부분은 GitHub Actions 러너가, 뒷부분은 ASG가 새로 띄운 EC2 인스턴스가 각자 스스로 한다. 러너는 이미지를 만들어 올리고 “새 태그로 교체해”라는 신호만 주고, 실제 배포(이미지 받아 실행)는 인스턴스가 부팅하며 수행한다.
flowchart TB
subgraph R["① GitHub Actions 러너 (ubuntu-latest)"]
direction LR
a1[git push] --> a2[checkout] --> a3[OIDC assume] --> a4[ECR 로그인] --> a5[이미지 빌드] --> a6[ECR push] --> a7[파라미터에 태그 기록] --> a8[ASG 교체 시작] --> a9[완료까지 폴링]
end
subgraph I["② 새 EC2 인스턴스 (ASG가 Launch Template로 기동)"]
direction LR
b1[부팅·User Data] --> b2["[1/4] 파라미터→.env"] --> b3["[2/4] image-tag 읽기"] --> b4["[3/4] ECR pull"] --> b5["[4/4] docker run :8080"]
end
a8 -. 방아쇠 .-> b1
러너의 “ASG 교체 시작”이 방아쇠다. 이걸 받으면 ASG가 새 인스턴스를 먼저 띄우고, 그게 준비된 뒤에 옛 인스턴스를 내린다. 러너는 그동안 “교체 완료” 상태가 될 때까지 15초 간격으로 확인만 한다.
1. 파이프라인을 돌려본 기록
완성된 모습부터, 처음 돌렸을 때 어디서 멈췄고 어떻게 고쳤는지까지 순서대로 적는다. 왜 실패했고 어떻게 고쳤는지가 이 작업에서 가장 많이 배운 부분이다.
1-1 · 파이프라인이 끝까지 돌았을 때 (3m 44s) — ci: trigger ASG instance refresh after image push #6 실행. checkout → OIDC → ECR 로그인 → 태그 지정 → 빌드 → ECR push → 파라미터 태그 기록 → ASG 교체 시작 → 교체 완료 대기(3m 17s) 까지 10단계가 모두 초록.
대부분의 시간(3분 17초)이 “교체 완료 대기”에 쓰인다. 새 인스턴스가 부팅하고 이미지를 받아 컨테이너가 뜰 때까지 걸리는 시간이다. 즉 이 화면은 빌드뿐 아니라 실제 인스턴스 교체까지 끝났다는 뜻이다.
1-2 · 첫 실행은 실패했다 (exit 252) — #1이 “파라미터 스토어에 새 태그 기록” 단계에서 죽었다. 로그를 보면 aws ssm put-parameter ... 한 줄이 세 번 중복되어 붙어, aws: [ERROR]: Unknown options: ssm, put-parameter, aws 로 종료됐다.
워크플로 YAML의
run:블록을 편집하다 같은 줄이 겹쳐 들어간 문제였다. AWS 권한이나 인프라 문제가 아니라 스크립트 문법 문제임을 로그로 특정했다. 앞 단계(빌드·ECR push)가 모두 통과한 뒤 이 단계에서만 멈춘 것이 단서였다.
1-3 · 중복 줄을 지우니 첫 성공 (30s) — ci: fix duplicated put-parameter line #2 가 30초 만에 성공. Docker 빌드 47% 캐시 적중. 이 시점엔 아직 ASG 교체 단계가 없어 빌드·푸시·태그 기록까지만 돈다.
먼저 빌드→푸시→태그 기록까지 파이프라인을 초록으로 만든 뒤, 그 다음에 “ASG 교체”(1-1)를 얹었다. 한 번에 다 넣지 않고 단계를 나눠, 어디서 깨지는지 좁힐 수 있게 했다.
마지막에 추가한 “교체” 트리거 부분:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
on:
push:
branches: [develop]
permissions:
id-token: write # OIDC 토큰 발급 권한 (이게 있어야 자격증명을 받음)
contents: read
# ... 빌드 / ECR push / 파라미터 태그 기록 생략 ...
- name: ASG 인스턴스 교체 시작
run: |
ID=$(aws autoscaling start-instance-refresh \
--auto-scaling-group-name dev-backend-asg \
--preferences '{"MinHealthyPercentage":100,"MaxHealthyPercentage":200,"InstanceWarmup":120}' \
--query InstanceRefreshId --output text)
- name: 교체 완료 대기 # 15초 간격으로 최대 40회 상태 확인
run: |
for i in $(seq 1 40); do
S=$(aws autoscaling describe-instance-refreshes ... )
case "$S" in
Successful*) exit 0 ;;
Failed*|Cancelled*|Rollback*) exit 1 ;;
esac
sleep 15
done
MinHealthyPercentage 100 / MaxHealthyPercentage 200 으로 둔 이유: 옛 인스턴스를 유지한 채(최소 100%) 새 인스턴스를 최대 2배까지 띄웠다가(최대 200%), 새 것이 준비되면 옛 것을 내린다. 먼저 띄우고 나중에 내리는(new-before-old) 순서라 교체 중에도 서버가 비지 않는다.
2. push 후, GitHub과 AWS가 자격증명을 주고받는 법
러너가 AWS를 건드리려면 자격증명이 필요하다. 장기 액세스 키를 GitHub Secret에 저장하는 대신, OIDC로 그때그때 1시간짜리 임시 자격증명을 발급받게 했다. 유출될 장기 키 자체가 없다.
2-1 · OIDC 자격증명 공급자 등록 — IAM에 token.actions.githubusercontent.com 를 OpenID Connect 공급자로 등록. Audience는 sts.amazonaws.com 한 개.
AWS에게 “GitHub Actions가 발급한 토큰을 믿어라”라고 알려주는 등록 단계다. Audience(aud) 는 이 토큰이 AWS STS를 향한 것임을 못박는 값 — 다른 서비스용 토큰을 재사용하는 것을 막는다.
2-2 · 러너 역할의 신뢰 정책 — 누가 이 역할을 쓸 수 있나 — 역할 myton-dev-backend-deploy-gha 의 신뢰 정책. Action은 sts:AssumeRoleWithWebIdentity, 조건은 aud = sts.amazonaws.com 이면서 sub = repo:ASM-17-NS/mvp-backend:ref:refs/heads/develop. 최대 세션 1시간.
sub 조건이 이 구조의 자물쇠다. 우리 리포의 develop 브랜치에서 돈 워크플로에만 이 역할을 내준다. 다른 리포·다른 브랜치, 심지어 포크 PR이 토큰을 받아도 sub가 달라 거부된다. 세션 1시간이라 발급된 키도 오래 살지 않는다.
2-3 · 러너 역할의 권한 — 딱 배포에 필요한 것만 — 권한 정책에 네 덩어리만 있다. ECR(PutImage / BatchGetImage / GetDownloadUrlForLayer 등, repository/dev-app), UpdateImageTag(ssm:PutParameter, parameter/dev-app/image-tag), RefreshDevAsg(autoscaling:StartInstanceRefresh), DescribeAsg(교체 상태 조회).
러너는 배포 파이프라인만 하면 되므로 그 외 권한은 주지 않았다. ECR 대상도
dev-app저장소 하나, 파라미터도image-tag하나로 좁혔다. 토큰이 새더라도 이 역할로 할 수 있는 일이 배포로 한정되도록(폭발 반경 축소).
3. 배포할 이미지를 가리키는 포인터 — Parameter Store
러너와 인스턴스는 서로 직접 대화하지 않는다. 러너는 “이번에 배포할 이미지 태그”를 파라미터에 적어두고, 인스턴스는 부팅하며 그 값을 읽는다. 둘 사이의 게시판 역할이다.
3-1 · /dev-app/image-tag 파라미터 — Type String, Value fc422e4(= 커밋 SHA 7자리), Version 11. “Last modified user”가 assumed-role/myton-dev-backend-deploy-gha/GitHubActions — 즉 러너가 방금 이 값을 덮어썼다는 기록.
이미지 태그를 코드나 AMI에 박아넣지 않고 파라미터로 뺀 이유: 새 배포 = 이 값 하나만 바꾸면 됨. 인스턴스는 항상 “지금 파라미터에 적힌 태그”를 받으므로, 몇 번을 재기동해도 같은 버전을 재현할 수 있다. Version이 쌓여 배포 이력도 남는다.
4. 새 인스턴스가 스스로 뜨고 앱을 띄우는 법
“ASG 교체 시작” 신호를 받으면 새 EC2가 뜬다. 이 새 인스턴스는 Launch Template에 적힌 대로 부팅하고, User Data 스크립트가 알아서 이미지를 받아 컨테이너를 띄운다. 사람이 SSH로 들어가 배포하지 않는다.
4-1 · Auto Scaling Group — ASG dev-backend-asg. 인스턴스는 Launch Template를 따르고, “Integrate with other services”의 Load balancer 항목이 비어 있다(-).
ASG를 배포 단위로 쓰면 “교체”라는 개념이 생긴다 — 인스턴스를 고치는 게 아니라 새 것으로 갈아끼운다. 다만 지금은 로드밸런서가 없어, 교체 판정이 앱 응답이 아니라 EC2 상태검사에 의존한다(이 한계는 맨 아래 7번에서 다룬다).
4-2 · Launch Template 새 버전 — dev-backend-lt (lt-037bd37ec8e819aab)의 새 버전을 만드는 화면. 버전 설명 fix PATH for snap aws-cli.
부팅 스크립트가
aws명령을 못 찾아 실패한 적이 있어, snap으로 설치된 aws-cli 경로(/snap/bin)를 PATH에 추가했다(4-3에서 확인). Launch Template는 버전 단위로 관리되므로 기존 버전을 덮지 않고 새 버전으로 올려, 문제가 생기면 이전 버전으로 되돌릴 수 있게 했다.
4-3 · User Data — 부팅 시 실행되는 4막 스크립트 — 인스턴스가 처음 부팅할 때 한 번 도는 스크립트. 로그는 /var/log/dev-app-boot.log로 남긴다.
- [1/4]
get-parameters-by-path로/dev-app/*설정을 받아.env생성(image-tag는 제외,chmod 600) - [2/4]
/dev-app/image-tag읽어 IMAGE_TAG 확정 - [3/4] ECR 로그인 후
docker pull— 이미지 주소는<계정ID>.dkr.ecr.ap-northeast-2.amazonaws.com/dev-app:<태그>형태 - [4/4]
docker run -d -p 8080:8080 --env-file --restart unless-stopped
설정과 코드를 이미지에 굽지 않고 런타임에 파라미터에서 받아 조립했다. 같은 이미지로 어떤 인스턴스가 떠도 그 시점의 설정과 태그로 동일하게 동작한다.
.env를 600으로 잠그고 image-tag만 따로 읽는 건, 설정값과 배포 버전의 관심사를 분리하기 위해서다.
4-4 · 인스턴스 프로파일 부착 — Launch Template의 IAM instance profile에 dev-app-ec2-role 지정.
User Data가 파라미터를 읽고 ECR에서 이미지를 받으려면 인스턴스 자신에게 권한이 있어야 한다. 그 권한을 이 인스턴스 역할로 준다. 이렇게 하면 스크립트 안에 키를 적을 필요가 없다 — 인스턴스가 메타데이터(IMDS)로 임시 자격증명을 받아 쓴다(권한 상세는 5번).
4-5 · 베이스 AMI — ami-016e0145edbe5add0 (dev-backend-base-ami-20260914). 설명: Ubuntu 24.04 + docker + docker-compose-v2 + awscli v2, no app, encrypted. 볼륨은 gp3 암호화.
docker와 awscli 같은 안 바뀌는 것들만 미리 구워둔 이미지다. 앱은 넣지 않았다(“no app”). 매 배포마다 docker를 새로 까는 대신 부팅 시엔 앱 이미지만 받으면 되니 기동이 빨라지고, 앱 버전은 AMI가 아니라 파라미터가 결정하도록 역할을 나눴다.
5. 인스턴스가 가지는 권한 — 이것도 최소한으로
인스턴스 역할 dev-app-ec2-role에는 세 가지만 붙였다. 설정 읽기 · 이미지 받기 · 원격 접속. 그 이상은 없다.
5-1 · 파라미터 읽기 전용 (dev-app-ssm-read) — 직접 만든 정책. Action은 ssm:GetParameter / GetParameters / GetParametersByPath 세 개, 대상은 parameter/dev-app 와 parameter/dev-app/* 로 한정.
인스턴스는 설정을 읽기만 하면 된다. 쓰기(PutParameter)는 러너만 갖는다(2-3). 경로도
dev-app하위로 좁혀, 인스턴스가 다른 서비스의 파라미터를 못 보게 했다.
5-2 · ECR 읽기 전용 (AmazonEC2ContainerRegistryReadOnly) — AWS 관리형 정책. Elastic Container Registry에 대해 List, Read만 허용.
인스턴스는 이미지를 받기만(pull) 하면 된다. 밀어넣기(push)는 러너 몫(2-3). 읽기 전용 관리형 정책을 붙여, 인스턴스가 저장소를 오염시킬 수 없게 했다.
5-3 · SSM 원격 접속 (AmazonSSMManagedInstanceCore) — AWS 관리형 정책. Systems Manager / SSM Messages / EC2 Messages 접근 허용.
이 인스턴스엔 SSH 포트를 열지 않는다(6-1). 대신 SSM Session Manager로 접속한다. 그러려면 인스턴스가 SSM 서비스와 통신해야 하는데, 이 정책이 그 통로를 연다. 접속은 인스턴스가 바깥으로 나가서(outbound) 이뤄지므로 인바운드를 열지 않아도 된다.
6. 프라이빗 네트워크 배선
인스턴스는 외부에서 직접 접근할 수 없는 자리에 둔다. 들어오는 문은 닫고, AWS 서비스 호출은 인터넷이 아니라 VPC 안의 엔드포인트로 흐르게 했다.
6-1 · 앱 보안그룹 — 인바운드 0 — sg-08eec11bf5351f545 (dev-backend-asg-sg). Inbound rules count 0, Outbound 1. VPC vpc-0c00d3258a346e92f.
아직 로드밸런서가 없어 밖에서 이 인스턴스로 직접 들어올 일이 없으므로 인바운드를 아예 비웠다. 접속은 SSM으로(나가는 방향, 5-3), 나중에 ALB를 붙이면 그때 “ALB의 보안그룹에서 오는 8080만” 여는 식으로 최소로 열 계획이다.
6-2 · VPC 엔드포인트 — 이 dev VPC에는 인터페이스 엔드포인트 3개 — ssm(vpce-0db267e9d5ef16834), ssmmessages(vpce-050efcb620d6b7a3b), ec2messages(vpce-033bac214cb732d2c).
SSM 접속(5-3)에 필요한 세 서비스로의 트래픽을 인터넷을 거치지 않고 VPC 안에서 처리하려고 인터페이스 엔드포인트를 뒀다. 덕분에 인바운드가 0인 프라이빗 인스턴스에도 Session Manager로 접속할 수 있다.
6-3 · ENI — 통신은 VPC 안에서 — dev-ec2-sg로 필터하면 ENI 여러 개가 나온다. 모두 같은 VPC vpc-0c00d3258a346e92f, 세 서브넷, AZ ap-northeast-2a.
엔드포인트와 인스턴스가 실제로 이 VPC 내부의 ENI로 존재한다는 걸 보여준다. 외부 공인 IP가 아니라 VPC 사설망 안에서 서로를 찾으므로, IP가 아니라 보안그룹·엔드포인트 단위로 통제가 가능하다.
7. 앞으로 해야 할 것
dev 배포 파이프라인은 돌지만 아직 “미완성”인 부분을 미완성으로 남겨둔다. 가장 큰 것은 헬스체크다.
- [다음 작업] ALB + 애플리케이션 헬스체크 연결 — 지금은 로드밸런서가 없어(4-1), ASG의 “교체 완료 대기”가 EC2가 켜졌는지까지만 본다. 컨테이너 안의 앱이 실제로 요청을 받는지는 확인하지 못한다. 진짜 무중단이라 부르려면 ALB를 앞에 두고
/health같은 헬스체크 경로를 만들어, ALB가 200을 받은 인스턴스만 정상으로 판정하고 트래픽을 넘기도록 해야 한다. 이때 앱 SG(6-1)에는 “ALB SG에서 오는 8080”만 인바운드로 연다. - [예정] prod 배포 파이프라인 — 출시 심사 중이라 prod는 동결. 심사가 끝난 뒤 dev에서 검증한 이 구조를 prod에 옮기고 로그·모니터링을 붙인다.
- [정리] 보안그룹 3분리 · 기존 인스턴스 정리 — 앱 / ALB / DB 접근 경로별로 보안그룹을 나누고, 교체 과정에서 남은 옛 인스턴스를 정리한다.
이 글은 “무엇을 만들었나”의 요약이다. 각 단계에서 개념을 어떻게 이해했고 어디서 막혔는지는 이어지는 글들에서 하나씩 자세히 다룬다.

















