1~3편에서 “CI 빌드(Gradle)”와 “Docker 이미지 빌드/푸시(DockerHub)”까지 끝냈다.
4편은 그 다음 단계인 배포(Deploy), 즉 EC2에서 최신 이미지를 pull 받고 컨테이너를 교체 실행하는 과정을 다룬다.
이번 편의 핵심은 3가지다.
- SSH 없이 GitHub Actions에서 SSM으로 EC2에 명령을 내려 배포하기
application.yml을 시크릿으로 만들어 Docker 이미지에 넣는 방식이 왜 위험한지
deploy job 전체 구조 (build 성공 후, main 또는 수동 실행에서만 배포)
워크플로우에서 deploy job은 이렇게 구성되어 있다.
deploy:
name: Deploy to EC2 with SSM
needs: build # build 작업이 성공해야 실행
runs-on: ubuntu-latest
if: >
(github.event_name == 'push' && github.ref == 'refs/heads/main')
|| github.event_name == 'workflow_dispatch'
steps:
# --- 1. AWS 자격 증명 설정 ---
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
# --- 2. AWS SSM을 통해 EC2의 deploy.sh 스크립트 실행 ---
- name: Run deploy script on EC2 via SSM
run: |
aws ssm send-command \
--instance-ids "${{ secrets.EC2_INSTANCE_ID }}" \
--document-name "AWS-RunShellScript" \
--comment "Running deploy.sh script" \
--parameters 'commands=["/bin/bash /home/ssm-user/deploy.sh ${{ secrets.DOCKERHUB_USERNAME }}"]'
needs: build덕분에, 도커 이미지 빌드/푸시가 성공해야 배포가 시작된다.if:조건 덕분에, main push 또는 workflow_dispatch(수동 실행)일 때만 배포된다.
1) Configure AWS Credentials
GitHub Actions 워크플로우가 AWS 계정에 로그인할 수 있도록 인증을 설정하는 단계이다.
이 단계가 없으면, Github Actions는 AWS 리소스(EC2, SSM 등)에 접근할 권한이 전혀 없다.
항목 설명
- 실행할 액션 지정
uses: aws-actions/configure-aws-credentials@v4
uses:Github Actions에서run:(직접 명령어 실행) 대신, 미리 만들어진 다른 사람의 코드(액션)를 가져와 사용하겠다는 뜻aws-actions/configure-aws-credentials@v4aws-actions: AWS가 공식적으로 만들어 관리하는 액션 저장소configure-aws-credentials: AWS 자격 증명 설정을 전문적으로 처리해 주는 액션 이름
- 액션에 필요한 입력 값 전달
with:
aws-access-key-id:${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key:${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region:${{ secrets.AWS_REGION }}
with::uses:로 지정한 액션에게 필요한 입력 파라미터(Inputs) 를 전달하는 부분aws-access-key-id: ...: AWS 접근 키 ID 를 설정aws-secret-access-key: ...: AWS 비밀 접근 키 를 설정aws-region: ...: 명령을 실행할 AWS 리전(Region, 지역)을 설정 (예:ap-northeast-2)
2) Run deploy script on EC2 via SSM
SSH 접속 없이 AWS SSM 서비스를 이용해, EC2 인스턴스에 원격으로 명령을 내려 deploy.sh 스크립트를 실행시키는 단계이다.
항목 설명
- 실행 명령어 정의
# --- 2. AWS SSM을 통해 EC2의 deploy.sh 스크립트 실행 ---
- name: Run deploy script on EC2 via SSM
run: |
aws ssm send-command \
--instance-ids "${{ secrets.EC2_INSTANCE_ID }}" \
--document-name "AWS-RunShellScript" \
--comment "Running deploy.sh script" \
--parameters 'commands=["/bin/bash /home/ssm-user/deploy.sh ${{ secrets.DOCKERHUB_USERNAME }}"]'
run: |: 이 단계가 Github actions 러너에서 실제로 실행할 셸 명령어를 정의한다.|: 여러 줄의 명령어를 쓰겠다는 의미이다.
aws ssm send-command:AWS CLI(명령줄 인터페이스)를 사용하는 명령어send-command: SSM의 기능 중 명령어 전송 기능을 사용
\: 명령어가 다음 줄에도 이어진다는 셸 문법
- 명령어 인자(Parameters)
send-command에 필요한 상세 옵션들
--instance-ids "${{ secrets.EC2_INSTANCE_ID }}" \
- 어떤 EC2 인스턴스에게 명령을 보낼지 지정
--document-name "AWS-RunShellScript" \
- "무엇을 할 것인지" 정의하는 SSM '문서'를 지정한다.
"AWS-RunShellScript": AWS가 미리 만들어둔 기본 문서로, "단순 셸 스크립트를 EC2에서 실행"시켜주는 기능을 한다.
--comment "Running deploy.sh script" \
- AWS SSM 실행 기록(History)에 남길 설명(주석)이다. 나중에 AWS 콘솔에서 "아, 이때 그 배포가 실행되었구나" 하고 알 수 있게 해준다.
--parameters 'commands=["/bin/bash /home/ssm-user/deploy.sh ${{ secrets.DOCKERHUB_USERNAME }}"]'
- 실제로 실행할 명령어의 내용
AWS-RunShellScript문서에commands라는 파라미터로 실제 명령어를 전달"/bin/bash ...": 최종적으로 EC2 인스턴스 내부에서 실행될 명령어/bin/bash: 이 스크립트를 실행할 해석기/home/ssm-user/deploy.sh: EC2 인스턴스 내부에 미리 준비되어 있는deploy.sh스크립트의 전체 경로${{ secrets.DOCKERHUB_USERNAME }}:deploy.sh스크립트에 전달할 첫 번째 인자($1)- 결과: EC2에서는
... deploy.sh my-docker-id(예시)와 같이 실행
3) /home/ssm-user/deploy.sh
deploy.sh는 Github Actions의 CI/CD 도구에서 Docker 이미지를 받아 EC2 서버에 자동으로 배포하기 위해 만들어진 셸 스크립트이다.
전체 흐름은 새 버전(latest) 이미지를 받고, 기존 컨테이너를 중지/삭제한 뒤, 새 이미지로 컨테이너를 띄우고, 마지막으로 오래된 이미지를 청소한다 이다.
deploy.sh
#!/bin/bash
set -euo pipefail
# --- 1. 스크립트 실행 인자 받기 ---
# $1: Docker Hub 사용자 이름(GitHub Actions에서 전달)
DOCKER_USERNAME="${1:-}"
NETWORK_NAME="oneco-network"
# 인자가 없으면 스크립트 종료
if [ -z "$DOCKER_USERNAME" ]; then
echo "오류: Docker Hub 사용자 이름이 인자로 전달되지 않았습니다."
echo "사용법: $0 <dockerhub-username>"
exit 1
fi
echo "배포 스크립트 시작..."
cd /home/ssm-user # 스크립트 실행 위치
APP_NAME="oneco"
IMAGE_NAME="${DOCKER_USERNAME}/${APP_NAME}:latest"
CONTAINER_NAME="oneco-container"
# 1. Docker Hub에서 최신 이미지를 PULL 받는다.
echo "Docker 이미지 pull..."
docker pull "$IMAGE_NAME"
# 2. 기존에 실행 중인 컨테이너가 있다면 중지하고 삭제한다.
echo "기존 컨테이너 중지 및 삭제..."
docker stop "$CONTAINER_NAME" || true
docker rm "$CONTAINER_NAME" || true
# 3. 새로운 이미지를 컨테이너로 실행한다.
echo "새 컨테이너 실행..."
docker run -d \
--env-file /opt/oneco/.env \
--network "$NETWORK_NAME" \
-p 8080:8080 \
--name "$CONTAINER_NAME" \
"$IMAGE_NAME"
# 4. 사용하지 않는 오래된 이미지를 삭제한다.
echo "오래된 이미지 정리..."
docker image prune -f
echo "배포 완료."
/bin/bash 란?
/bin이라는 디렉터리 안에 있는 bash라는 실행 파일이다.
/bin 디렉터리
- bin은 binary의 줄임말이다.
- 리눅스/macOS와 같은 유닉스 계열 운영체제에서 가장 기본적인(필수적인) 명령어 실행 파일들이 모여 있는 디렉토리이다.
- 예를 들어
ls,cp,mv,rm같이 시스템을 사용하는 데 꼭 필요한 명령어들이 이 안에 있다. - 시스템이 부팅할 때부터 바로 사용할 수 있어야 하는 중요한 파일들이다.
bash 파일
- bash는 Bourne Again Shell의 줄임말이다.
- 이 파일은 셸 프로그램이다.
- 셸이란, 사용자가 입력한 명령어를 해석해서 운영체제(커널)에 전달해 주는 역할을 하는 프로그램이다. 우리가 터미널에서 명령어를 치면, 그 명령어를 알아듣고 실행시켜 주는 게 바로 이
bash이다. - 즉
#!/bin/bash는 이 스크립트 파일은/bin디렉토리에 있는bash프로그램을 이용해서 실행해라는 뜻이다.
deploy.sh 항목 설명
- 셰뱅
#!/bin/bash
- 이 스크립트 파일을 실행할 때
/bin/bash셸(명령어 해석기)을 사용하려고 운영체제(Linux)에 알리는 약속이다. 스크립트 파일의 맨 첫 줄에 항상 있어야 한다.
- 스크립트 실행 인자 받기
# $1: Docker Hub 사용자 이름 (GitHub Actions에서 전달)
DOCKER_USERNAME=$1
# 인자가 없으면 스크립트 종료
if [ -z"$DOCKER_USERNAME" ];then
echo"오류: Docker Hub 사용자 이름이 인자로 전달되지 않았습니다."
exit 1
fi
$1: 스크립트를 실행할 때 첫 번째로 전달된 값(인자)를 의미한다.- (예:
/home/ssm-user/deploy.sh ${{ secrets.DOCKERHUB_USERNAME }}이라고 실행했다면$1은 secrets.DOCKERHUB_USERNAME에 해당하는 값이 된다.)
- (예:
if [ -z "$DOCKER_USERNAME" ]: 만약DOCKER_USERNAME변수의 내용이 비어있다면(-z)echo를 통해서 오류 메시지를 출력한 후 exit 1을 실행한다.exit 1: 스크립트를 즉시 비정상 종료시킨다. (보통 0이 정상 종료, 1 이상이 비정상 종료를 의미)- Docker 사용자 이름이 없으면 docker pull을 할 수 없으므로, 스크립트를 미리 중단시키는 것이다.
- Docker Hub에서 최신 이미지를 PULL 받는다.
echo"배포 스크립트 시작..."
cd /home/ssm-user# 스크립트 실행 위치 (필요시 수정)
# 1. Docker Hub에서 최신 이미지를 PULL 받는다.
echo"Docker 이미지 pull..."
docker pull${DOCKER_USERNAME}/oneco:latest
- 스크립트를 실행하는 현재 위치를
/home/ssm-user로 이동한다. - Docker Hub에서 이미지를 다운로드한다.
- oneco: 이미지 이름
:latest: 이미지의 태그 (최신 버전)
- 기존에 실행 중인 컨테이너가 있다면 중지하고 삭제
echo"기존 컨테이너 중지 및 삭제..."
docker stop oneco-container ||true
dockerrm oneco-container ||true
docker stop oneco-container:oneco-container라는 이름의 실행 중인 컨테이너를 중지시킨다.docker rm oneco-container:oneco-container라는 이름의 (중지된) 컨테이너를 삭제한다.|| true의 의미:||(OR 연산자): 왼쪽 명령어가 실패(오류)하면 오른쪽 명령어를 실행하라는 의미true: 아무것도 하지 않고 항상 '성공'으로 종료되는 명령어- 이유: 만약 스크립트를 처음 실행한다면
oneco-container가 존재하지 않아서docker stop이나docker rm명령어가 오류를 내고 스크립트가 중단된다. - 결론:
|| true를 붙이면 "컨테이너가 있어서 중지/삭제에 성공하든, 컨테이너가 없어서 명령어 자체는 실패하든, 스크립트가 중단되지 말고 그냥 다음으로 넘어가라"라는 의미가 된다.
- 새 컨테이너 실행(배포)
echo"새 컨테이너 실행..."
docker run -d -p 8080:8080 --name oneco-container${DOCKER_USERNAME}/oneco:latest
- 명령어:
docker run - 의미: 새 컨테이너를 생성하고 실행한다.
- 옵션 상세:
d: Detached 모드. 컨테이너를 백그라운드에서 실행시킨다. (이걸 안 붙이면 스크립트가 여기서 멈춘다.)p 8080:8080: 포트(Port) 매핑.[호스트 포트]:[컨테이너 내부 포트]- 즉, "서버(EC2)의 8080 포트로 들어오는 트래픽을 컨테이너 내부의 8080 포트로 전달하라"는 뜻
name oneco-container: 컨테이너의 이름을oneco-container로 지정한다. (이 이름으로 나중에stop,rm을 할 수 있다.) (name이 아니라-name이 정확한 옵션이다)(수정)${DOCKER_USERNAME}/oneco:latest: 실행할 이미지 (아까 pull 받은 그 이미지)
- 사용하지 않은 이미지 정리
echo"오래된 이미지 정리..."
docker image prune -f
- 명령어:
docker image prune - 의미: 사용하지 않는 이미지(Dangling)를 삭제
- Dangling 이미지?: 새 버전의
:latest이미지를 pull 받으면, 이전에:latest태그를 가지고 있던 '구 버전' 이미지는 태그가 없어진 고아(orphan) 이미지가 된다. 이런 이미지들이 쌓이면 디스크 용량을 차지한다. f: Force. "정말 삭제하시겠습니까? (y/n)" 같은 확인 질문을 묻지 말고(-f) 강제로 실행하라는 옵션이다. 스크립트에서는 필수이다.
4) application.yml 파일을 시크릿 주입하면 무엇이 문제일까?
1. 지금 쓰는 GitHub Secrets 들은 왜 괜찮냐?
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_REGIONDOCKERHUB_USERNAMEDOCKERHUB_TOKENEC2_INSTANCE_ID
이 값들은 GitHub Actions에서 이렇게만 쓰인다.
configure-aws-credentials액션이 AWS 세션 잡을 때 딱 한 번 사용docker/login-action이 Docker Hub 로그인 할 때 사용aws ssm send-command호출할 때 인스턴스 ID로 사용
즉, GitHub Actions Runner(일회용 가상머신) 안에서만 잠깐 살아 있다가:
- 코드 빌드하고
- Docker 이미지 빌드하고
- AWS/EC2에 “배포 스크립트 실행해줘” 요청 보내고
Runner가 종료되면 그 머신도 날아간다.
우리가 따로
jar파일에 박아 넣거나- Docker 이미지 안에 복사하거나
- 로그에 찍어버리지만 않으면
어디 영구 저장소에 남지 않음 → 이 사용은 아주 일반적인 패턴이라서 OK.
2. 그럼 application.yml은 뭐가 다르냐?
문제 되는 패턴은 이거다.
-name:Createapplication.yml
run: |
cd oneco/src/main/resources
echo "${{ secrets.APPLICATION_YML }}" > application.yml
-name:BuildandpushDockerimage
uses:docker/build-push-action@v6
with:
context:./oneco
...
이렇게 하면 데이터 흐름이 이렇게 된다
- GitHub Secrets(
APPLICATION_YML) → Runner - Runner가 시크릿 내용을 그대로
application.yml파일로 만든다 docker build에서oneco/src/main/resources/application.yml파일을 도커 레이어에 포함- 만들어진 이미지가 Docker Hub에 올라감
- 그 이미지를 EC2에서
docker pull해서 실행
즉, 시크릿이 들어간 application.yml이 Docker 이미지 안에 영구 저장된다.
- Docker Hub가 public이면 → 누구나 이미지 받으면
docker run --rm -it image /bin/sh하고 파일 열어서 볼 수 있음 - private이어도 →
- Hub 계정 털리거나
- EC2에 접근한 사람이 레이어/컨테이너 내용 보면 그대로 노출
그래서:
❌ “GitHub Secrets를 쓴다는 사실”이 문제가 아니라
❌ “그걸 application.yml로 만들어서 Docker 이미지에 같이 올린다”가 문제
5) 레포이 들어갈 application.yml (비밀값 없음)
레포에는 다음 예시와 같이 ${ENV_NAME} 형태로만 참조한다
DB, REDIS, JWT, OAuth 등 민감한 것들은 다 ${…}로 빼두기
spring:
profiles:
active: prod
datasource:
url:${DB_URL}
username:${DB_USERNAME}
password:${DB_PASSWORD}
jwt:
secret:${JWT_SECRET}
access-token-validity-in-seconds:${JWT_ACCESS_TOKEN_TTL:3600}
refresh-token-validity-in-seconds:${JWT_REFRESH_TOKEN_TTL:1209600}
logging:
level:
root: INFO
com.oneco: DEBUG
(여기서 spring.profiles.active: prod를 레포에 “고정”하면 운영엔 편하지만, dev/test 환경에서 오히려 불편할 수 있어 보통은 환경변수로도 빼거나 profile 분리를 한다는 선택지도 있다)(정리)
6) EC2 안의 .env 파일(레포에 올리지 않음)
#EC2 안에서
sudomkdir -p /opt/oneco
sudo nano /opt/oneco/.env
.env 파일안에 시크릿 값 작성
SPRING_PROFILES_ACTIVE=prod
# docker-compose
DB_CONTAINER_NAME=
DB_DATABASE=
DB_USERNAME=
DB_ROOT_PASSWORD=
# application
DB_URL=
- 이 .env 는 Github에도 안 올리고, Docker 이미지에도 안 들어감
- EC2에만 존재하는 비밀값 저장소
7) deploy.sh 에서 .env 를 컨테이너에 주입
echo "새 컨테이너 실행..."
docker run -d \
--env-file /opt/oneco/.env \
-p 8080:8080 \
--name"$CONTAINER_NAME" \
"$IMAGE_NAME"
--env-file /opt/oneco/.env.env에 있는 값들이 전부 컨테이너 안의 환경변수로 들어감- Spring boot가
application.yml에서${...}로 읽어감
여기까지가 GitHub Actions 기반으로 ‘빌드–이미지 유통–무중단에 가까운 컨테이너 교체 배포’까지 이어지는 자동화의 전 과정이다.
핵심은 단순히 ‘배포가 된다’가 아니라, 어떤 커밋이 어떤 이미지로 만들어졌고, 그 이미지가 어떤 방식으로 안전하게 서버까지 전달되는지를 함께 챙기는 것이다.
[CI/CD 1편] GitHub Actions + DockerHub + SSM으로 EC2 자동 배포 CI/CD 큰그림
CI/CD를 깊게 다룬 계기과거 프로젝트에서는 CI/CD가 너무 어렵게 느껴져서, 제대로 이해하지 못한 채 GPT가 제공한 워크플로우를 그대로 복사·붙여넣기 하며 구축했다. 하지만 이번 프로젝트에서
gimini.tistory.com
[CI/CD 2편] CI 빌드 파트 완전 해부: JDK 17 세팅 → Gradle 캐시 → Gradle Wrapper → build -x test ( + 트러블
이 글은 1편에서 잡은 큰그림 중, GitHub Actions의 build job을 “Runner 내부에서 실제로 어떤 일이 벌어지는지” 기준으로 풀어낸다.이번 2편에서 다루는 범위는 아래와 같다.Set up JDK 17Gradle Caching (actio
gimini.tistory.com
[CI/CD 3편] Docker 이미지 빌드/푸시 전략 완전 해부: Login → metadata(latest+sha) → buildx 캐시 → DockerHub
[CI/CD 3편] Docker 이미지 빌드/푸시 전략 완전 해부: Login → metadata(latest+sha) → buildx 캐시 → DockerHub push
gimini.tistory.com
[CI/CD 4편] SSH 없이 배포하기: GitHub Actions → AWS Credentials → SSM send-command → EC2 deploy.sh, 그리고 시
[CI/CD 4편] SSH 없이 배포하기: GitHub Actions → AWS Credentials → SSM send-command → EC2 deploy.sh, 그리고 시크릿 주입
gimini.tistory.com