ESC

분명 200이라고 했었다

분명 200이라고 했었다 티저 사진
d3h1 Profile Image

김 도훈

개발

이전 글에서 블로그와 다른 프로젝트들을 Oracle Cloud Infrastructure(OCI) 인스턴스에 셀프 호스팅하고, Jenkins와 Nginx로 Blue/Green 무중단 배포를 구성한 과정을 정리했습니다.

배포는 여전히 잘 되고 있습니다. 여러분이 보고 계신 것처럼 작동도 잘하고 있습니다. 역시 Jenkins 파이프라인에 smoke test를 추가하고, Dockerfile에 HEALTHCHECK를 넣기를 잘했다는 생각입니다.

하지만, 과연 smoke test 결과와 /api/health 엔드포인트에서 반환하는 결과가 정말 정상이라는 뜻인지에 대한 의문을 파헤쳐보고 해소해 봤습니다.

healthy가 unhealthy가 되면 어떡하지?


지금 운영하는 서비스는 다섯 개입니다. 블로그, 이력서, 개인정보 처리방침 페이지, 그리고 신한대학교 시간표 마법사의 프론트엔드와 백엔드입니다.

Next.js로 만든 프론트엔드 네 개에는 모두 /api/health 경로를 만들어 두었고, Dockerfile의 HEALTHCHECK가 이 경로를 호출했습니다. 배포 스크립트는 새 컨테이너가 healthy 상태가 된 뒤에 Nginx upstream을 전환했습니다.

그런데 /api/health가 하는 일을 다시 살펴보니, 그냥 200을 반환하는 것이 전부였습니다. 이 응답이 알려 주는 사실은 Node.js 프로세스가 요청에 응답할 수 있다"는 것뿐입니다. 페이지 렌더링이 실패하거나 백엔드와 통신하지 못하는 상황처럼 문제가 있어도 알 수 없었습니다.

게다가 HEALTHCHECK는 배포하는 순간에만 의미가 있었습니다. 배포가 끝난 뒤에 서비스가 멈추면, 컨테이너가 unhealthy로 바뀌어도 그 사실을 알려 주는 곳이 없었습니다.

즉, 기존에는 배포 시에 실제 기능이 동작하는지 확인하지 않았고, 배포가 끝난 뒤에는 아무도 서비스 상태를 감시하지 않고 있었습니다.

계란을 나눠 담아야지


Dockerfile의 HEALTHCHECK에 모든 계란을 담지 말고 나눠 담기로 결정했습니다. 이를 진행하기 위해 확인해야 하는 대상을 나누고 각 역할을 명확히 정했습니다.

단계확인하는 것담당
프로세스 준비컨테이너가 요청받을 수 있는가Dockerfile HEALTHCHECK
배포 직전 기능 확인주요 경로가 실제로 응답하는가배포 스크립트의 스모크 테스트
운영 중 감시지금 이 서비스가 살아 있는가Uptime Kuma
자원 추세디스크와 메모리가 서서히 차고 있지 않은가Beszel

/api/health의 존재 의미 상실

HEALTHCHECK의 역할을 제대로 정하니 200만 반환하던 /api/health 엔드포인트가 필요 없다는 판단이 들었습니다. 제가 서비스하고 있는 프론트엔드들은 모두 favicon을 가지고 있기 때문에 /favicon.ico를 확인하도록 하면 동일한 효과를 낼 수 있기 때문입니다.

/를 확인하는 방법도 있었지만, 배포 직전에 주요 경로를 확인하기 때문에 역할이 중복됩니다. 또한, 신한대학교 시간표 마법사의 프론트엔드는 백엔드에서 강좌 정보를 받아 렌더링하기 때문에, 백엔드가 느려지면 HEALTHCHECK도 느려져 컨테이너가 요청받을 수 있는지 확인하는 검사가 외부 영향을 받으면 안 된다고 판단했습니다.

여기서 예상하지 못한 문제가 하나 있었습니다. 처음에는 fetch로 favicon을 GET 요청했는데, Docker로 검증해 보니 HEALTHCHECK가 3초 제한 시간을 넘겨 실패했습니다. 응답 본문을 읽지 않은 채로 두었더니 요청이 끝나기까지 약 6.2초가 걸렸습니다. 요청 방식을 HEAD로 바꾸자 75~104ms 안에 끝났습니다.

fetch('http://127.0.0.1:' + process.env.PORT + '/favicon.ico', { method: 'HEAD' })

더욱 안전하게

Jenkins 파이프라인에서 사용하는 스크립트에는 새 컨테이너가 healthy가 된 후에 Nginx upstream을 전환하기 전에 실행하는 스모크 테스트를 추가했습니다. 서비스마다 확인할 경로 목록을 정해 두고, 컨테이너 안에서 각 경로를 실제로 요청합니다.

check_routes() {
  for path in $SMOKE_PATHS; do
    request_in_container "$1" "$path" || return 1
  done
}

응답이 200~299가 아니거나 10초 안에 응답하지 않으면 배포를 중단합니다. 이때 Nginx upstream은 기존 컨테이너를 그대로 가리키고, 새 컨테이너는 제거됩니다. 500을 반환하는 상황을 만들어 배포해 보니, 실행 중인 서비스는 영향받지 않았습니다.

백엔드는 상황이 조금 다릅니다. /api/health 엔드포인트가 있는 방향이 올바르므로 제거하지 않았습니다. 강좌 목록이 담긴 json 파일이 없을 때 사용자에게 강좌가 비어 있는 화면을 보게 되는 문제가 있어서 강좌가 없을 때 /api/health 경로에서 503을 반환하도록 했습니다.

