29) nonos(No No Stress) - Simple 무중단 Deploy

Simple 무중단 배포 하기!
주문 자동화 SaaS를 혼자 운영한다.
EC2 한 대, Docker 컨테이너 하나, 그 앞에 Nginx. master에 푸시하면 GitHub Actions가 알아서 배포한다.
그런데 배포할 때마다 사이트가 20~30초 죽는다.
왜 죽나?
배포 스크립트의 마지막 세 줄이 전부였다.
docker stop nonos
docker rm nonos
docker run -d --name nonos -p 8090:8080 $IMAGE:latest
stop을 실행한 순간부터 새 컨테이너의 Spring Boot가 기동을 마칠 때까지 8090 포트에 아무도 없다.
Nginx는 그대로 프록시하니까 502가 나간다.
구간소요
| docker stop (SIGTERM → 10초 후 SIGKILL) | 최대 10초 |
| JVM 기동 + Spring 컨텍스트 초기화 | 10~20초 |
| 502가 나가는 시간 | 20~30초 |
셀러가 발송처리를 누른 순간에 걸리면 더 나쁘다.
네이버 API는 이미 호출됐는데 우리 DB에는 결과가 안 남을 수 있다.
해법은 뻔한데..
새 컨테이너를 먼저 띄우고, 준비된 걸 확인하고, 트래픽을 옮기고, 그다음에 옛 컨테이너를 끈다.
이것을 블루/그린이라 부른다.
한 문장인데 여기에 전제가 셋 숨어 있다.
하나라도 빠지면 "대체로 잘 되는데 가끔 이상한?" 배포가 된다.
전제없으면
| 준비됐는지 물어볼 곳 | 시간을 잰다 → 기동이 느린 날 502 |
| 진행 중인 요청을 안 끊음 | 전환 직후 죽이면 처리 중이던 요청이 사라진다 |
| 두 컨테이너가 겹쳐도 안전 | 겹치는 동안 스케줄러가 양쪽에서 돈다 |
세 번째를 나는 처음에 놓쳤다.
그리고 그게 제일 위험했다!!!

