역량 UP!/Business

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

태하팍 2026. 8. 24. 14:19
반응형

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 락. 컨테이너가 하나일 땐 존재하지 않던 문제가 여기서 처음 생긴다

세 번째를 빼먹으면 무중단 배포가 오히려 사고를 만든다. 내 경우엔 송장 이중 발송이었다.

 

반응형