본문 바로가기
AWS

930일 동안 이중화가 0이었다 — mod_jk에서 ALB 블루그린으로

by 꼼냥냥 2026. 9. 2.
728x90

이중화된 줄 알았던 구간이 사고로 한 대만 남은 채 930일을 돌고 있었다. 에러가 안 났고, 그래서 아무도 몰랐다. 아파치 mod_jk 뒤에 Tomcat 여러 대를 두는 구성을 ALB 블루그린으로 옮기면서 알게 된 것들이다. "무중단으로 보이지만 실은 이중화 0"인 상태가 가능하고, 그 상태는 평상시에 아무 신호도 내지 않는다.

"무중단인 줄 알았는데"

기존 구조는 아파치 mod_jk 뒤에 Tomcat 여러 대였다. 오래된 자바 웹 서비스에서 흔한 구성이고, 몇 년 동안 별 탈 없이 돌았다.

원인은 mod_jk의 sticky_session_force 설정이다. 이게 없으면 죽은 워커로 핀 된 세션이 조용히 다른 워커로 넘어간다. 사용자는 로그인이 풀리거나 장바구니가 비는 정도를 겪지만, 그건 "가끔 그러네"로 넘어간다. 로그에는 아무것도 안 남는다.

이걸 알게 된 건 새 구조로 옮기려고 실물을 세어 보면서였다. 옮기지 않았다면 계속 몰랐을 것이다.

배포할 때 앱이 안 죽는 것도 문제였다

배포 로그에 이게 자주 찍혔다.

Data source is closed

처음엔 앱이 죽는 신호로 읽었는데, 반대다. 앱이 안 죽은 것이다. kill -9였다면 로그조차 안 남는다.

무슨 일이 벌어지냐면,

  1. Tomcat 종료는 커넥터를 pause만 한다 — 새 소켓만 차단하고 기존 건 살아 있다
  2. 그런데 컨텍스트(DataSource 포함)는 먼저 내린다
  3. mod_jk는 기존 AJP 커넥션으로 계속 요청을 밀어넣는다
  4. Tomcat이 그 요청에 정상 HTTP 500을 돌려준다

4번이 핵심이다. HTTP 500은 정상 응답이라 mod_jk 페일오버가 안 걸린다. 다른 인스턴스가 멀쩡히 있어도 소용이 없다. 커넥션이 이미 이쪽에 잡혀 있기 때문이다.

답은 하나다. 앞단에서 먼저 빼는 드레이닝 단계를 넣는 것.

새 구조 — 인스턴스 안에서 블루그린

인스턴스당 Tomcat을 두 벌 띄우고, ALB 타깃 그룹에 동적으로 등록/해제한다.

      ALB Target Group
        ├── 8180 (blue)   ← 지금 서비스 중
        └── 8280 (green)  ← 새 버전을 여기 올린다

배포 순서는 이렇다.

1. green 에 새 버전 기동
2. green 헬스체크 통과 확인
3. green 을 타깃 그룹에 등록
4. blue 를 타깃 그룹에서 해제
5. 드레이닝 대기 (진행 중이던 요청이 끝날 때까지)
6. blue 정지

5번이 앞의 Data source is closed를 없앤다. 타깃 그룹에서 빠진 뒤에 죽이니까 새 요청이 안 온다.

포트에도 규칙을 넣었다 — 8<앱><인스턴스>. 박스에 올라간 앱이 늘어도 포트만 보면 뭐가 뭔지 안다. (2편에서 로컬 터널 포트 앞자리를 계정으로 준 것과 같은 발상이다.)

드레이닝 대기를 한 함수로 합쳤다

처음엔 등록 대기와 해제 대기를 따로 짰는데, 로그가 중복으로 찍히고 타이머가 리셋되는 버그가 있었다. 상태를 기다리는 함수 하나(wait_state)로 합쳤다.

배포 스크립트에서 "기다리는 코드"는 생각보다 자주 틀린다. 대기는 한 군데로 모으는 게 낫다.

스티키가 필요한지 판단하는 기준

ALB로 옮기면서 "스티키 세션을 켜야 하나"를 정해야 했다. 기준은 하나다.

세션이 Redis 같은 공유 저장소에 있으면 필요 없고, JVM 메모리에 있으면 필요하다.

