Skip to content

Commit 32846d5

Browse files
committed
feat: ADD new post - 2026-02-26-diary.md
1 parent 3de8ba9 commit 32846d5

1 file changed

Lines changed: 346 additions & 0 deletions

File tree

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
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+
uses: appleboy/[email protected]
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

Comments
 (0)