이중화 환경에서 무중단 배포 프로세스 설계 - Maintenance 기반 L4 헬스체크와 Graceful Shutdown
들어가며
이중화 환경에서 배포를 진행하면 당연히 무중단일 것이라 생각했다.
하지만 실제로는:
- 배포 시점에 일부 요청이 5xx 에러 발생
- 처리 중인 요청이 강제 종료됨
- “이중화인데 왜?” 라는 의문
핵심 문제:
- L4가 서버로 트래픽을 계속 보내는 상태에서 재기동
- 처리 중인 요청을 기다리지 않고 즉시 종료
이 문제를 해결하기 위해 L4 헬스체크 제어 + Graceful Shutdown을 결합한 무중단 배포 프로세스를 설계했다.
1. 무중단 배포의 핵심 조건
무중단 배포란?
서비스 전체의 가용성을 유지한 채 서버를 순차적으로 교체/재기동하는 것
반드시 만족해야 할 조건
1. 배포 대상 서버로 신규 요청이 유입되지 않아야 함
- L4에서 해당 서버를 Down으로 인식
- 모든 신규 트래픽을 다른 서버로 전환
2. 이미 처리 중인 요청은 안전하게 종료되어야 함
- 요청 처리 중 강제 종료 방지
- 모든 Thread가 작업 완료 후 종료
현재 구조
[L4 / ADC]
/ \
[L7-1] [L7-2]
| |
[app-api] [app-api]
배포 방식:
- L7-1 배포 → L7-2 배포 (순차)
- 항상 최소 1대는 트래픽 처리
2. L4 헬스체크 구조
헬스체크 방식
L4(ADC)가 주기적으로 L7 서버의 상태를 확인
동작:
- 5초 간격으로 헬스체크 URL 호출
- HTTP 200 → 서버 Up
- HTTP 200 이외 → 서버 Down
- 연속 실패 시 트래픽 차단
현재 헬스체크 엔드포인트
@RestController
@RequestMapping("/monitor")
public class MonitorController {
@Value("${health.check.file.path}")
private String checkFilePath;
@GetMapping("/l7check")
public ResponseEntity<String> checkL4ToL7() {
return ResponseEntity.status(getHttpStatus()).build();
}
private HttpStatus getHttpStatus() {
return isDeployMode() ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK;
}
private boolean isDeployMode() {
return Files.exists(Path.of(checkFilePath));
}
}
핵심 로직:
maintenance파일 존재 여부로 배포 모드 판단- 배포 모드 → 503 응답 → L4가 Down으로 인식
application.yml 설정
health:
check:
file:
path: /app/maintenance
3. Maintenance 파일 기반 트래픽 제어
설계 원리
파일 기반 제어의 장점:
- 서버 재기동 없이 트래픽 제어
- L4 설정 변경 불필요
- 애플리케이션 레벨에서 완전 제어
동작 방식
# 1. maintenance 파일 생성
$ touch /app/maintenance
# 헬스체크 응답: 503 Service Unavailable
# L4가 서버 Down으로 인식
# 신규 트래픽 차단
# 2. 배포 완료 후 파일 제거
$ rm /app/maintenance
# 헬스체크 응답: 200 OK
# L4가 서버 Up으로 인식
# 트래픽 복구
L4 헬스체크 실패 처리 시간
ADC 벤더별 Down 인지 소요 시간:
| ADC 벤더 | Down 인지 시간 |
|---|---|
| A10 | 약 15 ~ 20초 |
| Citrix | 약 7 ~ 12초 |
중요:
- maintenance 생성 후 충분한 대기 시간 필수
- 너무 짧으면 트래픽이 계속 유입
4. Graceful Shutdown의 필요성
문제 상황
“L4에서 트래픽을 막았으니 이제 안전한가?”
아니다
왜?
- 서버 내부에는 처리 중인 요청(Thread)이 존재
- 이 상태에서 서버를 종료하면?
- 요청 도중 강제 종료
기존 stop.sh의 문제점
#!/bin/sh
# 기존 종료 스크립트
PID=$(cat /app/app.pid)
# SIGTERM 전송
kill -15 $PID
# 2초 대기
sleep 2
# 최대 4회 확인
for i in {1..4}; do
if ! ps -p $PID > /dev/null; then
echo "프로세스 종료됨"
exit 0
fi
sleep 2
done
# 강제 종료
kill -9 $PID
문제:
kill -15(SIGTERM) 전송 시 Spring은 즉시 종료- 처리 중인 요청을 기다려주지 않음
- 요청 처리 Thread에
InterruptedException발생
SIGTERM에 대한 흔한 오해
오해:
“kill -15면 스프링이 알아서 다 끝내고 종료하지 않을까?”
실제:
- 설정이 없으면 즉시 종료
- 처리 중인 요청 무시
- 무중단 배포 실패
5. Spring Boot Graceful Shutdown 적용
설정
application.yml:
server:
shutdown: graceful # immediate(기본값) → graceful로 변경
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 최대 대기 시간
동작 방식
1. SIGTERM 수신
kill -15 → Spring이 종료 신호 수신
2. 신규 요청 차단
새로운 요청 → 503 Service Unavailable
3. 기존 요청 처리 대기
처리 중인 Thread들이 작업 완료될 때까지 대기
최대 timeout-per-shutdown-phase 시간까지
4. 안전한 종료
모든 요청 완료 → 애플리케이션 종료
또는 타임아웃 초과 시 강제 종료
6. Graceful Shutdown 동작 검증
테스트 Controller
@RestController
@Slf4j
public class TestController {
@GetMapping("/test/long-request")
public ResponseEntity<String> longRequest() {
log.info("요청 처리 시작");
try {
// 60초 동안 처리 중인 요청 시뮬레이션
Thread.sleep(60000);
log.info("요청 처리 완료");
return ResponseEntity.ok("Success");
} catch (InterruptedException e) {
log.error("요청 처리 중 인터럽트 발생", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Interrupted");
}
}
}
Case 1: Immediate 모드 (기본값)
설정:
server:
shutdown: immediate # 기본값
테스트:
# 1. 요청 전송 (60초 대기)
$ curl http://localhost:8080/test/long-request &
# 2. 서버 종료
$ kill -15 $(cat app.pid)
결과:
[1] 요청 처리 시작
[2초 후] 요청 처리 중 인터럽트 발생: InterruptedException
[2초 후] 서버 종료됨
약 2초 후 즉시 종료, 요청 실패
Case 2: Graceful 모드
설정:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
테스트:
# 동일한 테스트 실행
$ curl http://localhost:8080/test/long-request &
$ kill -15 $(cat app.pid)
결과:
[1] 요청 처리 시작
[30초 대기] ...
[30초 후] 요청 처리 완료: Success
[30초 후] 서버 종료됨
최대 30초 대기, 요청 정상 완료
검증 로그
Graceful 모드 로그:
2025-01-04 10:15:30.123 INFO --- [http-nio-8080-exec-1] : 요청 처리 시작
2025-01-04 10:15:32.456 INFO --- [main] o.s.b.w.e.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2025-01-04 10:16:00.789 INFO --- [http-nio-8080-exec-1] : 요청 처리 완료
2025-01-04 10:16:01.012 INFO --- [main] o.s.b.w.e.tomcat.GracefulShutdown : Graceful shutdown complete
7. 최종 무중단 배포 프로세스
전체 배포 플로우
[L7-1 배포]
1. maintenance 생성
2. L4가 Down 인식 대기 (20초)
3. 서버 종료 (Graceful Shutdown)
4. 새 버전 배포
5. 서버 기동
6. 헬스체크 검증
7. maintenance 제거
8. L4가 Up 인식 대기
[L7-2 배포]
9. 동일 프로세스 반복
deploy.sh 스크립트
#!/bin/sh
SERVICE_NAME="app-api"
SERVICE_PORT=8080
MAINTENANCE_FILE="/app/maintenance"
echo "=========================================="
echo "${SERVICE_NAME} 무중단 배포 시작"
echo "=========================================="
# Step 1: Maintenance 모드 진입
echo "[Step 1] Maintenance 모드 진입"
touch ${MAINTENANCE_FILE}
echo "- maintenance 파일 생성 완료"
# Step 2: L4 헬스체크 실패 대기
echo "[Step 2] L4 트래픽 차단 대기 (20초)"
echo "- L4가 서버 Down 상태로 인식할 때까지 대기"
sleep 20
echo "- 트래픽 차단 완료"
# Step 3: 서버 종료 (Graceful Shutdown)
echo "[Step 3] 서버 종료 (Graceful Shutdown)"
./stop.sh
if [ $? -ne 0 ]; then
echo "[ERROR] 서버 종료 실패"
rm ${MAINTENANCE_FILE}
exit 1
fi
echo "- 서버 정상 종료 완료"
# Step 4: 새 버전 배포
echo "[Step 4] 새 버전 배포"
# WAR 파일 복사, 설정 파일 업데이트 등
cp build/libs/${SERVICE_NAME}.jar /app/
echo "- 배포 완료"
# Step 5: 서버 기동
echo "[Step 5] 서버 기동"
./start.sh
if [ $? -ne 0 ]; then
echo "[ERROR] 서버 기동 실패"
exit 1
fi
echo "- 서버 기동 완료"
# Step 6: 헬스체크 검증
echo "[Step 6] 애플리케이션 헬스체크"
./deploy_health_check.sh ${SERVICE_PORT}
if [ $? -ne 0 ]; then
echo "[ERROR] 헬스체크 실패"
exit 1
fi
echo "- 헬스체크 정상"
# Step 7: Maintenance 모드 해제
echo "[Step 7] Maintenance 모드 해제"
rm ${MAINTENANCE_FILE}
echo "- maintenance 파일 제거 완료"
# Step 8: L4 헬스체크 성공 대기
echo "[Step 8] L4 트래픽 복구 대기 (10초)"
echo "- L4가 서버 Up 상태로 인식할 때까지 대기"
sleep 10
echo "- 트래픽 복구 완료"
echo "=========================================="
echo "${SERVICE_NAME} 무중단 배포 완료"
echo "=========================================="
exit 0
stop.sh (Graceful Shutdown 지원)
#!/bin/sh
PID_FILE="/app/app.pid"
SHUTDOWN_TIMEOUT=40 # Graceful Shutdown 타임아웃 + 여유
if [ ! -f ${PID_FILE} ]; then
echo "[WARN] PID 파일이 없습니다."
exit 0
fi
PID=$(cat ${PID_FILE})
if ! ps -p ${PID} > /dev/null; then
echo "[INFO] 프로세스가 이미 종료되었습니다."
rm ${PID_FILE}
exit 0
fi
echo "[INFO] 프로세스 종료 시작 (PID: ${PID})"
echo "- Graceful Shutdown 진행 중..."
# SIGTERM 전송
kill -15 ${PID}
# Graceful Shutdown 대기
for ((i=1; i<=${SHUTDOWN_TIMEOUT}; i++)); do
if ! ps -p ${PID} > /dev/null; then
echo "[SUCCESS] 프로세스 정상 종료 (${i}초 소요)"
rm ${PID_FILE}
exit 0
fi
if [ $((i % 5)) -eq 0 ]; then
echo "- 종료 대기 중... (${i}/${SHUTDOWN_TIMEOUT}초)"
fi
sleep 1
done
# 타임아웃 시 강제 종료
echo "[WARN] Graceful Shutdown 타임아웃, 강제 종료 시도"
kill -9 ${PID}
sleep 2
if ! ps -p ${PID} > /dev/null; then
echo "[SUCCESS] 프로세스 강제 종료 완료"
rm ${PID_FILE}
exit 0
else
echo "[ERROR] 프로세스 종료 실패"
exit 1
fi
핵심 변경 사항:
- 타임아웃을 40초로 증가 (Graceful Shutdown 30초 + 여유 10초)
- 5초마다 대기 상태 로깅
- 타임아웃 초과 시에만 강제 종료
8. Jenkins 파이프라인 통합
Jenkinsfile
pipeline {
agent any
parameters {
choice(name: 'TARGET_SERVER', choices: ['L7-1', 'L7-2', 'ALL'], description: '배포 대상 서버')
}
stages {
stage('Build') {
steps {
sh './gradlew clean build'
}
}
stage('Deploy to L7-1') {
when {
expression { params.TARGET_SERVER == 'L7-1' || params.TARGET_SERVER == 'ALL' }
}
steps {
script {
deployToServer('L7-1', '192.168.1.10')
}
}
}
stage('Deploy to L7-2') {
when {
expression { params.TARGET_SERVER == 'L7-2' || params.TARGET_SERVER == 'ALL' }
}
steps {
script {
deployToServer('L7-2', '192.168.1.11')
}
}
}
}
post {
success {
echo "배포 성공"
}
failure {
echo "배포 실패"
// 알림 전송 등
}
}
}
def deployToServer(String serverName, String serverIp) {
echo "=========================================="
echo "${serverName} 배포 시작"
echo "=========================================="
// JAR 파일 전송
sh "scp build/libs/app-api.jar ${serverIp}:/app/"
// 무중단 배포 스크립트 실행
def result = sh(
script: "ssh ${serverIp} '/app/deploy.sh'",
returnStatus: true
)
if (result != 0) {
error("${serverName} 배포 실패")
}
echo "${serverName} 배포 완료"
}
9. 배포 시나리오별 동작 흐름
시나리오 1: 정상 배포
[L7-1]
10:00:00 | maintenance 생성
10:00:00 | L4 헬스체크 시작 실패
10:00:20 | L4가 L7-1 Down 인식 → 모든 트래픽 L7-2로
10:00:20 | 서버 종료 시작 (Graceful Shutdown)
10:00:50 | 처리 중이던 요청 모두 완료 → 서버 종료
10:00:55 | 새 버전 배포 및 기동
10:01:30 | 헬스체크 성공
10:01:30 | maintenance 제거
10:01:30 | L4 헬스체크 성공
10:01:40 | L4가 L7-1 Up 인식 → 트래픽 분산 재개
[L7-2]
10:02:00 | L7-1과 동일한 프로세스 반복
결과:
- 전 과정에서 사용자 요청 실패 0건
- 항상 최소 1대가 서비스 제공
시나리오 2: 애플리케이션 기동 실패
[L7-1]
10:00:00 ~ 10:00:20 | maintenance 생성 및 대기
10:00:20 ~ 10:00:50 | 서버 종료 (Graceful)
10:00:55 | 서버 기동 시작
10:01:30 | 헬스체크 실패 (DB 연결 오류)
[배포 스크립트]
[ERROR] 헬스체크 실패
exit 1 → 배포 중단
[현재 상태]
- L7-1: Down (maintenance 유지)
- L7-2: Up (정상 서비스)
서비스 가용성 유지
결과:
- 배포 실패했지만 서비스는 정상
- L7-2가 모든 트래픽 처리
- L7-1 문제 해결 후 재배포
10. 모니터링 및 검증
헬스체크 엔드포인트 확장
@RestController
@RequestMapping("/monitor")
@RequiredArgsConstructor
public class MonitorController {
private final HealthChecker healthChecker;
@Value("${health.check.file.path}")
private String checkFilePath;
// L4 헬스체크용
@GetMapping("/l7check")
public ResponseEntity<String> checkL4ToL7() {
if (isDeployMode()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body("Deploy Mode");
}
return ResponseEntity.ok("OK");
}
// 상세 헬스체크용 (배포 검증)
@GetMapping("/health")
public ResponseEntity<HealthCheckResponse> health() {
if (isDeployMode()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(HealthCheckResponse.deployMode());
}
HealthCheckResponse response = healthChecker.check();
return ResponseEntity.status(response.isHealthy() ?
HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE)
.body(response);
}
private boolean isDeployMode() {
return Files.exists(Path.of(checkFilePath));
}
}
HealthChecker 구현
@Component
@RequiredArgsConstructor
@Slf4j
public class HealthChecker {
private final DataSource dataSource;
public HealthCheckResponse check() {
List<String> errors = new ArrayList<>();
// DB 연결 체크
if (!checkDatabase()) {
errors.add("DB 연결 실패");
}
// 필요 시 외부 API 체크
// if (!checkExternalApi()) {
// errors.add("외부 API 연결 실패");
// }
boolean healthy = errors.isEmpty();
return HealthCheckResponse.of(healthy, errors);
}
private boolean checkDatabase() {
try (Connection conn = dataSource.getConnection()) {
return conn.isValid(1);
} catch (Exception e) {
log.error("DB 헬스체크 실패", e);
return false;
}
}
}
HealthCheckResponse
@Getter
@AllArgsConstructor
public class HealthCheckResponse {
private String status;
private boolean healthy;
private List<String> errors;
private LocalDateTime checkTime;
public static HealthCheckResponse of(boolean healthy, List<String> errors) {
return new HealthCheckResponse(
healthy ? "UP" : "DOWN",
healthy,
errors,
LocalDateTime.now()
);
}
public static HealthCheckResponse deployMode() {
return new HealthCheckResponse(
"MAINTENANCE",
false,
List.of("서버가 배포 모드입니다"),
LocalDateTime.now()
);
}
}
배포 중 상태 확인
# L4 헬스체크 상태
$ curl http://localhost:8080/monitor/l7check
503 Service Unavailable (배포 모드)
# 상세 헬스체크
$ curl http://localhost:8080/monitor/health
{
"status": "MAINTENANCE",
"healthy": false,
"errors": ["서버가 배포 모드입니다"],
"checkTime": "2025-01-04T10:00:00"
}
11. 주의사항 및 트러블슈팅
1. L4 대기 시간 부족
증상:
- 배포 중 일부 요청이 배포 대상 서버로 유입
- 503 에러 발생
원인:
- L4가 Down을 인식하기 전에 서버 종료
해결:
# 대기 시간 증가 (15초 → 25초)
sleep 25
2. Graceful Shutdown 타임아웃 부족
증상:
- 장시간 실행되는 배치 작업 중단
- 파일 업로드 중 연결 끊김
원인:
- 타임아웃(30초)보다 긴 작업 존재
해결:
spring:
lifecycle:
timeout-per-shutdown-phase: 60s # 타임아웃 증가
3. 동시 배포 방지
문제:
- L7-1, L7-2 동시 배포 시 전체 서비스 중단
해결:
// Jenkinsfile에 lock 추가
stage('Deploy to L7-1') {
steps {
lock(resource: 'deployment-lock') {
deployToServer('L7-1', '192.168.1.10')
}
}
}
4. Maintenance 파일 권한
문제:
- maintenance 파일 생성/삭제 권한 없음
해결:
# 배포 디렉토리 권한 확인
$ ls -la /app
drwxr-xr-x deploy deploy /app
# 배포 사용자 권한 부여
$ chown -R deploy:deploy /app
12. 성능 영향 분석
Graceful Shutdown의 오버헤드
측정 환경:
- 일반 요청: 평균 100ms
- Graceful Shutdown 설정: 30초
결과:
- 처리 중인 요청 없을 때: 즉시 종료 (1~2초)
- 처리 중인 요청 있을 때: 요청 완료 후 종료 (평균 5~10초)
- 타임아웃 대기: 최대 30초
결론:
- 성능 오버헤드 없음
- 무중단 배포를 위한 필수 대기 시간
L4 헬스체크 주기 최적화
현재:
- 5초 간격 헬스체크
- 연속 실패 시 Down 처리
고려사항:
- 주기 짧음 → 빠른 복구, L4 부하 증가
- 주기 길음 → 느린 복구, L4 부하 감소
권장:
- 5초 주기 유지 (일반적인 설정)
- 배포 시 20초 대기 (여유 있게)
13. 실무 적용 체크리스트
배포 전 확인사항
□ application.yml에 Graceful Shutdown 설정 완료
□ L4 헬스체크 엔드포인트 정상 동작 확인
□ maintenance 파일 경로 및 권한 확인
□ 배포 스크립트 실행 권한 확인
□ 헬스체크 스크립트 동작 확인
□ Jenkins 파이프라인 설정 완료
□ 배포 순서 정의 (L7-1 → L7-2)
배포 중 확인사항
□ L4에서 대상 서버 Down 상태 확인
□ 다른 서버로 트래픽 전환 확인
□ 서버 종료 로그에서 Graceful Shutdown 확인
□ 헬스체크 성공 확인
□ L4에서 대상 서버 Up 상태 확인
배포 후 확인사항
□ 모든 서버 정상 기동 확인
□ L4 트래픽 분산 확인
□ 애플리케이션 로그 에러 확인
□ 모니터링 지표 정상 확인
□ 사용자 요청 성공률 확인
14. 결론
무중단 배포의 핵심
무중단 배포는 단일 기술로 해결되지 않는다.
반드시 함께 가야 할 3가지:
1. L4 헬스체크 제어
- Maintenance 파일 기반
- 신규 트래픽 차단
2. Graceful Shutdown
- 처리 중인 요청 보호
- 안전한 종료
3. 체계적인 배포 프로세스
- 충분한 대기 시간
- 헬스체크 검증
- 순차적 배포
트래픽 차단 + 안전한 종료 = 진짜 무중단 배포
이중화 환경이라고 무중단이 자동으로 되는 것이 아니다.
L4부터 애플리케이션까지 모든 레이어를 고려해야 한다.
15. 설계 과정에서 배운 것
1. “이중화 = 무중단”이 아니다
- 이중화는 조건이지 결과가 아님
- 올바른 배포 프로세스가 반드시 필요
2. L4와 애플리케이션의 협력
- L4 헬스체크만으로는 부족
- 애플리케이션의 Graceful Shutdown 필수
3. 충분한 대기 시간의 중요성
- 너무 짧으면 트래픽 차단 실패
- 너무 길면 배포 시간 증가
- 벤더별 특성 이해 필요
4. 검증의 중요성
- 배포 후 헬스체크 필수
- 실패 시 자동 중단 메커니즘 필요
5. 모니터링과 로깅
- 각 단계별 상태 로깅
- 문제 발생 시 추적 가능
댓글남기기