[친구하자] CI/CD 파이프라인 구축기: GitHub Actions + Docker + AWS ECR/EC2 배포 자동화
이전 글에서 다룬 CI/CD·Docker·ECR·EC2에 이어, 실제 파이프라인 구축 과정과 EC2 배포 시 겪은 트러블슈팅을 정리했습니다.
이전 일기: Android 앱을 만들며 배운 CI/CD와 AWS EC2·ECS에서 CI/CD, Docker, ECR, EC2 개념과 전체 흐름을 다뤘다.
이번에는 실제로 파이프라인을 구축한 과정과 배포하면서 겪은 트러블슈팅을 중심으로 정리한다.
1. 전체 아키텍처
[ 개발자 ]
│ git push (main 브랜치)
↓
[ GitHub ]
│ CI 워크플로우 실행 (ci.yml)
│ - Gradle 빌드
│ - 테스트 실행
↓ (CI 성공 시)
[ CD 워크플로우 실행 (cd.yml) ]
│
├─ Docker 이미지 빌드
│
├─ AWS ECR(컨테이너 레지스트리)에 이미지 push
│
└─ EC2 서버에 SSH 접속
│
├─ ECR에서 새 이미지 pull
├─ 기존 컨테이너 종료 & 삭제
└─ 새 컨테이너 실행
왜 CI와 CD를 나눌까?
CI 워크플로우(ci.yml)는 push할 때마다 빌드·테스트만 하고, CD 워크플로우(cd.yml)는 CI가 성공했을 때만 배포를 맡는다. 이렇게 나누면 테스트가 실패한 코드는 절대 서버에 올라가지 않는다. 한 파일에 다 넣어도 되지만, workflow_run으로 “다른 워크플로우 완료”를 트리거로 쓰면 CI 결과에 따라 CD 실행 여부를 분리하기 쉽다.
2. AWS ECR에 이미지 푸시
이전 글에서 ECR의 역할을 다뤘다. 여기서는 GitHub Actions에서 ECR에 푸시하는 방법을 본다.
GitHub Actions 워크플로우는 GitHub이 제공하는 Runner(가상 머신) 위에서 실행된다. 이 Runner에는 Docker가 미리 설치되어 있어서, 워크플로우 안에서 docker build, docker tag, docker push 같은 Docker CLI 명령어를 그대로 쓸 수 있다. 즉, “이미지 빌드”는 Runner에서 Docker로 하고, “저장”만 ECR에 하는 구조다. ECR은 이미지 보관소일 뿐, 빌드를 대신 해 주지 않는다. 그래서 CI 단계에서 Docker로 이미지를 만들고, 만든 이미지를 ECR 주소로 푸시하는 방식이 된다.
로컬/CI 서버 AWS ECR EC2
docker build → docker push → docker pull → docker run
(이미지 생성) (업로드) (다운로드) (실행)
GitHub Actions에서 ECR에 push하는 코드
필요한 사전 설정
secrets.AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION 값은 GitHub 저장소 → Settings → Secrets and variables → Actions에서 등록해야 한다. 워크플로우 안에서는 $으로만 참조할 수 있고, 로그에는 값이 노출되지 않는다.
ECR 로그인을 왜 하냐면
Docker는 이미지를 푸시할 때 레지스트리(ECR 포함)에 인증을 요구한다. amazon-ecr-login 액션은 위에서 설정한 AWS 자격증명으로 ECR에 로그인해 두어서, 그 다음 docker push가 비공개 레지스트리에 올라갈 수 있게 해 준다.
- name: AWS 자격증명 설정
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: $
aws-secret-access-key: $
aws-region: $
- name: ECR 로그인
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Docker 이미지 빌드 및 ECR 푸시
env:
ECR_REGISTRY: $
ECR_REPOSITORY: chingoo-haja
IMAGE_TAG: $
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \
$ECR_REGISTRY/$ECR_REPOSITORY:latest
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
이미지에 두 개의 태그를 붙인다:
$(커밋 해시): “이 커밋에서 만든 이미지”를 정확히 가리킨다. 롤백할 때docker pull ...:abc123처럼 특정 버전을 지정할 수 있고, 어떤 배포가 어떤 코드에서 나왔는지 추적하기 좋다.latest: “지금 최신” 이미지를 가리킨다. 매번 같은 이름을 쓰면 EC2에서docker pull ...:latest만 해도 최신으로 갱신할 수 있다.
3. EC2 배포
CD 워크플로우 트리거 설정
CD는 CI가 성공했을 때만 실행되어야 한다. workflow_run으로 설정:
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
jobs:
deploy:
if: $
개발자가 꼭 알아둘 점
types: [completed]는 CI 워크플로우가 끝났을 때(성공이든 실패든) CD를 트리거한다는 뜻이다. 그래서 반드시 if: conclusion == 'success'를 걸어야 한다. 이 조건이 없으면 CI가 실패(테스트 실패, 빌드 실패)해도 CD가 돌아가서, 깨진 코드가 서버에 배포될 수 있다.
SSH로 EC2에 접속해서 배포
- name: EC2에 SSH 배포
uses: appleboy/ssh-action@v1.2.2
with:
host: $
username: $
key: $
script: |
# ECR에서 최신 이미지 다운로드 (아래 변수들은 job env에서 전달해야 함)
aws ecr get-login-password --region $AWS_REGION | \
docker login --username AWS --password-stdin $ECR_REGISTRY
docker pull $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
# 기존 컨테이너 종료 & 삭제
docker stop chingoo-haja || true
docker rm chingoo-haja || true
# 새 컨테이너 실행
docker run -d \
--name chingoo-haja \
--restart unless-stopped \
-p 8080:8080 \
--env-file $HOME/app/.env \
-v $HOME/app/firebase-service-account.json:/firebase-service-account.json:ro \
$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
각 옵션의 의미:
-d: 백그라운드 실행 (detached mode)--name chingoo-haja: 컨테이너에 이름 부여--restart unless-stopped: 서버 재시작 시 자동으로 컨테이너도 재시작-p 8080:8080: 호스트의 8080 포트 → 컨테이너의 8080 포트로 연결--env-file: 환경변수 파일 주입-v: 파일 시스템 마운트 (볼륨).:ro는 read-only라서 컨테이너 안에서는 해당 경로를 수정할 수 없고, 보안·실수 방지에 유리하다.
EC2 script에서 쓰는 변수
$ECR_REGISTRY, $ECR_REPOSITORY, $IMAGE_TAG, $AWS_REGION은 CD job의 env에서 정의해 두어야 한다. ECR 푸시 단계에서 쓴 값과 동일하게, 같은 워크플로우 안에서 env나 steps.xxx.outputs로 넘겨 주면 된다. 이 값들이 없으면 EC2에 SSH로 들어가도 “어떤 이미지를 pull할지” 알 수 없다.
4. 트러블슈팅: 실제로 겪은 5가지 문제
문제 1: docker: command not found
증상: CD가 실패하면서 “docker: command not found” 에러
원인: EC2 인스턴스에 Docker가 설치되어 있지 않았다.
해결: EC2에 SSH 접속 후 Docker 직접 설치
sudo yum install -y docker
sudo systemctl start docker
sudo systemctl enable docker # 서버 재시작 시 자동 시작
sudo usermod -aG docker ec2-user # sudo 없이 docker 명령 실행
참고: usermod -aG docker ec2-user를 적용한 뒤에는 해당 사용자로 한 번 재로그인해야 그룹 변경이 반영된다. SSH로 접속한 세션이라면 한 번 끊었다가 다시 접속하거나, 새 터미널에서 SSH로 들어가면 된다.
문제 2: address already in use (포트 8080 충돌)
증상: 새 컨테이너 실행 시 “bind: address already in use” 에러
원인: 기존에 java -jar 방식으로 직접 실행 중이던 Spring Boot 프로세스가 아직 8080 포트를 점유하고 있었다.
해결: Docker 배포 전 포트를 강제로 해제
sudo fuser -k 8080/tcp || true
fuser는 특정 포트를 사용 중인 프로세스를 찾아서 종료하는 명령이다. || true는 “이미 아무도 없어도 에러 내지 마라”는 의미.
문제 3: 컨테이너가 created 상태에서 멈춤
증상: docker ps에서 컨테이너가 created 상태 (실행 전 상태)로 멈춰있음
원인: 포트 충돌로 인해 컨테이너가 시작 자체를 못 함
해결: 헬스체크 코드에서 created도 실패로 처리
STATUS=$(docker inspect --format='' chingoo-haja)
if [ "$STATUS" = "exited" ] || [ "$STATUS" = "dead" ] || [ "$STATUS" = "created" ]; then
echo "컨테이너 기동 실패 (status: $STATUS)"
docker logs chingoo-haja --tail 50
exit 1
fi
이 검사는 CD 스크립트 안에서 docker run 직후(또는 짧은 대기 후) 실행한다. exit 1로 빠지면 GitHub Actions job이 실패 처리되어 “배포는 했는데 컨테이너가 안 떴다”는 상황을 CI/CD 단계에서 바로 알 수 있다.
문제 4: .env 파일 수정 후에도 변경이 반영되지 않음
증상: EC2의 .env 파일을 수정하고 docker restart를 했는데 여전히 이전 값이 사용됨
원인: docker restart는 --env-file을 다시 읽지 않는다!
이게 초보자가 가장 많이 실수하는 부분이다. Docker 컨테이너의 환경변수는 docker run 시점에 한 번만 읽혀서 컨테이너 내부에 고정된다. docker restart는 같은 환경변수로 재시작할 뿐이다.
# ❌ 이렇게 해도 .env 변경사항이 반영되지 않음
docker restart chingoo-haja
# ✅ 컨테이너를 완전히 삭제하고 새로 실행해야 함
docker stop chingoo-haja && docker rm chingoo-haja
docker run -d --name chingoo-haja --env-file ~/app/.env ...
문제 5: Firebase 서비스 계정 JSON 파일 누락
증상: 컨테이너 시작 시 FileNotFoundException: class path resource [firebase-service-account.json] cannot be opened
원인: Firebase 인증에 필요한 JSON 파일은 보안상 .gitignore에 등록되어 있다. 따라서 Git에 커밋되지 않고, Docker 이미지에도 포함되지 않는다.
잘못된 접근: 파일을 Git에 커밋 → 보안 사고
올바른 접근: 두 가지 변경
- 코드 수정:
ClassPathResource→ResourceLoader로 변경
ClassPathResource는 JAR/classpath 안의 리소스만 찾을 수 있다. 서버에서는 파일을 컨테이너 밖(EC2 경로)에 두고 file: 경로로 넘기므로, Spring의 Resource 추상화를 쓰는 ResourceLoader.getResource(path)로 바꾸면 classpath:, file: 둘 다 지원한다. path에 file:/firebase-service-account.json처럼 넘기면 컨테이너 내부의 해당 경로(볼륨으로 마운트된 파일)를 읽게 된다.
// Before: JAR 내부에서만 파일을 찾음
ClassPathResource serviceAccount = new ClassPathResource(path);
// After: classpath:, file: 등 다양한 위치 지원
Resource serviceAccount = resourceLoader.getResource(path);
- 파일을 EC2에 직접 올리고 볼륨 마운트
# EC2에 파일 업로드 (로컬에서)
scp -i key.pem firebase-service-account.json ec2-user@<EC2_IP>:~/app/
# .env에서 경로 설정
FIREBASE_SERVICE_ACCOUNT_PATH=file:/firebase-service-account.json
# cd.yml에서 볼륨 마운트
docker run -d \
-v $HOME/app/firebase-service-account.json:/firebase-service-account.json:ro \
...
file: 접두사는 파일 시스템 경로를, classpath: 접두사는 JAR 내부를 의미한다.
5. IAM Instance Role: 장기 자격증명 없이 ECR 접근
처음에는 EC2의 배포 스크립트에 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY를 환경변수로 넘기려 했다. 이는 보안상 좋지 않다.
더 나은 방법: IAM Instance Role
EC2 인스턴스 자체에 IAM 역할(Instance Profile)을 붙여 두면, 그 EC2 안에서 돌아가는 프로세스(배포 스크립트 포함)가 별도 액세스 키 없이 해당 역할의 권한을 쓰게 된다. AWS 콘솔에서 IAM 역할을 만들고 “EC2가 ECR에서 pull할 수 있음” 같은 정책을 붙인 뒤, EC2 인스턴스 설정에서 “해당 역할 연결”만 해 주면 된다. 그러면 EC2 안에서는 위처럼 액세스 키 없이 aws ecr get-login-password가 동작한다.
# EC2 내에서 자격증명 없이 ECR 로그인 가능
aws ecr get-login-password --region ap-northeast-2 | \
docker login --username AWS --password-stdin <ECR_URI>
6. 환경변수 관리: .env 파일 전략
운영 서버에는 수십 개의 민감한 설정값(DB URL, 비밀번호, API 키 등)이 필요하다. 이를 ~/app/.env 파일에 한 줄에 KEY=VALUE 형태로 모아 두고, Docker 실행 시 --env-file로 넘기면 컨테이너 안의 프로세스(예: Spring Boot)가 그 값을 환경변수로 읽을 수 있다. Spring은 SPRING_PROFILES_ACTIVE, MYSQL_URL 같은 이름을 자동으로 인식한다.
# ~/app/.env
SPRING_PROFILES_ACTIVE=prod
MYSQL_URL=jdbc:mysql://db.amazonaws.com:3306/chingoo_db?...
MYSQL_USERNAME=admin
MYSQL_PASSWORD=secret
REDIS_HOST=my-redis.cache.amazonaws.com
JWT_SECRET=very-long-secret-key
FIREBASE_SERVICE_ACCOUNT_PATH=file:/firebase-service-account.json
# Docker 실행 시 주입
docker run --env-file ~/app/.env ...
주의할 점: 이 .env 파일에는 비밀번호·API 키가 들어가므로 절대 Git에 올리면 안 된다. 저장소 루트의 .gitignore에 .env를 추가하고, 운영 서버용 값은 EC2에만 직접 만들어 두거나 시크릿 관리 도구를 쓰는 것이 안전하다.
마무리: 완성된 CD 흐름
git push main
│
▼
GitHub Actions CI (ci.yml)
- Gradle 빌드
- 단위/통합 테스트 실행
│
│ 성공 시
▼
GitHub Actions CD (cd.yml)
- Docker 이미지 빌드
- AWS ECR에 push
- EC2에 SSH 접속
├─ ECR에서 새 이미지 pull
├─ 기존 컨테이너 종료 & 삭제
├─ 포트 해제 (fuser)
├─ 새 컨테이너 실행 (볼륨 마운트 포함)
└─ 기동 상태 확인 (최대 60초)
이 과정을 직접 구축하면서 가장 많이 배운 것은 “왜 안 되는지”를 로그에서 찾는 능력이다. 에러 메시지를 끝까지 읽고, 그 원인을 이해하고, 해결하는 과정이 인프라 공부의 핵심이라고 생각한다.