문제는 옛 구조에서는 이 차이가 안 드러난다는 것이다. mod_jk의 jvmRoute가 세션 ID 뒤에 워커 이름을 붙여(JSESSIONID=….bt1) 자동으로 스티키를 걸어 주고 있었기 때문이다.

ALB로 옮기는 순간에만 드러난다. 그러니 옮기기 전에 확인해야 한다.

하나 덧붙이면, 로그인 기능이 없는 앱도 세션을 쓴다. 인터셉터가 getSession()을 부르면 그 시점에 세션이 생긴다. "로그인이 없으니 세션도 없다"는 확인 없이 믿을 게 못 된다.

헬스체크 — 죽어도 모르는 상태

이건 별도로 다뤄야 할 만큼 고약하다.

멀티테넌트 앱의 헬스체크를 /로 두면 항상 unhealthy다. 테넌트 Host 헤더 없이 /를 부르면 404라서 이렇게 실패한다.

Target.ResponseCodeMismatch [404]

그런데 서비스는 정상으로 보인다. ALB는 한 타깃 그룹의 타깃이 전부 unhealthy면 그래도 트래픽을 보내기 때문이다(fail-open).

결과가 뭐냐면,

  • 평소: 전부 unhealthy → 그래도 다 보냄 → 정상으로 보임
  • 앱이 진짜 죽었을 때: 여전히 전부 unhealthy → 상태 변화가 없다

헬스체크가 있는데 아무것도 감지하지 못한다. 기존 계정의 ALB 두 개가 이 상태였다.

해결은 인터셉터를 타지 않는 정적 파일을 헬스체크 경로로 주는 것이다.

/resources/images/favicon.ico

배포 스크립트를 만들면서 겪은 것들

git과 오브젝트 스토리지가 한 세트다

CI가 스크립트를 S3에서 받아 실행하는 구조라, 저장소만 고치고 S3에 안 올리면 반영이 안 된다. 한 번 겪고 나서 저장소 규칙 파일에 못 박았다.

"코드를 고쳤다"와 "배포된 코드가 바뀌었다"가 다른 구조에서는, 그 사실 자체를 규칙으로 적어 두는 수밖에 없다.

Terraform이 관리하는 것과 배포가 관리하는 것을 겹치지 않는다

로드밸런서 타깃 등록을 Terraform 관리에서 뺐다. 배포 스크립트가 등록/해제를 다루는데 Terraform도 같은 걸 들고 있으면 서로 되돌린다.

실제로 apply -auto-approve가 배포 중이던 타깃을 도로 등록한 적이 있다. (1편에서 쓴 "auto-approve는 다시 계산한다"가 이 얘기다.)

같은 자원을 두 시스템이 관리하면 언젠가 싸운다. 한쪽이 놓아야 한다.

빌드 박스와 실행 박스는 둘 다 청소해야 한다

도커 이미지 정리를 CI 잡에만 넣어 뒀더니, 그건 빌드 박스만 치운다. 실행 박스는 배포 스크립트가 치워야 하는데 그게 없었다.

디스크가 83%까지 찬 뒤에 알았다.

롤백에서 "조회 실패"를 "없음"으로 읽지 않는다

빌드 번호로 되돌리는 스크립트를 만들었는데, S3 조회가 실패한 경우를 산출물이 없는 경우로 처리하고 있었다. 권한 문제나 일시 오류가 "롤백할 게 없습니다"로 둔갑한다.

되돌리는 도구일수록 실패와 없음을 구분해야 한다.

정리

  • sticky_session_force가 없으면 이중화가 깨져도 에러 없이 몇 년 돈다.
  • 배포 중 Data source is closed는 앱이 죽은 게 아니라 안 죽은 것이다. 앞단 드레이닝이 답.
  • 스티키 필요 여부는 세션 저장소로 판단한다. jvmRoute가 차이를 가리고 있다.
  • 헬스체크를 /로 두면 fail-open 때문에 죽어도 모른다. 정적 파일로.
  • 같은 자원을 Terraform과 배포 스크립트가 같이 관리하지 않는다.

다음 편은 오픈 준비다. Terraform을 아무리 잘 써도 코드로는 안 되는 것들이 있고, 그게 일정을 잡아먹는다.

댓글