1. 물어볼 곳 — Actuator
전환 시점을 sleep 20으로 정하면 반드시 언젠가 틀린다.
기동 시간은 서버 부하에 따라 흔들린다.
물어봐야 한다!!
GET /actuator/health/readiness → {"status":"UP"}
readiness는 "지금 요청을 받을 수 있는가"다. liveness("살아는 있는가")와 다르다 — 기동 중인 앱은 살아 있지만 준비되지는 않았다.
management:
endpoints:
web:
exposure:
include: health # health 만. metrics, env, beans 는 닫는다
endpoint:
health:
probes:
enabled: true
show-details: never
group:
readiness:
include: readinessState, db # ← 이게 핵심
마지막 줄이 중요하다.
기본 readiness는 readinessState만 보므로 DB에 접속 못 하는 컨테이너도 UP으로 나온다.
db를 넣어야 그런 컨테이너가 게이트에서 걸린다.
Spring Security가 없는 프로젝트라면 Nginx에서 막는다.
배포 스크립트는 127.0.0.1:포트로 직접 물어보니까 영향 없다.
location /actuator { return 404; }
2. 요청을 안 끊기 — 두 개의 시간
Nginx를 reload하면 새 요청만 새 컨테이너로 간다.
옛 컨테이너는 여전히 처리 중인 요청을 들고 있다.
바로 죽이면 그게 끊긴다.
drain 30초 — reload 후 기다린다.
graceful shutdown — 30초가 지나도 안 끝난 게 있을 수 있다.
SIGTERM을 받으면 새 요청은 거절하되 진행 중인 건 마치고 종료하게 한다.
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
그리고 이게 빠지면 위 설정이 통째로 무의미해진다.
docker stop -t 40 <컨테이너>
docker stop은 기본 10초만 기다리고 SIGKILL을 보낸다.
Spring이 30초까지 기다리려는데 Docker가 10초에 죽여버린다.
하나 더 확인할 것.
Dockerfile이 exec 형식이어야 SIGTERM이 JVM에 도달한다.
ENTRYPOINT ["java", "-jar", "app.jar"] # java 가 PID 1 — OK
ENTRYPOINT java -jar app.jar # /bin/sh 가 PID 1 — 시그널 안 감
셸 형식이면 graceful shutdown이 조용히 동작하지 않는다.
설정은 다 맞는데 아무 효과가 없다.
3. 겹쳐도 안전하기 — 여기가 진짜 문제였다!!
Nginx는 HTTP 요청만 한쪽으로 보낸다.
스케줄러는 Nginx를 안 거친다. 각 컨테이너 안에서 자기 시계로 알아서 돈다.
내 앱에는 @Scheduled가 4개가 있었다.
Job 주기가 겹치면
| 송장 에이전트 tick | 60초 | 같은 송장이 두 번 발송된다 |
| blob 정리 | 매일 04:00 | 무해 |
| 취소 동기화 | 10분 | 중복 조회 — API 호출 제한(rate limit) 압박 |
| 클레임 감지 | 5분 | 중복 감지 |
송장 에이전트가 위험하다.
IMAP으로 송장 메일을 받아 파싱하고 주문에 매칭한 뒤 네이버에 발송처리를 호출한다.
60초 주기니까 45초 겹침 구간에 거의 확실히 한 번은 양쪽에서 뜬다.
컨테이너가 하나뿐일 땐 문제가 드러날 수 없었다.
무중단으로 바꾸는 순간 겹치는 구간이 처음으로 생긴다.
무중단이 사고를 만드는 셈이다.
선택지
A. 새 컨테이너는 스케줄러를 끄고 시작, 전환 후 켠다. 짧다. 그런데 "켜는 단계"가 배포 스크립트에 남고, 그 한 줄이 실패하면 스케줄러가 영영 안 돈다. 조용히 실패하는 설계는 피한다.
B. DB 락. 돌기 전에 DB에 "내가 맡는다"를 원자적으로 찍는다.
테이블 하나가 는다. 서버가 늘어나도 그대로 쓴다.
C. 전환 직후 옛 컨테이너를 즉시 죽인다.
겹침을 줄일 뿐 없애지 못하고, drain과 정면으로 충돌한다.
B로 갔다.
ShedLock을 안 쓴 이유
Java 진영 표준 답은 ShedLock이다. @SchedulerLock 하나 붙이면 끝이다.
그런데 ShedLock의 핵심 로직은 UPDATE 문 하나고, 내가 필요한 것도 정확히 그것뿐이었다.
라이브러리를 넣으면 의존성 + Spring 통합 설정 + 버전 관리가 따라오는데 얻는 건 60줄짜리 코드의 대체다.
직접 만들었다. 다만 갈아탈 조건을 문서에 적어뒀다 — 서버가 상시 2대 이상이 되거나, 락 지점이 10곳을 넘거나, long-running job의 리스 연장이 필요해지면 갈아타야한다!!
테이블
CREATE TABLE scheduler_lock (
lock_name VARCHAR(64) NOT NULL,
locked_until DATETIME(3) NOT NULL,
locked_at DATETIME(3) NOT NULL,
locked_by VARCHAR(100) NOT NULL,
PRIMARY KEY (lock_name)
) ENGINE=InnoDB;
DATETIME(3) — 초 단위면 같은 초에 들어온 두 요청의 비교가 애매해진다.
획득 — UPDATE 한 문장
UPDATE scheduler_lock
SET locked_until = :until, -- now + lease
locked_at = :now,
locked_by = :who
WHERE lock_name = :name
AND locked_until <= :now -- 아무도 안 쥐고 있을 때만
영향받은 행이 1이면 내가 잡은 것, 0이면 남이 쥔 것.
안전한 이유는 단일 UPDATE라서다.
InnoDB는 이 문장을 실행하는 동안 해당 행에 배타 락을 건다.
A: UPDATE ... WHERE locked_until <= now → 행 락 → 조건 만족 → 1행 → 커밋
B: (A의 행 락이 풀릴 때까지 대기)
B: → 조건 재평가 → 미래 → 0행
B는 기다렸다가 실패한다.
SELECT 후 UPDATE로 나누면 두 문장 사이에 틈이 생겨 둘 다 성공할 수 있다.
반드시 한 문장이어야 한다.
함정 1 — MySQL의 affected rows
MySQL Connector/J는 기본값(useAffectedRows=false)에서 CLIENT_FOUND_ROWS를 켠다.
그래서 executeUpdate()가 돌려주는 건 실제로 바뀐 행이 아니라 WHERE에 매칭된 행이다.
내 쿼리는 locked_until이 항상 now + lease로 앞으로 움직이니까 매칭 = 변경이라 두 해석이 같은 답을 준다.
값이 그대로일 수 있는 UPDATE로 락을 만들면 여기서 조용히 깨진다.
함정 2 — 트랜잭션 전파
이게 제일 고약하다.
@Modifying
@Transactional(propagation = Propagation.REQUIRES_NEW) // ← 빠지면 정반대로 동작
fun tryAcquire(...): Int
락 획득은 즉시 커밋되어야 한다.
잡 전체를 감싸는 트랜잭션 안에서 획득하면 커밋이 잡이 끝날 때까지 미뤄진다.
그러면 두 번째 컨테이너는 "건너뛰기"가 아니라 행 락에 걸려 대기한다.
그리고 잡이 끝나는 순간 대기가 풀려 곧바로 두 번째 실행이 시작된다.
막으려던 것을 정확히 하게 된다. 코드는 똑같이 생겼는데.
리스 — 유일한 트레이드오프
locked_until은 "이 시간 안에 안 끝나면 죽은 걸로 친다"는 선언이다.
양쪽으로 틀릴 수 있다.
- 너무 짧으면: 잡이 정상적으로 오래 걸리는데 리스가 먼저 만료 → 다른 컨테이너가 가져감 → 막으려던 이중 실행이 그대로 일어난다
- 너무 길면: 컨테이너가 잡 도중 죽었을 때 그 시간만큼 스케줄러가 멈춘다
원칙은 리스 ≥ p99 실행 시간, 그 위에서 짧을수록 좋다. 나는 tick 5분, 나머지 5~10분으로 잡았다.
한 가지 안심되는 점: Spring 기본 TaskScheduler는 스레드 풀 크기가 1이라 잡들이 순차 실행된다.
한 컨테이너 안에서는 같은 잡이 자기 자신과 겹치지 않는다.
리스가 의미를 갖는 건 오직 컨테이너가 둘일 때뿐이다.
해제 — 소유자 확인
UPDATE scheduler_lock SET locked_until = :now
WHERE lock_name = :name AND locked_by = :who -- ← 없으면 사고
locked_by가 없으면 이렇게 된다.
A가 락 획득 (리스 5분)
A의 잡이 6분 걸림 → 리스 만료
B가 락 획득
A의 잡이 끝남 → 해제 → B가 쥔 락을 A가 풀어버린다
래퍼
fun runIfFree(name: String, lease: Duration, block: () -> Unit) {
val now = LocalDateTime.now(clock)
if (repo.tryAcquire(name, now.plus(lease), now, instanceId) == 0) {
if (!repo.existsById(name)) {
log.error("[스케줄러락] '$name' 행이 없다 — 마이그레이션 누락. 이 잡은 영원히 실행되지 않는다")
} else {
log.info("[스케줄러락] '$name' 을 다른 인스턴스가 보유 중 — 건너뜀")
}
return
}
try { block() } finally {
repo.release(name, LocalDateTime.now(clock), instanceId)
}
}
existsById 분기가 있는 이유: 획득 실패(0행)는 "남이 쥠"과 "행이 아예 없음" 두 경우에 똑같이 나온다.
시드 INSERT를 빠뜨리면 그 스케줄러는 아무 로그도 없이 영원히 안 도는데, 그건 며칠 뒤에야 발견된다.
실패 경로에서만 한 번 더 조회한다.
호출부는 이렇게 된다.
@Scheduled(fixedDelay = 60_000)
fun tick() = lock.runIfFree("invoice-agent-tick", Duration.ofMinutes(5)) {
// 기존 본문 그대로
}
AOP 애너테이션은 만들지 않았다. 호출 지점이 넷뿐이고 명시적 래퍼가 읽기도 테스트하기도 쉽다.
전환 스크립트
포트 두 개(8090/8091)를 번갈아 쓴다.
"지금 어느 쪽이 서비스 중인가"는 별도 상태 파일을 두지 않고 Nginx upstream 파일에서 읽는다.
그게 단일 기준(source of truth)이다.
# /etc/nginx/conf.d/nonos-upstream.conf
upstream nonos_app { server 127.0.0.1:8090; }
set -euo pipefail
CURRENT_PORT=$(grep -oE '127\.0\.0\.1:[0-9]+' "$UPSTREAM" | cut -d: -f2)
if [ "$CURRENT_PORT" = "8090" ]; then NEW_PORT=8091; NEW_NAME=nonos-green
else NEW_PORT=8090; NEW_NAME=nonos-blue; fi
docker rm -f "$NEW_NAME" 2>/dev/null || true
docker run -d --name "$NEW_NAME" -p 127.0.0.1:$NEW_PORT:8080 ... "$IMAGE:$GIT_SHA"
# 준비될 때까지 (최대 90초)
READY=0
for _ in $(seq 1 45); do
if curl -sf "http://127.0.0.1:$NEW_PORT/actuator/health/readiness" | grep -q '"status":"UP"'; then
READY=1; break
fi
sleep 2
done
if [ "$READY" != "1" ]; then
echo "새 컨테이너가 안 떴다. 배포 중단 — 기존 컨테이너는 계속 서비스 중이다."
docker logs --tail 200 "$NEW_NAME"; docker rm -f "$NEW_NAME"; exit 1
fi
# 전환
echo "upstream nonos_app { server 127.0.0.1:$NEW_PORT; }" | sudo tee "$UPSTREAM" > /dev/null
sudo nginx -t && sudo nginx -s reload
sleep 30 # drain
OLD_ID=$(docker ps -q --filter "name=nonos" --filter "publish=$CURRENT_PORT")
[ -n "$OLD_ID" ] && docker stop -t 40 "$OLD_ID" && docker rm "$OLD_ID"
--filter "name=nonos"는 안전장치다. 같은 호스트에 다른 서비스가 8080으로 떠 있는데, 포트만으로 찾다가 변수가 꼬이면 남의 서비스를 죽인다.
nginx -s reload는 그 자체로 무중단이다. 마스터가 새 워커를 띄우고 옛 워커는 처리 중인 요청을 끝낸 뒤 종료한다.
타임라인
t=0 새 컨테이너 기동 (8091) [8090 이 계속 서비스]
t≈15 readiness → UP
t=15 nginx reload [새 요청은 8091 로]
t=45 drain 끝
t=45 docker stop -t 40 (8090) [진행 중 요청은 마치고 종료]
t≈46 종료 완료
502: 0초
덤으로 알게 된 것들
이미지에 커밋 SHA를 붙여야 한다
:latest 하나만 푸시하고 있었다.
즉 되돌릴 방법이 없었다. 이전 이미지는 덮여서 사라진다.
tags: |
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ github.sha }}
docker image prune -f는 dangling 이미지만 지우니까 SHA 태그가 붙은 과거 이미지는 남는다. 롤백이 한 줄이 된다.
무중단은 마이그레이션을 더 어렵게 만든다
겹치는 45초 동안 옛 코드와 새 코드가 같은 DB를 동시에 본다.
겹침 구간에서
| ADD COLUMN (nullable) | 안전 — 옛 코드는 모르는 컬럼을 무시한다 |
| CREATE TABLE, ADD INDEX | 안전 |
| DROP COLUMN, RENAME | 위험 — 옛 코드의 SELECT 가 45초간 전부 실패 |
| NOT NULL 추가 | 위험 — 옛 코드의 INSERT 가 실패 |
원칙은 expand → deploy → contract. 컬럼 이름을 바꾸려면 세 번에 나눈다: 새 컬럼 추가(양쪽에 쓰기) → 데이터 이관 → 다음 배포에서 옛 컬럼 제거.
"배포는 실패했는데 사이트는 멀쩡"이 처음 가능해진다
이게 제일 큰 소득일지도 모르겠다. 예전엔 옛 컨테이너를 먼저 지우니까 새 컨테이너가 안 뜨면 사이트가 내려간 채로 끝났다. 이제는 헬스체크에서 걸리면 새 컨테이너만 지우고 나간다.
첫 배포에만 있는 함정 — 락이 한쪽에만 있다
배포 스크립트가 쓰는 health 엔드포인트는 앱 안에 있다.
그래서 "앱을 먼저 배포해야 하고 그 배포는 아직 무중단이 아니겠구나" 생각했는데, 틀렸다.
스크립트가 readiness를 묻는 대상은 새 컨테이너뿐이고 옛 컨테이너는 멈추기만 하면 된다.
전제는 Nginx upstream 파일이 미리 있는 것 하나뿐이라, 첫 배포부터 무중단이 된다.
진짜 함정은 다른 데 있었다. 겹치는 45초 동안 옛 컨테이너에는 락 코드가 없다.
새 컨테이너 (락 있음) → 락 잡음 → 실행
옛 컨테이너 (락 없음) → 락을 모름 → 그냥 실행 ← 막을 방법이 없다
이 한 번은 락이 아무것도 막지 못한다. 나는 위험한 잡(송장 에이전트)의 on/off가 마침 DB 컬럼이라 배포 직전에 꺼두고 끝나고 켰다. 재시작 없이 양쪽 컨테이너에 즉시 적용된다.
두 번째 배포부터는 필요 없다 — 그때는 양쪽 다 락을 갖는다.
검증
배포하는 동안 다른 터미널에서 계속 요청을 보낸다.
while :; do
printf '%s %s\n' "$(date +%T)" \
"$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 https://나의도메인/)"
sleep 0.3
done
200이 아닌 게 하나라도 나오면 실패다. 바꾸기 전에 같은 걸 돌려서 502가 20~30초 나오는 걸 먼저 찍어두면 비교가 된다.
락은 로그로 확인한다. 전환 구간에 새 컨테이너에서 이게 보여야 한다.
[스케줄러락] 'invoice-agent-tick' 을 다른 인스턴스가 보유 중 — 건너뜀
그리고 락 테스트는 뮤테이션 테스트로 검증했다.
tryAcquire의 AND locked_until <= :now를 지우면 "스레드 20개 중 정확히 1개만 실행" 테스트가 반드시 실패해야 한다.
실패하지 않으면 그 테스트는 아무것도 증명하지 않는 것이다.
정리
무중단 배포를 검색하면 대부분 블루/그린 스크립트만 나온다. 그건 셋 중 하나다.
- 준비 확인 — actuator readiness, db를 그룹에 넣을 것
- 요청 보호 — drain + graceful shutdown + docker stop -t
- 겹침 안전 — DB 락. 컨테이너가 하나일 땐 존재하지 않던 문제가 여기서 처음 생긴다
세 번째를 빼먹으면 무중단 배포가 오히려 사고를 만든다. 내 경우엔 송장 이중 발송이었다.