지금 살아있는지 알 수 있도록


배포가 끝난 뒤의 빈틈을 메우기 위해 Gatus를 사용해 보았습니다. 설정을 YAML로 관리할 수 있다는 점이 좋았습니다. 하지만, 별도의 로그인 화면도 없었고 DB 연결이 너무 어렵게 느껴져서, Web UI로 관리하고 DB가 내장되어 있는 Uptime Kuma를 선택했습니다.

어디서 감시해야 잘 감시했다고 소문이 나지?

서비스와 같은 인스턴스에서 감시 서버를 배치하면, 해당 인스턴스에 문제가 생겼을 때 감시 서버도 멈춰서 알림을 받을 수 없게 됩니다.

그래서 서비스가 돌아가는 인스턴스 말고 다른 OCI 인스턴스에 Uptime Kuma를 설치했습니다. 서비스들이 동작하는 인스턴스가 멈추더라도 Kuma는 계속 동작하면서 서비스들을 감시합니다.

하지만, 같은 리전에 있으니 리전 전체 장애가 생긴다면 서비스가 동작하는 인스턴스나 Kuma 인스턴스나 모두 영향을 받습니다. 그래서 추후 설명할 Beszel은 다른 리전에 두었습니다.

OCI 춘천 리전
  ├─ 서비스 인스턴스: 블로그, 이력서, 개인정보 처리방침, 시간표 마법사
  └─ 감시 인스턴스: Uptime Kuma
 
OCI 서울 리전
  └─ 감시 인스턴스: Beszel 허브

응답 코드만으로는 몰라요

Uptime Kuma를 설정하면서 가장 많이 고민한 부분은 어떻게 정상 동작을 파악하냐는 것이었습니다. 응답 코드가 200번대라고 해서 무조건 정상 동작이라는 보장은 없습니다.

대부분의 모니터는 HTTP 키워드 방식으로 설정했습니다. 응답 본문에 특정 문자열이 있는지까지 확인하는 방식입니다. 블로그는 페이지 제목을, RSS 피드는 <item> 태그를 확인합니다.

신한대학교 시간표 마법사 프론트엔드는 반대로 설정했습니다. 백엔드와 통신하지 못하면 안내 화면을 200으로 응답하기 때문에, 상태 코드만 보면 계속 정상으로 표시합니다. 그래서 백엔드와 통신하지 못했다는 안내 문구가 표시되지 않으면 정상으로 판단하도록 키워드 반전을 활성화했습니다.

서비스마다 확인할 경로도 따로 골라야 했습니다.

  • Jenkins: 자격증명이 없을 때 /가 403을 반환하기 때문에, 로그인 없이 열리는 /login을 확인합니다.
  • code-server: /healthz가 한동안 사용하지 않으면 alive 대신 expired를 반환합니다. 이 상태도 정상이라서 두 값에 공통으로 들어 있는 status를 키워드로 씁니다.
  • Beszel: /api/health가 API is healthy.를 반환하는지 확인합니다.

인스턴스 감시는 별도로


Uptime Kuma는 서비스가 응답하는지는 알려 주지만, 디스크가 서서히 차는 것처럼 장애가 일어나기 전의 인스턴스 자체 문제는 알려 주지 않습니다.

이런 지표를 보는 도구로는 Prometheus와 Grafana가 가장 먼저 떠오르지만, 들이지 않기로 했습니다. 호스트와 컨테이너의 자원 사용량만 보는 가벼운 도구로 충분하기 때문에 과하다는 생각이 들었기 때문입니다.

그래서 Beszel을 선택했습니다. 허브 하나와 서버마다 설치하는 에이전트로 구성되고, CPU, 메모리, 디스크, 네트워크, 컨테이너별 사용량을 기록으로 남겨 보여 줍니다.

OCI 인스턴스에 설치한 에이전트는 허브가 접속할 수 있도록 포트를 하나 열어야 했습니다. 에이전트는 허브가 가진 키로 인증하기 때문에 보안 문제는 어느 정도 안심할 수 있다고 생각합니다.

이제 안전해


/api/health 엔드포인트가 거슬려서 시작한 작업이였는데, 깊게 파고 들어보니 서비스가 진짜 정상인지 어떻게 알 수 있는지에 대해 정리해보았습니다.

하나의 테스트에 의존하여 정상 동작을 보장할 수 없기에 단계를 세분화해서 분명히 확인하는 것이 관리하기 편리했습니다. 또한, 단계마다 확인하는 대상이 분명해지니 문제가 생겼을 때 어디부터 살펴봐야 하는지도 바로 알 수 있었습니다. 컨테이너가 뜨지 않으면 HEALTHCHECK를, 배포가 멈추면 smoke test를, 배포 이후에 서비스가 응답하지 않으면 Uptime Kuma를, 서버가 점점 느려지면 Beszel을 보면 됩니다.

감시하는 쪽을 어디에 둘지도 이번에 처음 고민해 보았습니다. 서비스와 같은 인스턴스에 두면 정작 알림이 필요한 순간에 감시 서버도 함께 멈추기 때문에, Uptime Kuma는 같은 리전의 다른 인스턴스에, Beszel은 아예 다른 리전의 인스턴스에 두었습니다.

이제 200이라는 응답 하나만 보고 정상이라고 판단하던 것에서 나아가 진짜 작동하는지 확인하고, 문제가 생겼을 때 제일 먼저 인지할 수 있게 안전한 서비스 구축을 마쳤습니다.