지난 글에서는 배포 계획을 세우고 AWS 인프라(VPC, RDS, ECR, EC2, IAM, Parameter Store)를 구축하는 데까지 정리했다. 이번 글은 그 위에 서버를 처음으로 직접 올려 보는 3단계, 수동 배포를 다룬다. 이미지를 내 PC에서 빌드해 ECR에 올리고, EC2에서 받아 컨테이너로 실행하고, Flyway와 헬스체크로 정상 기동을 확인하고, 이미지를 교체했다가 되돌리고, 서버를 재부팅해도 스스로 살아나는지 보는 과정이다. 이 작업이 배포 자동화(6단계)가 대신해 줄 내용의 원형이라, 나중에 그대로 따라 할 수 있도록 명령어와 각 명령의 의미, 확인 기준까지 자세히 남긴다. 이어서 이 손 절차를
deploy.sh스크립트 하나로 정리하고, 가짜 도구로 모든 분기를 테스트한 뒤 EC2에서 성공·롤백 경로를 실제로 검증한 과정도 별도의 장으로 남긴다.
자동화는 사람이 하는 절차를 기계가 대신하는 것이다. 절차를 직접 겪어 보지 않으면 무엇을 자동화해야 하는지, 어디서 실패할 수 있는지, 실패했을 때 어떻게 알 수 있는지를 알 수 없다. 그래서 CI/CD(6단계)를 만들기 전에 같은 절차를 손으로 한 번 끝까지 해 보기로 했다.
| # | 확인할 것 | 성공 기준 |
|---|---|---|
| 1 | 이미지를 ECR에 올릴 수 있다 | ECR 콘솔에 태그가 보인다 |
| 2 | EC2에서 이미지를 받아 비밀값을 주입해 실행할 수 있다 | 컨테이너가 계속 실행 중이다 |
| 3 | Flyway가 RDS에 스키마를 만들고 헬스체크가 통과한다 | 로그에 마이그레이션 V1~V3, 헬스체크 UP |
| 4 | 교체와 되돌리기를 할 수 있다 | 새 태그로 바꿨다가 이전 태그로 복귀 |
| 5 | 재부팅해도 스스로 살아난다 | 재부팅 후 컨테이너가 다시 떠 있다 |
| 6 | 절차를 스크립트 하나로 정리하고 실제로 검증한다 | 성공은 종료 코드 0, 실패는 롤백 후 종료 코드 1 |
2단계에서 미뤄 둔 확인도 이 단계에서 같이 끝난다. 앱 전용 DB 계정의 권한(CREATE, ALTER 등)이 Flyway에 충분한지는 앱을 실제로 띄워 보아야 알 수 있었다.
[내 PC] [AWS]
Dockerfile ─ docker build ─▶ 이미지(태그 m1)
│ docker push
▼
ECR ◀──────────────────────── docker pull ─┐
│
[EC2: Docker] │
Parameter Store ──(env 파일)──▶ docker run ◀───┘
│ JDBC(3306)
▼
[RDS: Flyway가 스키마 생성]
이 작업이 생소했기 때문에 용어부터 정리해 둔다.
| 용어 | 설명 |
|---|---|
| 이미지 | 프로그램과 실행 환경(JRE 등)을 한 덩어리로 포장한 것. 요리로 치면 밀키트다. 한 번 만들면 내용이 바뀌지 않는다 |
| 컨테이너 | 이미지를 실제로 실행한 것. 같은 이미지로 여러 개 띄울 수 있다 |
| 레지스트리(ECR) | 이미지를 저장하고 나눠 주는 창고. 내 PC에서 올리고(push) EC2에서 받는다(pull) |
| 태그 | 이미지에 붙이는 이름표(m1, m2). 롤백은 "이전 태그의 이미지를 다시 실행"하는 일이다 |
| 태그 변경 불가(immutable) | 한 번 쓴 태그를 다른 이미지에 다시 쓸 수 없는 ECR 설정. "그 태그는 항상 그 이미지"라는 것이 보장된다 |
| 환경변수 주입 | 비밀값을 이미지에 굽지 않고, 컨테이너를 실행하는 순간에 넣는 방식 |
이 글의 명령은 아래 리소스가 이미 있다는 전제다. 이름은 2단계에서 정한 것이다.
| 리소스 | 이름 |
|---|---|
| ECR 리포지토리 | feedablackhole-server (태그 변경 불가, 이미지 10개 초과 시 오래된 것 만료) |
| EC2 | blackhole-app (Amazon Linux 2023 x86_64, Docker 설치됨, 접속은 SSM) |
| EC2 인스턴스 역할 | blackhole-ec2-role (ECR 읽기, SSM, /blackhole 파라미터 읽기) |
| RDS | blackhole-db (MySQL 8.4, 앱 전용 계정 blackhole_app) |
| Parameter Store | /blackhole/DB_URL, /blackhole/DB_USERNAME, /blackhole/DB_PASSWORD, /blackhole/JWT_SECRET |
식별 정보는 글에 넣지 않았다. 명령에 나오는 <…>는 자기 환경의 값으로 바꾼다.
| 자리표시자 | 의미 | 확인하는 곳 |
|---|---|---|
<계정ID> | 12자리 AWS 계정 번호 | 콘솔 우측 상단 계정 메뉴 |
<ECR 레지스트리> | <계정ID>.dkr.ecr.ap-northeast-2.amazonaws.com | ECR 리포지토리 목록의 URI에서 /feedablackhole-server 앞부분 |
Dockerfile과 .dockerignore가 있는 브랜치를 체크아웃해 둔다. 빌드는 작업 폴더의 파일 기준이라, 커밋하지 않은 변경도 이미지에 들어간다는 점을 기억해 둔다.| 구분 | 어디서 | 쉘 |
|---|---|---|
| 3장 | 내 PC (저장소 루트) | PowerShell |
| 4~7장 | EC2 (Session Manager로 접속한 터미널) | bash |
winget install Amazon.AWSCLI
설치 후 터미널을 새로 연다(PATH가 갱신된다). 그다음 로그인한다.
aws login --region ap-northeast-2
aws login은 콘솔 로그인으로 임시 자격을 받는 명령이다(비교적 최근 버전의 AWS CLI v2가 지원한다). 브라우저가 열리면 평소 콘솔에 로그인하듯 IAM 사용자와 MFA로 인증한다. 비밀번호나 MFA는 터미널에 입력하지 않는다.로그인이 됐는지 확인한다.
aws sts get-caller-identity
출력의 Arn이 내 IAM 사용자(…:user/<사용자 이름>)면 성공이다.
aws login이 지원되지 않는 CLI 버전이라면 CLI를 업데이트하는 것이 우선이다. 대안은 AWS CloudShell에서 빌드하는 것(저장소를 가져와야 한다)과 IAM 사용자 액세스 키를 임시로 만드는 것인데, 키는 만들면 그 순간부터 관리 대상이 되므로 되도록 피한다.
Docker가 ECR에 올리고 받으려면 ECR에 로그인해야 한다. 로그인 정보(토큰)는 AWS CLI가 발급한다.
aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin <ECR 레지스트리>
aws ecr get-login-password는 12시간짜리 임시 비밀번호를 출력하고, 파이프로 docker login의 표준입력에 넘긴다. 사용자 이름은 항상 AWS다.Login Succeeded가 나오면 성공이다. 12시간이 지나면 푸시가 denied로 막히므로 같은 명령을 다시 실행한다.저장소 루트(Dockerfile이 있는 폴더)에서 실행한다.
docker build --platform linux/amd64 --provenance=false -t <ECR 레지스트리>/feedablackhole-server:m1 .
| 옵션 | 의미 |
|---|---|
--platform linux/amd64 | EC2와 같은 x86_64용으로 만든다. 아키텍처가 다르면 EC2에서 exec format error로 시작조차 못 한다 |
--provenance=false | 최근 Docker는 빌드 출처를 증명하는 부가 항목(어테스테이션)을 이미지 옆에 붙인다. 이게 ECR에 별개의 이미지처럼 보이고 수명 주기 규칙의 "개수"에도 섞이므로 끈다 |
-t <레지스트리>/<리포지토리>:<태그> | 이미지 이름과 태그. 레지스트리 주소가 이름 앞에 붙어 있어야 docker push가 그 위치로 올린다 |
마지막 . | 빌드 컨텍스트(현재 폴더). .dockerignore에 적은 .env, .git, build 등은 제외된다 |
m1, m2처럼 순번을 썼고, 6단계 자동화에서는 커밋 SHA를 쓴다.docker push <ECR 레지스트리>/feedablackhole-server:m1
ECR 콘솔의 feedablackhole-server 리포지토리에서 태그 m1 이 보이면 성공이다. 이미지 크기와 다이제스트(이미지 내용의 해시)도 이 화면에서 볼 수 있다.
콘솔의 Systems Manager > 세션 관리자에서 blackhole-app을 선택하고 세션 시작을 누른다. 팝업 창으로 터미널이 열린다.
ssm-user로 들어온다. docker 그룹에 속한 계정으로 바꾼다.sudo -iu ec2-user
exit로 세션을 닫는다(팝업만 닫으면 세션 기록에 "종료 중"이 한동안 남을 수 있다).편의를 위해 자주 쓰는 값을 변수로 둔다. 이후 명령은 이 변수를 쓴다.
REGISTRY=<ECR 레지스트리>
TAG=m1
IMAGE=$REGISTRY/feedablackhole-server:$TAG
aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin $REGISTRY
docker pull $IMAGE
blackhole-ec2-role) 의 임시 자격으로 AWS CLI가 동작한다.docker login은 "인증 정보가 암호화 없이 저장된다"는 경고를 낸다. 저장되는 것은 12시간짜리 읽기 전용 토큰이라 이 서버에서는 문제가 크지 않다. 다만 6단계 배포 스크립트는 배포할 때마다 로그인하고 끝나면 로그아웃하게 만들 계획이다.앱이 필요로 하는 DB_URL, DB_USERNAME, DB_PASSWORD, JWT_SECRET을 Parameter Store에서 읽어 파일로 만든다.
umask 077
aws ssm get-parameters-by-path --path /blackhole --with-decryption --region ap-northeast-2 \
--query "Parameters[].[Name,Value]" --output text \
| while IFS=$'\t' read -r name value; do echo "${name##*/}=$value"; done > ~/blackhole.env
cut -d= -f1 ~/blackhole.env
한 줄씩 풀면 다음과 같다.
| 부분 | 의미 |
|---|---|
umask 077 | 이후 만드는 파일의 권한을 소유자만 읽고 쓰게(600) 한다. 비밀값이 든 파일이라 다른 사용자가 읽지 못하게 한다 |
get-parameters-by-path --path /blackhole | /blackhole 아래 파라미터를 한꺼번에 가져온다 |
--with-decryption | SecureString을 복호화한다. 서버 역할이 키(aws/ssm)로 복호화할 수 있어서 가능하다 |
--query "Parameters[].[Name,Value]" --output text | 이름과 값만 탭으로 구분해 출력한다 |
while … echo "${name##*/}=$value" | 이름에서 경로(/blackhole/)를 떼고 KEY=VALUE 형식으로 바꾼다 |
> ~/blackhole.env | 파일로 저장한다. 값은 화면에 출력되지 않는다 |
cut -d= -f1 | 파일의 키 이름만 출력해서 확인한다 |
DB_PASSWORD, DB_URL, DB_USERNAME, JWT_SECRET 네 개가 나오면 된다. 값이 아니라 이름만 보는 것이 포인트다. 터미널 화면에 비밀값이 찍히지 않게 하려는 것이다. 이 정책이 GetParametersByPath를 허용하는지는 2단계에서 확인해 두었다.
docker run -d --name blackhole --restart unless-stopped \
-p 127.0.0.1:8080:8080 --env-file ~/blackhole.env $IMAGE
rm ~/blackhole.env
| 옵션 | 의미 |
|---|---|
-d | 백그라운드로 실행한다 |
--name blackhole | 컨테이너 이름. 이후 docker logs blackhole처럼 이름으로 다룬다 |
--restart unless-stopped | 프로세스가 죽거나 서버가 재부팅되면 자동으로 다시 띄운다. 직접 멈춘 경우에만 그대로 둔다 |
-p 127.0.0.1:8080:8080 | 서버의 8080 포트를 컨테이너의 8080에 연결하되, 서버 내부(127.0.0.1)에서만 접근된다. 인터넷에서는 닿지 않고, 4단계의 Nginx만 접근하게 하려는 것이다 |
--env-file ~/blackhole.env | 파일의 KEY=VALUE를 컨테이너의 환경변수로 넣는다 |
rm ~/blackhole.env | 컨테이너가 시작될 때 환경변수를 읽어 가므로 파일은 바로 지워도 된다 |
주의: 파일을 지워도 컨테이너 설정에는 환경변수가 남아 있다. docker inspect blackhole을 하면 값이 보이므로 이 출력은 어디에도 공유하지 않는다.
docker logs -f blackhole
-f는 로그를 계속 따라 보는 옵션이다. Ctrl+C는 로그 보기만 끝내고 컨테이너는 계속 돈다. 아래 순서로 나오는지 본다.
| 로그 | 의미 |
|---|---|
Schema history table … does not exist yet, Creating Schema History table | Flyway가 처음이라 이력 테이블부터 만든다 |
Migrating schema … to version "1 - create auth tables", "2 - create progress tables", "3 - create sync tables" | 앱 전용 계정으로 테이블을 만들었다. 권한이 충분하다는 증거다 |
콘텐츠 묶음을 불러왔다 … 노드 276개 | 게임 콘텐츠 검증과 로드 통과 |
Tomcat started on port 8080, Started FeedABlackholeServerApplication | 기동 완료 |
기동에는 20초 안팎이 걸린다. 처음에는 이미지가 크고 DB에 스키마를 만들기 때문이다.
curl -s localhost:8080/actuator/health
{"groups":["liveness","readiness"],"status":"UP"}가 나오면 서버가 떠 있고 DB에도 연결된 것이다(이 헬스체크는 DB 연결까지 확인한다).
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/v1/me
토큰 없이 보호된 경로를 부르는 것이라 401이 정상이다. 헬스체크만 열려 있고 나머지는 닫혀 있다는 것을 같이 확인하는 셈이다.
로그만 믿지 않고 DB에서 마이그레이션 결과를 볼 수도 있다. 2단계에서 앱 계정을 확인할 때 쓴 방식과 같다. 앱 비밀번호는 Parameter Store에서 변수로 읽고, mysql 클라이언트는 설치하지 않고 일회용 컨테이너로 쓴다.
APP_PW=$(aws ssm get-parameter --name /blackhole/DB_PASSWORD --with-decryption --region ap-northeast-2 --query Parameter.Value --output text)
DB_HOST=<RDS 엔드포인트>
docker run --rm -e MYSQL_PWD="$APP_PW" mysql:8.4 mysql -h $DB_HOST -u blackhole_app feedablackhole \
-e "SELECT version, description, success FROM flyway_schema_history; SHOW TABLES;"
unset APP_PW
flyway_schema_history에 V1~V3이 success=1로 있고, SHOW TABLES에 인증·진행도·동기화 테이블이 보이면 된다.
배포는 결국 "새 이미지를 받아 기존 컨테이너를 내리고 새 컨테이너를 띄우는 것"이다. 그 과정을 손으로 해 본다. 코드를 바꿀 필요 없이 같은 이미지에 새 태그를 붙여 연습한다.
docker tag <ECR 레지스트리>/feedablackhole-server:m1 <ECR 레지스트리>/feedablackhole-server:m2
docker push <ECR 레지스트리>/feedablackhole-server:m2
이미 올라간 이미지에 이름표(태그)를 하나 더 붙이는 것이라 새로 올라가는 데이터는 거의 없다. ECR 콘솔에서 같은 이미지에 m1, m2가 같이 붙은 것을 볼 수 있다.
순서가 중요하다. 받기 → 기존 컨테이너를 정상 종료 → 새 컨테이너 실행 → 헬스체크.
TAG=m2
IMAGE=$REGISTRY/feedablackhole-server:$TAG
docker pull $IMAGE
docker stop -t 30 blackhole && docker rm blackhole
그다음 4.3(환경변수 파일 만들기)과 4.4(docker run)를 같은 방식으로 다시 실행하고($IMAGE가 바뀌었다), 5.2의 헬스체크가 UP인지 본다.
docker stop -t 30을 쓴 이유: docker stop은 종료 신호(SIGTERM)를 보낸 뒤 기본 10초를 기다렸다가 강제 종료(SIGKILL)한다. 그런데 앱은 처리 중인 요청을 마치려고 최대 20초까지 기다리도록 설정했다(graceful shutdown). 10초만 기다리면 앱이 마무리하기 전에 강제로 죽을 수 있어서, 대기 시간을 30초로 늘려 앱 설정보다 길게 맞췄다. 배포 스크립트에 그대로 반영할 점이다.
같은 과정을 TAG=m1로 반복한다(docker stop -t 30 → rm → env 파일 → docker run → 헬스체크). 되돌리기가 "이전 태그의 이미지를 다시 실행하는 것"뿐이라는 점이 중요하다. ECR의 태그 변경 불가 덕분에 m1은 언제나 처음 올린 그 이미지다.
Graceful shutdown complete가 나오는지.deploy.sh가 자동화할 내용이라는 점. 사람이 하는 단계가 그대로 스크립트의 줄이 된다.서버가 재부팅이나 장애 뒤에 스스로 복구되는지 확인한다.
sudo reboot
세션이 끊긴다. 1분쯤 뒤에 세션 관리자로 다시 접속해 확인한다.
sudo -iu ec2-user
docker ps
curl -s localhost:8080/actuator/health
docker ps에 blackhole이 보이고 헬스체크가 UP이면 된다. 이것이 두 가지 설정이 함께 동작한다는 증거다.
--restart unless-stopped가 Docker가 뜨면 컨테이너를 다시 띄운다.탄력적 IP를 연결해 두었기 때문에 서버의 외부 주소는 재부팅해도 그대로다.
실제로 겪은 문제가 아니라, 이 절차에서 흔히 막히는 곳을 미리 정리한 점검표다.
| 증상 | 가능한 원인 | 확인·해결 |
|---|---|---|
docker push에서 tag invalid … already exists | 태그가 이미 있다(태그 변경 불가) | 새 태그를 쓴다 |
docker push에서 denied, no basic auth credentials | ECR 로그인 토큰이 만료됐다(12시간) 또는 리전이 다르다 | 3.2의 로그인 명령을 다시 실행한다 |
aws login이 안 된다 | CLI 버전이 낮다 | CLI를 업데이트한다 |
| 컨테이너가 바로 종료된다 | 기동 중 오류 | docker logs blackhole 마지막 줄이 원인이다 |
exec format error | 이미지가 x86이 아니다 | --platform linux/amd64로 다시 빌드한다 |
| 로그에 JWT 관련 오류 | JWT_SECRET이 비었거나 32자 미만이다 | 파라미터 값을 덮어쓰고 컨테이너를 다시 실행한다 |
Flyway command denied | 앱 계정의 권한이 부족하다 | 로그에 필요한 권한이 나온다. GRANT를 조정한다 |
Access denied for user 'blackhole_app' | 비밀번호가 DB 계정과 다르다 | Parameter Store 값과 DB 계정의 비밀번호를 맞춘다 |
헬스체크가 DOWN이거나 30초 가깝게 걸린다 | DB 연결 문제 | DB_URL 값, blackhole-sg-db의 출처 규칙을 확인한다 |
docker run에서 이름 충돌 | 같은 이름의 컨테이너가 남아 있다 | docker rm -f blackhole |
AccessDenied(SSM) | 정책이 경로를 허용하지 않는다 | 정책 리소스에 parameter/blackhole와 parameter/blackhole/*가 모두 있는지 본다 |
deploy.sh: 다른 배포가 진행 중입니다 | 다른 실행이 잠금 파일(flock)을 쥐고 있다 | 진행 중인 배포가 끝나기를 기다린다. 잠금은 프로세스가 끝나면 자동으로 풀린다 |
deploy.sh: 파라미터 … 가 없거나 비어 있습니다, JWT_SECRET이 32자 미만 | 설정 검증 실패(이 경우 기존 컨테이너는 건드리지 않은 상태다) | Parameter Store 값을 고친 뒤 다시 실행한다 |
deploy.sh: 되돌리기도 실패했습니다 | 새 버전과 직전 버전이 모두 헬스체크를 통과하지 못했다 | DB 접속 정보 같은 설정 문제일 가능성이 높다. docker logs blackhole와 DB 상태를 확인한다 |
비밀값이 섞일 수 있는 출력(docker inspect, env 파일 내용, 환경변수 목록)은 공유하지 않는다. docker logs는 비밀값이 나오지 않는다.
수동으로 해 보니 deploy.sh와 CD 워크플로에 들어가야 할 내용이 분명해졌다.
| 항목 | 정해진 내용 |
|---|---|
| 태그 | 커밋 SHA처럼 매번 고유한 값. 태그 변경 불가라 재사용하지 않는다 |
| ECR 로그인 | 배포할 때마다 로그인하고, 끝나면 로그아웃한다 |
| 환경변수 | Parameter Store에서 파일로 만들고(umask 077), docker run 직후 지운다 |
| 종료 | docker stop -t 30. 앱의 graceful shutdown(20초)보다 길게 한다 |
| 포트 | 127.0.0.1:8080에만 바인딩한다. 외부에서는 Nginx만 접근한다 |
| 헬스체크 | 교체 후 /actuator/health를 확인하되, DB가 끊긴 경우 응답이 약 30초 걸리므로 타임아웃은 40초 이상으로 잡는다 |
| 롤백 | 교체 전에 직전에 정상이던 태그를 기록해 두고, 헬스체크가 실패하면 그 태그로 되돌린다. ECR 수명 주기 규칙(최근 10개 유지)이 그 이미지를 지우지 않는지 확인한다 |
| 실행 방식 | 6단계에서는 SSH가 아니라 SSM Run Command로 실행한다 |
이 표가 다음 장의 deploy.sh로 옮겨졌다.
앞 장의 표가 설계 입력이라면, 이 장은 그것을 스크립트 하나로 옮긴 결과다. 손으로 한 절차를 매번 정확히 반복하기는 어렵다. 순서를 틀리거나 한 줄을 빼먹으면 서비스가 끊기고, 롤백할지 말지를 사람이 눈으로 판단해야 한다. 6단계의 CD가 서버에서 실행할 단일 진입점도 필요했다.
| 항목 | 내용 |
|---|---|
| 위치 | 저장소의 deploy/deploy.sh |
| 실행 | EC2에서 sudo bash deploy.sh <이미지 태그> |
| 결과 | 종료 코드 0 = 성공, 그 외 = 실패(롤백에 성공해도 이번 배포는 실패로 처리한다) |
| 전제 | docker, aws CLI, curl이 있고, 인스턴스 역할이 ECR 읽기와 /blackhole 파라미터 읽기 권한을 가진다. root(SSM Run Command) 또는 sudo로 실행한다 |
파일 실행 권한(chmod +x)에 의존하지 않도록 bash deploy.sh로 부른다. 줄바꿈은 LF여야 하며, 이 저장소의 .gitattributes가 보장한다.
① 설정 검증 → ② 이미지 받기 → ③ 교체(기존 정상 종료 → 새 컨테이너) → ④ 판정(성공 기록 / 실패 시 롤백)
| 순서 | 하는 일 | 이 순서인 이유 |
|---|---|---|
| ① | Parameter Store 값을 읽어 필수 키 4개가 있고 JWT_SECRET이 32자 이상인지 확인한다 | 기존 컨테이너를 내리기 전에 검증해야, 설정 실수로 서비스가 끊기지 않는다 |
| ② | ECR에 로그인하고 새 이미지를 받는다 | 받는 동안에는 기존 컨테이너가 계속 서비스한다. 다운타임이 "받는 시간"만큼 늘지 않는다 |
| ③ | 기존 컨테이너를 docker stop -t 30으로 내리고 새 컨테이너를 실행한다 | 종료 대기 시간이 앱의 graceful shutdown(20초)보다 길다 |
| ④ | 헬스체크가 UP이면 태그를 기록하고, 아니면 롤백한다 | 사람의 눈이 아니라 헬스체크가 판정한다 |
핵심 조각만 본다. 전체는 deploy/deploy.sh다.
안전장치와 동시 실행 방지
set -euo pipefail
...
exec 9>"$LOCK_FILE"
flock -n 9 || die "다른 배포가 진행 중입니다."
set -euo pipefail은 명령이 실패하면 즉시 멈추고(-e), 정의되지 않은 변수를 쓰면 오류로 보고(-u), 파이프 중간의 실패도 실패로 취급한다(pipefail).flock은 같은 서버에서 배포가 동시에 두 번 돌아 컨테이너 이름이 충돌하거나 서로의 컨테이너를 지우는 일을 막는다. 두 번째 실행은 거부된다. CD가 실수로 두 번 트리거돼도 안전하다.식별 정보를 저장소에 적지 않기
ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text --region "$REGION")"
REGISTRY="${REGISTRY:-$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com}"
계정 번호를 스크립트에 적지 않고, 인스턴스 역할로 실행 중에 조회한다.
임시 파일은 반드시 지운다
umask 077
DOCKER_CONFIG="$(mktemp -d)"
export DOCKER_CONFIG
ENV_FILE="$(mktemp)"
trap 'rm -rf "$DOCKER_CONFIG" "$ENV_FILE"' EXIT
DOCKER_CONFIG가 가리키는 임시 폴더에만 저장된다. 4.2에서 본 "토큰이 config.json에 평문으로 저장된다"는 경고의 대책이다. 폴더째 지우면 로그아웃과 같다.mktemp가 소유자만 읽을 수 있게 만든다.trap … EXIT는 성공이든 실패든, 중간에 오류로 멈추든 종료할 때 항상 실행되어 두 가지를 지운다.설정 검증 (기존 컨테이너를 내리기 전)
for key in DB_URL DB_USERNAME DB_PASSWORD JWT_SECRET; do
grep -q "^${key}=." "$ENV_FILE" || die "파라미터 $PARAM_PATH/$key 가 없거나 비어 있습니다."
done
(( ${#jwt_secret} >= 32 )) || die "JWT_SECRET이 32자 미만입니다. 서버가 시작하지 않습니다."
^KEY=.는 "키가 있고 값이 비어 있지 않다"는 뜻이다. JWT 키는 앱이 32자 미만이면 시작하지 않으므로 스크립트가 먼저 거른다. 이 검증이 없으면 기존 컨테이너를 내린 뒤에야 새 앱이 시작에 실패하는 것을 알게 된다.
되돌릴 대상 결정
if [[ -s $LAST_GOOD_FILE ]]; then
PREV_TAG="$(< "$LAST_GOOD_FILE")"
elif docker inspect "$NAME" > /dev/null 2>&1; then
running_image="$(docker inspect -f '{{.Config.Image}}' "$NAME")"
PREV_TAG="${running_image##*:}"
fi
기준은 "지금 실행 중인 것"이 아니라 마지막으로 헬스체크를 통과한 태그(/var/lib/blackhole/last-good-tag)다. 실행 중인 버전이 이미 불안정할 수 있어서다. 기록이 없는 처음에는 실행 중인 컨테이너의 태그를 쓴다. 3단계에서 손으로 띄운 컨테이너를 이 스크립트가 이어받는 경로가 이것이다.
판정: 컨테이너 상태와 헬스체크를 함께 본다
state="$(docker inspect -f '{{.State.Status}}' "$NAME" 2> /dev/null || echo missing)"
if [[ $state != running ]]; then ... return 1; fi
response="$(curl -s --max-time "$HEALTH_REQUEST_TIMEOUT" -w '\n%{http_code}' \
"http://127.0.0.1:$HOST_PORT/actuator/health" || true)"
if [[ $code == 200 && $body == *'"status":"UP"'* ]]; then return 0; fi
running이 아니면(예: 시작하자마자 죽는 restarting) 기다리지 않고 바로 실패로 본다. --restart unless-stopped 때문에 죽은 컨테이너가 계속 다시 뜨는 크래시 루프를 오래 기다리지 않기 위해서다.200이어도 "status":"UP"이 아니면 통과가 아니다.판정 이후
if start_container "$IMAGE" && wait_healthy; then
echo "$TAG" > "$LAST_GOOD_FILE"
...
exit 0
fi
... remove_container
if [[ -n $PREV_TAG && $PREV_TAG != "$TAG" ]]; then
... start_container "$PREV_IMAGE" && wait_healthy ...
fi
exit 1
성공하면 태그를 기록한다. 실패하면 컨테이너 로그를 보여 주고, 직전 정상 태그로 되돌린 뒤 그래도 종료 코드 1로 끝낸다. 롤백이 성공해서 서비스가 살아 있어도 이번 배포는 실패이고, CD가 그 사실을 알아야 하기 때문이다. 되돌릴 이미지가 서버에서 지워져 있으면 다시 받는다.
| 변수 | 기본값 | 의미 |
|---|---|---|
REGION | ap-northeast-2 | 리전 |
PARAM_PATH | /blackhole | Parameter Store 경로 |
REPO | feedablackhole-server | ECR 리포지토리 |
NAME | blackhole | 컨테이너 이름 |
HOST_PORT | 8080 | 서버 내부(127.0.0.1)에 여는 포트 |
STATE_DIR | /var/lib/blackhole | 마지막 정상 태그 기록 위치 |
LOCK_FILE | /var/lock/blackhole-deploy.lock | 동시 실행 방지 잠금 |
STOP_TIMEOUT | 30 | docker stop 대기(초) |
HEALTH_REQUEST_TIMEOUT | 40 | 헬스체크 요청 하나의 제한(초) |
HEALTH_TIMEOUT | 120 | 헬스체크 전체 제한(초) |
POLL_INTERVAL | 3 | 헬스체크 간격(초) |
운영에 맞춘 값을 기본값으로 두고, 테스트에서는 HEALTH_TIMEOUT=6, POLL_INTERVAL=1처럼 줄여서 쓴다.
EC2와 AWS를 매번 쓰지 않고, 로컬에서 스크립트의 모든 분기를 먼저 확인했다.
방법: PATH 맨 앞에 가짜 docker, aws, curl, flock을 두고 스크립트를 그대로 실행한다. 가짜 docker는 컨테이너 상태를 파일 두 개(container: 실행 중인 이미지, crashed: 죽었는지)로 흉내 내고, 시나리오는 환경변수로 조절한다.
| 환경변수 | 역할 |
|---|---|
CRASH_TAGS | 이 태그의 컨테이너는 시작하자마자 죽는다 |
FAIL_TAGS | 이 태그의 컨테이너는 헬스체크가 DOWN(503)을 반환한다 |
PARAM_MODE | missing(키 하나 누락), shortjwt(JWT 32자 미만) |
시나리오와 결과 (검증 37개가 모두 통과)
| 시나리오 | 확인한 것 |
|---|---|
| 첫 배포 성공 | 종료 코드 0, 마지막 정상 태그 기록, 레지스트리 주소가 계정 ID로 조립됨, 포트가 127.0.0.1에만 바인딩, 재시작 정책 지정, 임시 파일이 남지 않음, 비밀값이 출력에 나오지 않음 |
| 두 번째 배포 성공 | 교체됨, 기존 컨테이너를 stop -t 30으로 내림 |
| 새 버전이 크래시 | 직전 정상 태그로 되돌아가 실행 중, 종료 코드 1, 마지막 정상 태그 유지, 실패 로그 출력 |
| 새 버전이 헬스체크 DOWN | 제한 시간 후 롤백 |
| 설정 누락, JWT 짧음 | 종료 코드 1, 기존 컨테이너를 건드리지 않음, 이미지를 받기 전에 중단 |
| 잘못된 태그, 태그 없음, 받을 수 없는 이미지 | 종료 코드 1, 기존 컨테이너 유지 |
| 되돌릴 대상이 없는 첫 배포 실패 | 컨테이너 없음, 기록 안 함, 안내 메시지 |
| 기록 없이 컨테이너만 있을 때 | 실행 중이던 태그로 롤백 |
테스트가 쓸모 있는지 확인하기 (변형 테스트): 테스트가 통과한다는 것만으로는 테스트가 문제를 잡을 수 있는지 알 수 없다. 그래서 스크립트를 일부러 망가뜨려서 테스트가 실패하는지도 확인했다.
| 일부러 넣은 결함 | 결과 |
|---|---|
STOP_TIMEOUT 기본값 30 → 10 | 해당 검증 실패 |
| 롤백 분기를 항상 건너뛰게 변경 | 롤백 관련 검증 여러 개 실패 |
| JWT 길이 검사 삭제 | 짧은 JWT 시나리오 실패 |
세 가지 변형에서 9개 검증이 실패했다. 테스트가 결함을 감지한다는 증거다.
한계: 가짜 도구는 실제 docker와 curl의 동작 차이(출력 형식, 상태 값 등)를 잡지 못한다. 그래서 다음 절의 EC2 검증이 필요했다. 이 테스트 도구는 저장소에 넣지 않았다. 스크립트가 바뀔 때마다 다시 돌릴 필요가 생기면 저장소에 추가하는 것을 검토한다.
준비 1: 일부러 실패하는 이미지. 롤백 경로를 유도하려면 곧바로 종료하는 이미지가 필요하다. 작은 alpine 이미지를 bad1 태그로 ECR에 올렸다(내 PC).
docker pull --platform linux/amd64 alpine:3
docker tag alpine:3 <ECR 레지스트리>/feedablackhole-server:bad1
docker push <ECR 레지스트리>/feedablackhole-server:bad1
alpine은 실행할 명령이 없으면 쉘이 바로 끝나서 컨테이너가 곧바로 종료된다. 앱 이미지가 아니라서 헬스체크에도 응답할 수 없다.
준비 2: 스크립트를 EC2에 올리기. 이 검증에서는 세션 터미널에 파일 내용을 그대로 붙여 넣었다. 6단계에서는 CD가 이 일을 한다.
cat > ~/deploy.sh <<'EOF'
(deploy/deploy.sh 내용 전체)
EOF
성공 경로. 이미 실행 중이던 m2를 다시 배포해 교체 메커니즘을 확인했다. 새 태그(m3)를 올리는 대신 같은 태그를 재배포한 것이다.
$ sudo bash ~/deploy.sh m2; echo "종료 코드: $?"
16:30:38 [deploy] Parameter Store(/blackhole)에서 설정을 읽습니다.
16:30:38 [deploy] ECR에서 이미지를 받습니다: <ECR 레지스트리>/feedablackhole-server:m2
16:30:39 [deploy] 교체를 시작합니다: m2 -> m2
16:30:55 [deploy] 배포 성공: m2
종료 코드: 0
롤백 경로.
$ sudo bash ~/deploy.sh bad1; echo "종료 코드: $?"
16:31:01 [deploy] Parameter Store(/blackhole)에서 설정을 읽습니다.
16:31:02 [deploy] ECR에서 이미지를 받습니다: <ECR 레지스트리>/feedablackhole-server:bad1
16:31:03 [deploy] 교체를 시작합니다: m2 -> bad1
16:31:07 [deploy] 컨테이너 상태가 running이 아닙니다: restarting
16:31:07 [deploy] 배포 실패: bad1. 컨테이너 로그(마지막 40줄):
16:31:07 [deploy] 직전 정상 태그로 되돌립니다: m2
16:31:23 [deploy] 되돌리기 성공: m2 (이번 배포는 실패로 처리합니다)
종료 코드: 1
결과
| 경로 | 관찰 | 의미 |
|---|---|---|
| 성공 | 교체 시작부터 헬스체크 통과까지 약 16초, 종료 코드 0 | 서비스가 끊기는 시간이 이 정도다. 대부분이 앱 기동(Flyway 검증, 콘텐츠 로드) 시간이다 |
| 롤백 | restarting을 감지해 4초 만에 실패로 판정하고, 직전 정상 태그 m2로 복구하기까지 약 20초, 종료 코드 1 | 크래시 루프를 오래 기다리지 않고 바로 되돌린다 |
docker와 curl 환경에서도 같은 분기로 움직였다.alpine은 출력이 없어서 "컨테이너 로그" 아래가 비어 있다. 실제 앱이 실패하면 이 부분에 원인이 나온다.ec2-user + sudo)에서 했다. SSM으로 실행하는 검증은 6단계에서 한다.bad1 이미지가 남는다. 수명 주기 규칙(10개 초과 시 만료)이 정리하므로 지우지 않아도 된다.grep -c $'\r'로 확인했더니 모든 줄(157줄)에 일치한다고 나왔다. 쉘 스크립트가 CRLF면 Linux에서 set -euo pipefail\r처럼 오류가 나는 전형적인 문제라 변환했다. 그런데 변환한 뒤에도 같은 숫자가 나와서 의심했고, 바이트 단위로 직접 세어 보니 CR은 0개였다. Windows의 MSYS grep이 패턴의 \r를 떼어 버려 빈 패턴(모든 줄에 일치)이 된 것이었다. 도구의 출력이 이상하면 다른 방법으로 교차 확인해야 하고, 파일을 바이트로 읽는 쪽이 가장 확실하다.--restart unless-stopped는 죽은 컨테이너를 다시 띄우므로, 크래시하는 이미지는 exited가 아니라 restarting과 running을 오간다. 그래서 running일 때만 헬스체크를 보고, 그렇지 않으면 즉시 실패로 처리했다.docker stop의 기본 대기(10초)가 앱의 graceful shutdown(20초)보다 짧다는 것처럼, 문서만 보면 놓치기 쉬운 지점은 스크립트에 이유와 함께 적어 두었다.| 단계 | 내용 | 검증 |
|---|---|---|
| 4. 도메인과 HTTPS | DuckDNS 서브도메인을 탄력적 IP에 연결한다. Nginx로 443 → 8080을 프록시하고 certbot으로 인증서를 발급·갱신한다. 정식 도메인은 구매 후 같은 IP로 연결하고 두 도메인을 함께 서비스하다가 전환한다 | curl https://도메인/actuator/health 성공, 클라이언트의 게스트 로그인 동작 |
| 5. CI 확장 | PR에서 빌드·테스트를 실행한다. main 브랜치 보호를 건다 | 테스트가 깨지는 PR은 병합이 막힌다 |
| 6. CD | GitHub OIDC 역할로 main 푸시 시 빌드 → ECR 푸시 → SSM으로 deploy.sh 실행(교체·헬스체크·롤백은 스크립트가 처리하고, 워크플로는 종료 코드를 본다) | 병합하면 사람 손 없이 배포되고, 일부러 실패시키면 롤백된다. SSM으로 실행하는 검증은 여기서 한다 |
| 7. 운영 점검 | 로그, 알람, 백업 복원 시험, 운영 런북 | 복원을 한 번 실제로 해 본다 |
deploy.sh를 EC2에 미리 두지 않고, 워크플로가 실행 직전에 저장소의 파일 내용을 SSM 명령으로 전달하는 방식을 쓸 계획이다. 서버에 둔 사본과 저장소의 스크립트가 어긋나는 일을 막는다.main 병합 전 CI에서 스크립트를 점검할지는 5단계에서 같이 정한다.| 항목 | 선택 | 이유 |
|---|---|---|
| 로컬 AWS 자격 | aws login(콘솔 로그인 임시 자격) | 장기 액세스 키를 만들지 않는다 |
| 빌드 옵션 | --platform linux/amd64 --provenance=false | EC2와 CPU를 맞추고, ECR에 부가 항목이 섞이지 않게 한다 |
| 태그 | 수동 연습은 m1, m2, 자동화는 커밋 SHA | 태그 변경 불가라 매번 고유해야 한다 |
| 비밀값 | Parameter Store → 파일(600) → docker run → 파일 삭제 | 이미지와 저장소에 넣지 않고, 파일은 오래 두지 않는다 |
| 포트 | 127.0.0.1:8080에만 바인딩 | 인터넷에서 앱에 직접 닿지 않는다 |
| 재시작 | --restart unless-stopped + 부팅 시 Docker 자동 시작 | 장애와 재부팅 뒤 스스로 복구한다 |
| 종료 | docker stop -t 30 | 앱의 graceful shutdown 시간보다 길게 맞춘다 |
| 배포 스크립트 | deploy/deploy.sh 하나로 검증 → 받기 → 교체 → 판정 | 사람이 순서를 틀려도 안전하고, CD가 종료 코드로 판단한다 |
| 롤백 기준 | 마지막으로 헬스체크를 통과한 태그를 기록 | 지금 실행 중인 버전이 불안정할 수 있다 |
| 종료 코드 | 롤백에 성공해도 이번 배포는 실패(1) | 서비스가 살아 있는 것과 배포가 성공한 것은 다르다 |
deploy.sh의 입력이 된다.