|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +collection: project_diary |
| 4 | +title: 친구하자 프로젝트 일기 - CI/CD 파이프라인 구축기 (GitHub Actions + Docker + ECR/EC2) |
| 5 | +description: > |
| 6 | + 이전 글에서 다룬 CI/CD·Docker·ECR·EC2에 이어, 실제 파이프라인 구축 과정과 EC2 배포 시 겪은 트러블슈팅을 정리했습니다. |
| 7 | +sitemap: false |
| 8 | +--- |
| 9 | + |
| 10 | +# [친구하자] CI/CD 파이프라인 구축기: GitHub Actions + Docker + AWS ECR/EC2 배포 자동화 |
| 11 | + |
| 12 | +> [이전 일기: Android 앱을 만들며 배운 CI/CD와 AWS EC2·ECS](https://nan0silver.github.io/projectdiary/2026-02-24-diary/)에서 CI/CD, Docker, ECR, EC2 개념과 전체 흐름을 다뤘다. |
| 13 | +> 이번에는 **실제로 파이프라인을 구축한 과정**과 **배포하면서 겪은 트러블슈팅**을 중심으로 정리한다. |
| 14 | +
|
| 15 | +- [1. 전체 아키텍처](#1-전체-아키텍처) |
| 16 | +- [2. AWS ECR에 이미지 푸시](#2-aws-ecr에-이미지-푸시) |
| 17 | +- [3. EC2 배포](#3-ec2-배포) |
| 18 | +- [4. 트러블슈팅: 실제로 겪은 5가지 문제](#4-트러블슈팅-실제로-겪은-5가지-문제) |
| 19 | +- [5. IAM Instance Role](#5-iam-instance-role-장기-자격증명-없이-ecr-접근) |
| 20 | +- [6. 환경변수 관리](#6-환경변수-관리-env-파일-전략) |
| 21 | +- [마무리](#마무리-완성된-cd-흐름) |
| 22 | + |
| 23 | +--- |
| 24 | + |
| 25 | +## 1. 전체 아키텍처 |
| 26 | + |
| 27 | +``` |
| 28 | +[ 개발자 ] |
| 29 | + │ git push (main 브랜치) |
| 30 | + ↓ |
| 31 | +[ GitHub ] |
| 32 | + │ CI 워크플로우 실행 (ci.yml) |
| 33 | + │ - Gradle 빌드 |
| 34 | + │ - 테스트 실행 |
| 35 | + ↓ (CI 성공 시) |
| 36 | +[ CD 워크플로우 실행 (cd.yml) ] |
| 37 | + │ |
| 38 | + ├─ Docker 이미지 빌드 |
| 39 | + │ |
| 40 | + ├─ AWS ECR(컨테이너 레지스트리)에 이미지 push |
| 41 | + │ |
| 42 | + └─ EC2 서버에 SSH 접속 |
| 43 | + │ |
| 44 | + ├─ ECR에서 새 이미지 pull |
| 45 | + ├─ 기존 컨테이너 종료 & 삭제 |
| 46 | + └─ 새 컨테이너 실행 |
| 47 | +``` |
| 48 | + |
| 49 | +**왜 CI와 CD를 나눌까?** |
| 50 | +CI 워크플로우(ci.yml)는 push할 때마다 빌드·테스트만 하고, CD 워크플로우(cd.yml)는 **CI가 성공했을 때만** 배포를 맡는다. 이렇게 나누면 테스트가 실패한 코드는 절대 서버에 올라가지 않는다. 한 파일에 다 넣어도 되지만, `workflow_run`으로 “다른 워크플로우 완료”를 트리거로 쓰면 CI 결과에 따라 CD 실행 여부를 분리하기 쉽다. |
| 51 | + |
| 52 | +--- |
| 53 | + |
| 54 | +## 2. AWS ECR에 이미지 푸시 |
| 55 | + |
| 56 | +이전 글에서 ECR의 역할을 다뤘다. 여기서는 **GitHub Actions에서 ECR에 푸시하는 방법**을 본다. |
| 57 | + |
| 58 | +GitHub Actions 워크플로우는 GitHub이 제공하는 **Runner(가상 머신)** 위에서 실행된다. 이 Runner에는 Docker가 미리 설치되어 있어서, 워크플로우 안에서 `docker build`, `docker tag`, `docker push` 같은 **Docker CLI 명령어**를 그대로 쓸 수 있다. 즉, "이미지 빌드"는 Runner에서 Docker로 하고, "저장"만 ECR에 하는 구조다. ECR은 이미지 보관소일 뿐, 빌드를 대신 해 주지 않는다. 그래서 CI 단계에서 Docker로 이미지를 만들고, 만든 이미지를 ECR 주소로 푸시하는 방식이 된다. |
| 59 | + |
| 60 | +``` |
| 61 | +로컬/CI 서버 AWS ECR EC2 |
| 62 | +docker build → docker push → docker pull → docker run |
| 63 | +(이미지 생성) (업로드) (다운로드) (실행) |
| 64 | +``` |
| 65 | + |
| 66 | +### GitHub Actions에서 ECR에 push하는 코드 |
| 67 | + |
| 68 | +**필요한 사전 설정** |
| 69 | +`secrets.AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` 값은 **GitHub 저장소 → Settings → Secrets and variables → Actions**에서 등록해야 한다. 워크플로우 안에서는 `${{ secrets.이름 }}`으로만 참조할 수 있고, 로그에는 값이 노출되지 않는다. |
| 70 | + |
| 71 | +**ECR 로그인을 왜 하냐면** |
| 72 | +Docker는 이미지를 푸시할 때 레지스트리(ECR 포함)에 **인증**을 요구한다. `amazon-ecr-login` 액션은 위에서 설정한 AWS 자격증명으로 ECR에 로그인해 두어서, 그 다음 `docker push`가 비공개 레지스트리에 올라갈 수 있게 해 준다. |
| 73 | + |
| 74 | +```yaml |
| 75 | +- name: AWS 자격증명 설정 |
| 76 | + uses: aws-actions/configure-aws-credentials@v4 |
| 77 | + with: |
| 78 | + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} |
| 79 | + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} |
| 80 | + aws-region: ${{ secrets.AWS_REGION }} |
| 81 | + |
| 82 | +- name: ECR 로그인 |
| 83 | + id: login-ecr |
| 84 | + uses: aws-actions/amazon-ecr-login@v2 |
| 85 | + |
| 86 | +- name: Docker 이미지 빌드 및 ECR 푸시 |
| 87 | + env: |
| 88 | + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} |
| 89 | + ECR_REPOSITORY: chingoo-haja |
| 90 | + IMAGE_TAG: ${{ github.sha }} |
| 91 | + run: | |
| 92 | + docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . |
| 93 | + docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \ |
| 94 | + $ECR_REGISTRY/$ECR_REPOSITORY:latest |
| 95 | + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG |
| 96 | + docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest |
| 97 | +``` |
| 98 | +
|
| 99 | +이미지에 **두 개의 태그**를 붙인다: |
| 100 | +
|
| 101 | +- **`${{ github.sha }}`** (커밋 해시): “이 커밋에서 만든 이미지”를 정확히 가리킨다. 롤백할 때 `docker pull ...:abc123`처럼 특정 버전을 지정할 수 있고, 어떤 배포가 어떤 코드에서 나왔는지 추적하기 좋다. |
| 102 | +- **`latest`**: “지금 최신” 이미지를 가리킨다. 매번 같은 이름을 쓰면 EC2에서 `docker pull ...:latest`만 해도 최신으로 갱신할 수 있다. |
| 103 | + |
| 104 | +--- |
| 105 | + |
| 106 | +## 3. EC2 배포 |
| 107 | + |
| 108 | +### CD 워크플로우 트리거 설정 |
| 109 | + |
| 110 | +CD는 CI가 성공했을 때만 실행되어야 한다. `workflow_run`으로 설정: |
| 111 | + |
| 112 | +```yaml |
| 113 | +on: |
| 114 | + workflow_run: |
| 115 | + workflows: ["CI"] |
| 116 | + types: [completed] |
| 117 | + branches: [main] |
| 118 | +
|
| 119 | +jobs: |
| 120 | + deploy: |
| 121 | + if: ${{ github.event.workflow_run.conclusion == 'success' }} |
| 122 | +``` |
| 123 | + |
| 124 | +**개발자가 꼭 알아둘 점** |
| 125 | +`types: [completed]`는 CI 워크플로우가 **끝났을 때**(성공이든 실패든) CD를 트리거한다는 뜻이다. 그래서 **반드시** `if: conclusion == 'success'`를 걸어야 한다. 이 조건이 없으면 CI가 실패(테스트 실패, 빌드 실패)해도 CD가 돌아가서, 깨진 코드가 서버에 배포될 수 있다. |
| 126 | + |
| 127 | +### SSH로 EC2에 접속해서 배포 |
| 128 | + |
| 129 | +```yaml |
| 130 | +- name: EC2에 SSH 배포 |
| 131 | + |
| 132 | + with: |
| 133 | + host: ${{ secrets.EC2_HOST }} |
| 134 | + username: ${{ secrets.EC2_USER }} |
| 135 | + key: ${{ secrets.EC2_SSH_KEY }} |
| 136 | + script: | |
| 137 | + # ECR에서 최신 이미지 다운로드 (아래 변수들은 job env에서 전달해야 함) |
| 138 | + aws ecr get-login-password --region $AWS_REGION | \ |
| 139 | + docker login --username AWS --password-stdin $ECR_REGISTRY |
| 140 | +
|
| 141 | + docker pull $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG |
| 142 | +
|
| 143 | + # 기존 컨테이너 종료 & 삭제 |
| 144 | + docker stop chingoo-haja || true |
| 145 | + docker rm chingoo-haja || true |
| 146 | +
|
| 147 | + # 새 컨테이너 실행 |
| 148 | + docker run -d \ |
| 149 | + --name chingoo-haja \ |
| 150 | + --restart unless-stopped \ |
| 151 | + -p 8080:8080 \ |
| 152 | + --env-file $HOME/app/.env \ |
| 153 | + -v $HOME/app/firebase-service-account.json:/firebase-service-account.json:ro \ |
| 154 | + $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG |
| 155 | +``` |
| 156 | + |
| 157 | +각 옵션의 의미: |
| 158 | + |
| 159 | +- `-d`: 백그라운드 실행 (detached mode) |
| 160 | +- `--name chingoo-haja`: 컨테이너에 이름 부여 |
| 161 | +- `--restart unless-stopped`: 서버 재시작 시 자동으로 컨테이너도 재시작 |
| 162 | +- `-p 8080:8080`: 호스트의 8080 포트 → 컨테이너의 8080 포트로 연결 |
| 163 | +- `--env-file`: 환경변수 파일 주입 |
| 164 | +- `-v`: 파일 시스템 마운트 (볼륨). `:ro`는 read-only라서 컨테이너 안에서는 해당 경로를 수정할 수 없고, 보안·실수 방지에 유리하다. |
| 165 | + |
| 166 | +**EC2 script에서 쓰는 변수** |
| 167 | +`$ECR_REGISTRY`, `$ECR_REPOSITORY`, `$IMAGE_TAG`, `$AWS_REGION`은 CD job의 `env`에서 정의해 두어야 한다. ECR 푸시 단계에서 쓴 값과 동일하게, 같은 워크플로우 안에서 `env`나 `steps.xxx.outputs`로 넘겨 주면 된다. 이 값들이 없으면 EC2에 SSH로 들어가도 “어떤 이미지를 pull할지” 알 수 없다. |
| 168 | + |
| 169 | +--- |
| 170 | + |
| 171 | +## 4. 트러블슈팅: 실제로 겪은 5가지 문제 |
| 172 | + |
| 173 | +### 문제 1: `docker: command not found` |
| 174 | + |
| 175 | +**증상**: CD가 실패하면서 "docker: command not found" 에러 |
| 176 | + |
| 177 | +**원인**: EC2 인스턴스에 Docker가 설치되어 있지 않았다. |
| 178 | + |
| 179 | +**해결**: EC2에 SSH 접속 후 Docker 직접 설치 |
| 180 | + |
| 181 | +```bash |
| 182 | +sudo yum install -y docker |
| 183 | +sudo systemctl start docker |
| 184 | +sudo systemctl enable docker # 서버 재시작 시 자동 시작 |
| 185 | +sudo usermod -aG docker ec2-user # sudo 없이 docker 명령 실행 |
| 186 | +``` |
| 187 | + |
| 188 | +**참고**: `usermod -aG docker ec2-user`를 적용한 뒤에는 **해당 사용자로 한 번 재로그인**해야 그룹 변경이 반영된다. SSH로 접속한 세션이라면 한 번 끊었다가 다시 접속하거나, 새 터미널에서 SSH로 들어가면 된다. |
| 189 | + |
| 190 | +### 문제 2: `address already in use` (포트 8080 충돌) |
| 191 | + |
| 192 | +**증상**: 새 컨테이너 실행 시 "bind: address already in use" 에러 |
| 193 | + |
| 194 | +**원인**: 기존에 `java -jar` 방식으로 직접 실행 중이던 Spring Boot 프로세스가 아직 8080 포트를 점유하고 있었다. |
| 195 | + |
| 196 | +**해결**: Docker 배포 전 포트를 강제로 해제 |
| 197 | + |
| 198 | +```bash |
| 199 | +sudo fuser -k 8080/tcp || true |
| 200 | +``` |
| 201 | + |
| 202 | +`fuser`는 특정 포트를 사용 중인 프로세스를 찾아서 종료하는 명령이다. `|| true`는 "이미 아무도 없어도 에러 내지 마라"는 의미. |
| 203 | + |
| 204 | +### 문제 3: 컨테이너가 `created` 상태에서 멈춤 |
| 205 | + |
| 206 | +**증상**: `docker ps`에서 컨테이너가 `created` 상태 (실행 전 상태)로 멈춰있음 |
| 207 | + |
| 208 | +**원인**: 포트 충돌로 인해 컨테이너가 시작 자체를 못 함 |
| 209 | + |
| 210 | +**해결**: 헬스체크 코드에서 `created`도 실패로 처리 |
| 211 | + |
| 212 | +```bash |
| 213 | +STATUS=$(docker inspect --format='{{.State.Status}}' chingoo-haja) |
| 214 | +if [ "$STATUS" = "exited" ] || [ "$STATUS" = "dead" ] || [ "$STATUS" = "created" ]; then |
| 215 | + echo "컨테이너 기동 실패 (status: $STATUS)" |
| 216 | + docker logs chingoo-haja --tail 50 |
| 217 | + exit 1 |
| 218 | +fi |
| 219 | +``` |
| 220 | + |
| 221 | +이 검사는 **CD 스크립트 안에서** `docker run` 직후(또는 짧은 대기 후) 실행한다. `exit 1`로 빠지면 GitHub Actions job이 실패 처리되어 “배포는 했는데 컨테이너가 안 떴다”는 상황을 CI/CD 단계에서 바로 알 수 있다. |
| 222 | + |
| 223 | +### 문제 4: `.env` 파일 수정 후에도 변경이 반영되지 않음 |
| 224 | + |
| 225 | +**증상**: EC2의 `.env` 파일을 수정하고 `docker restart`를 했는데 여전히 이전 값이 사용됨 |
| 226 | + |
| 227 | +**원인**: **`docker restart`는 `--env-file`을 다시 읽지 않는다!** |
| 228 | + |
| 229 | +이게 초보자가 가장 많이 실수하는 부분이다. Docker 컨테이너의 환경변수는 `docker run` 시점에 한 번만 읽혀서 컨테이너 내부에 고정된다. `docker restart`는 같은 환경변수로 재시작할 뿐이다. |
| 230 | + |
| 231 | +```bash |
| 232 | +# ❌ 이렇게 해도 .env 변경사항이 반영되지 않음 |
| 233 | +docker restart chingoo-haja |
| 234 | +
|
| 235 | +# ✅ 컨테이너를 완전히 삭제하고 새로 실행해야 함 |
| 236 | +docker stop chingoo-haja && docker rm chingoo-haja |
| 237 | +docker run -d --name chingoo-haja --env-file ~/app/.env ... |
| 238 | +``` |
| 239 | + |
| 240 | +### 문제 5: Firebase 서비스 계정 JSON 파일 누락 |
| 241 | + |
| 242 | +**증상**: 컨테이너 시작 시 `FileNotFoundException: class path resource [firebase-service-account.json] cannot be opened` |
| 243 | + |
| 244 | +**원인**: Firebase 인증에 필요한 JSON 파일은 보안상 `.gitignore`에 등록되어 있다. 따라서 Git에 커밋되지 않고, Docker 이미지에도 포함되지 않는다. |
| 245 | + |
| 246 | +**잘못된 접근**: 파일을 Git에 커밋 → 보안 사고 |
| 247 | + |
| 248 | +**올바른 접근**: 두 가지 변경 |
| 249 | + |
| 250 | +1. **코드 수정**: `ClassPathResource` → `ResourceLoader`로 변경 |
| 251 | + |
| 252 | +`ClassPathResource`는 JAR/classpath 안의 리소스만 찾을 수 있다. 서버에서는 파일을 컨테이너 밖(EC2 경로)에 두고 `file:` 경로로 넘기므로, Spring의 `Resource` 추상화를 쓰는 `ResourceLoader.getResource(path)`로 바꾸면 `classpath:`, `file:` 둘 다 지원한다. `path`에 `file:/firebase-service-account.json`처럼 넘기면 컨테이너 내부의 해당 경로(볼륨으로 마운트된 파일)를 읽게 된다. |
| 253 | + |
| 254 | +```java |
| 255 | +// Before: JAR 내부에서만 파일을 찾음 |
| 256 | +ClassPathResource serviceAccount = new ClassPathResource(path); |
| 257 | +
|
| 258 | +// After: classpath:, file: 등 다양한 위치 지원 |
| 259 | +Resource serviceAccount = resourceLoader.getResource(path); |
| 260 | +``` |
| 261 | + |
| 262 | +2. **파일을 EC2에 직접 올리고 볼륨 마운트** |
| 263 | + |
| 264 | +```bash |
| 265 | +# EC2에 파일 업로드 (로컬에서) |
| 266 | +scp -i key.pem firebase-service-account.json ec2-user@<EC2_IP>:~/app/ |
| 267 | +
|
| 268 | +# .env에서 경로 설정 |
| 269 | +FIREBASE_SERVICE_ACCOUNT_PATH=file:/firebase-service-account.json |
| 270 | +``` |
| 271 | + |
| 272 | +```yaml |
| 273 | +# cd.yml에서 볼륨 마운트 |
| 274 | +docker run -d \ |
| 275 | +-v $HOME/app/firebase-service-account.json:/firebase-service-account.json:ro \ |
| 276 | +... |
| 277 | +``` |
| 278 | + |
| 279 | +`file:` 접두사는 파일 시스템 경로를, `classpath:` 접두사는 JAR 내부를 의미한다. |
| 280 | + |
| 281 | +--- |
| 282 | + |
| 283 | +## 5. IAM Instance Role: 장기 자격증명 없이 ECR 접근 |
| 284 | + |
| 285 | +처음에는 EC2의 배포 스크립트에 `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`를 환경변수로 넘기려 했다. 이는 보안상 좋지 않다. |
| 286 | + |
| 287 | +**더 나은 방법: IAM Instance Role** |
| 288 | + |
| 289 | +EC2 인스턴스 자체에 **IAM 역할(Instance Profile)**을 붙여 두면, 그 EC2 안에서 돌아가는 프로세스(배포 스크립트 포함)가 **별도 액세스 키 없이** 해당 역할의 권한을 쓰게 된다. AWS 콘솔에서 IAM 역할을 만들고 “EC2가 ECR에서 pull할 수 있음” 같은 정책을 붙인 뒤, EC2 인스턴스 설정에서 “해당 역할 연결”만 해 주면 된다. 그러면 EC2 안에서는 위처럼 액세스 키 없이 `aws ecr get-login-password`가 동작한다. |
| 290 | + |
| 291 | +```bash |
| 292 | +# EC2 내에서 자격증명 없이 ECR 로그인 가능 |
| 293 | +aws ecr get-login-password --region ap-northeast-2 | \ |
| 294 | + docker login --username AWS --password-stdin <ECR_URI> |
| 295 | +``` |
| 296 | + |
| 297 | +--- |
| 298 | + |
| 299 | +## 6. 환경변수 관리: .env 파일 전략 |
| 300 | + |
| 301 | +운영 서버에는 수십 개의 민감한 설정값(DB URL, 비밀번호, API 키 등)이 필요하다. 이를 `~/app/.env` 파일에 한 줄에 `KEY=VALUE` 형태로 모아 두고, Docker 실행 시 `--env-file`로 넘기면 컨테이너 안의 프로세스(예: Spring Boot)가 그 값을 환경변수로 읽을 수 있다. Spring은 `SPRING_PROFILES_ACTIVE`, `MYSQL_URL` 같은 이름을 자동으로 인식한다. |
| 302 | + |
| 303 | +```bash |
| 304 | +# ~/app/.env |
| 305 | +SPRING_PROFILES_ACTIVE=prod |
| 306 | +MYSQL_URL=jdbc:mysql://db.amazonaws.com:3306/chingoo_db?... |
| 307 | +MYSQL_USERNAME=admin |
| 308 | +MYSQL_PASSWORD=secret |
| 309 | +REDIS_HOST=my-redis.cache.amazonaws.com |
| 310 | +JWT_SECRET=very-long-secret-key |
| 311 | +FIREBASE_SERVICE_ACCOUNT_PATH=file:/firebase-service-account.json |
| 312 | +``` |
| 313 | + |
| 314 | +```bash |
| 315 | +# Docker 실행 시 주입 |
| 316 | +docker run --env-file ~/app/.env ... |
| 317 | +``` |
| 318 | + |
| 319 | +**주의할 점**: 이 `.env` 파일에는 비밀번호·API 키가 들어가므로 **절대 Git에 올리면 안 된다.** 저장소 루트의 `.gitignore`에 `.env`를 추가하고, 운영 서버용 값은 EC2에만 직접 만들어 두거나 시크릿 관리 도구를 쓰는 것이 안전하다. |
| 320 | + |
| 321 | +--- |
| 322 | + |
| 323 | +## 마무리: 완성된 CD 흐름 |
| 324 | + |
| 325 | +``` |
| 326 | +git push main |
| 327 | + │ |
| 328 | + ▼ |
| 329 | +GitHub Actions CI (ci.yml) |
| 330 | + - Gradle 빌드 |
| 331 | + - 단위/통합 테스트 실행 |
| 332 | + │ |
| 333 | + │ 성공 시 |
| 334 | + ▼ |
| 335 | +GitHub Actions CD (cd.yml) |
| 336 | + - Docker 이미지 빌드 |
| 337 | + - AWS ECR에 push |
| 338 | + - EC2에 SSH 접속 |
| 339 | + ├─ ECR에서 새 이미지 pull |
| 340 | + ├─ 기존 컨테이너 종료 & 삭제 |
| 341 | + ├─ 포트 해제 (fuser) |
| 342 | + ├─ 새 컨테이너 실행 (볼륨 마운트 포함) |
| 343 | + └─ 기동 상태 확인 (최대 60초) |
| 344 | +``` |
| 345 | +
|
| 346 | +이 과정을 직접 구축하면서 가장 많이 배운 것은 **"왜 안 되는지"를 로그에서 찾는 능력**이다. 에러 메시지를 끝까지 읽고, 그 원인을 이해하고, 해결하는 과정이 인프라 공부의 핵심이라고 생각한다. |
0 commit comments