엔지니어링 노트설치·운영과 품질

워커를 재시작해도 돌던 턴은 답을 내거나 실패를 남긴다

OwlAI에서 채팅 턴(질문 하나에 답 하나가 끝나기까지)은 웹 서버가 아니라 워커가 돌립니다. 웹 서버는 질문을 받아 작업 큐에 넣기만 합니다. 턴 하나는 도구를 여러 번 부르며 몇 분을 쓰고, 회의 받아쓰기나 플레이북(정해 둔 순서대로 도구를 부르는 자동 작업)은 더 오래 돕니다. 설정 파일 한 줄을 고쳐도 워커는 다시 띄워야 하는데, 이때 컨테이너를 그냥 내리면 도는 턴은 SIGKILL로 끊기고 사용자는 끝나지 않는 스피너를 봅니다.

OwlAI는 배포와 수동 재시작을 같은 스크립트로 합니다. 스크립트는 새 컨테이너를 하나 더 띄워 healthy가 된 것을 확인한 뒤에 옛 컨테이너를 내리고, 옛 워커는 새 작업을 받지 않은 채 정해진 시간 안에 돌던 턴을 끝내거나 실패를 스스로 기록합니다. 이 글은 그 순서와 시간 예산, 두 컨테이너가 함께 도는 동안 따로 막아야 하는 두 가지(하트비트와 DB 커넥션 수), 그리고 작업 형식이 바뀌는 업데이트에서 워커를 먼저 비우는 이유를 설명합니다.

compose 명령으로 다시 띄우면 모든 컨테이너가 한꺼번에 내려간다

가장 쉬운 재시작은 docker compose restart나 up -d --force-recreate입니다. 운영 compose는 웹 서버와 워커를 기본 두 벌씩 띄우는데, 이 명령들은 두 벌을 한꺼번에 내립니다. 그동안 들어온 질문은 받을 곳이 없습니다. restart -t 10처럼 짧은 타임아웃을 주면 컨테이너에 정해 둔 정지 유예를 덮어써서, 10초 뒤 도는 턴이 SIGKILL을 맞습니다. 턴은 실패를 기록하지 못하고, 웹 쪽의 하트비트 판정이 그 턴을 닫을 때까지 약 2분 동안 사용자는 스피너를 봅니다.

정지 유예를 턴 실행 상한인 30분에 맞추면 어떤 턴도 끊기지 않습니다. 대신 재시작이 가장 느린 턴만큼 기다리고, 컨테이너 네 개를 차례로 교체하면 최악에는 두 시간이 걸립니다.

그래서 재시작은 한 번에 컨테이너 하나만 교체하고, 내려가는 컨테이너에는 정해진 시간 안에 일을 마무리하게 합니다.

새 컨테이너가 healthy가 된 뒤에 옛 컨테이너를 내린다

교체 스크립트는 서비스마다 옛 컨테이너 수만큼 아래 다섯 걸음을 되풀이합니다. 웹 서버를 먼저 교체하고 워커가 뒤따릅니다.

  1. 새 컨테이너 하나를 더 띄웁니다. 옛 컨테이너는 그대로 돕니다.
  2. 새 컨테이너가 healthy가 되기를 기다립니다. 기본 상한은 300초이고, 넘기면 새 컨테이너만 지우고 멈춥니다. 옛 컨테이너는 계속 일합니다.
  3. 6초 쉽니다. 앞단 nginx가 웹 서버 이름을 5초마다 다시 풀기 때문에, 그보다 길게 쉬어 새 컨테이너를 알게 합니다.
  4. 가장 오래된 옛 컨테이너를 정지 유예를 줄이지 않은 채 멈추고 지웁니다.
  5. 다시 6초 쉬어 nginx가 지운 컨테이너의 주소를 잊게 합니다.

옛 컨테이너는 새 컨테이너가 healthy가 된 뒤에만 내려가므로, 교체하는 동안 일하는 컨테이너 수가 줄지 않습니다. 한 벌만 띄운 설치도 같은 절차를 따르고, 교체 중에만 잠깐 두 벌이 됩니다.

교체 한 걸음: 새 워커가 선 뒤에 옛 워커가 돌던 턴을 끝낸다
그림 1. 교체 한 걸음: 새 워커가 선 뒤에 옛 워커가 돌던 턴을 끝낸다

웹 서버 쪽은 nginx가 맡습니다. 이미 지워진 컨테이너의 주소를 골라 500밀리초 안에 연결되지 않으면 다른 컨테이너로 다시 보냅니다. 연결 단계의 실패는 POST도 다시 보내고, 요청을 보낸 뒤의 실패는 쓰기가 두 번 실행되지 않게 다시 보내지 않습니다. 내려가는 웹 서버는 진행 중인 요청을 300초 안에 끝냅니다.

배포는 이 교체 앞에 실패할 수 있는 일을 모두 둡니다. 설치 전 점검(「설치 전 점검은 호스트를 읽기만 하고, 고칠 명령은 사람에게 넘긴다」), 이미지 받기, 메타스토어 마이그레이션이 그렇고, 어느 하나라도 실패하면 컨테이너를 하나도 바꾸지 않고 멈춥니다. PostgreSQL 컨테이너는 다시 만들면 모든 DB 연결이 끊기므로, 설정이나 이미지 내용이 실제로 바뀌었을 때만 다시 만듭니다. 이미지 참조 이름만 바뀐 경우는 스크립트가 설정 해시와 이미지 레이어를 대조해 그대로 둡니다.

옛 워커는 270초 안에 턴을 끝내고, 넘으면 실패를 스스로 남긴다

옛 워커는 정지 신호(SIGTERM)를 받으면 새 작업을 받지 않고, 도는 작업이 끝나기를 기다립니다. 이것을 드레인(drain)이라고 합니다. 그사이 새로 들어온 작업은 남은 워커가 받습니다.

채팅 턴에는 시간 예산이 셋 있고, 크기 관계가 고정돼 있습니다.

구간 길이 하는 일
완주 270초 도는 턴을 끝까지 돌립니다
마무리 30초 끝나지 않은 턴에 중단을 걸고, 턴이 실패를 스스로 기록합니다
정지 유예 330초 도커가 이 시간이 지나면 SIGKILL을 보냅니다

완주와 마무리를 더한 300초가 정지 유예 330초보다 짧아야 턴이 SIGKILL을 맞기 전에 기록을 남깁니다. 270초는 측정한 턴 길이의 p95(177초)가 들어가는 값입니다. 중단은 사용자가 답변 생성을 중지할 때와 같은 신호라서, 턴이 DB에서 돌리던 SQL까지 취소됩니다.

사용자가 겪는 일은 셋으로 나뉩니다. 도는 턴이 270초 안에 끝나면 아무 일 없었던 것처럼 답이 옵니다. 넘기면 대화에 「서버 작업이 중단되어 답변하지 못했습니다. 잠시 후 다시 시도해주세요.」가 바로 남습니다. 교체 중에 새로 보낸 질문은 남은 워커가 집습니다. 한 벌만 띄운 설치라면 새 워커가 뜰 때까지 줄을 섭니다. 웹 서버가 접수할 때 그 턴을 420초 동안 진행 중으로 표시해 두므로, 그동안 대화에는 진행 중 표시가 남습니다.

회의 받아쓰기, 플레이북, 스키마 분석 같은 작업에는 따로 시간 예산이 없고 정지 유예 330초까지 돕니다. 그 안에 끝나지 않은 작업은 SIGKILL로 끊기고, 다른 워커가 멈춘 작업을 찾아 작업 종류에 따라 처리합니다.

  • 회의 받아쓰기는 구간마다 결과를 덮어쓰므로 처음부터 다시 돌려도 같은 결과가 됩니다. 그래서 대기열로 되돌립니다.
  • 플레이북 실행은 다시 돌리면 이미 부른 도구(메일 발송, 티켓 생성)를 또 부릅니다. 그래서 실패로 확정하고, 끝난 단계는 그대로 둡니다. 사용자는 사유와 멈춘 단계를 보고 다시 실행합니다.
  • 스키마 분석은 다시 돌리면 LLM 호출을 처음부터 다시 하므로 실패로 닫고, 사용자가 다시 분석합니다.

정지 중인 워커도 하트비트를 유지해야 다른 워커가 작업을 가져가지 않는다

작업 큐 라이브러리(Procrastinate)는 워커마다 하트비트를 기록하고, OwlAI의 워커는 하트비트가 30초 넘게 멈춘 워커의 작업을 앞 절의 방식으로 처리합니다. 그런데 라이브러리의 기본 정지 절차는 하트비트부터 끄고 도는 작업을 기다립니다.

이대로 두면 교체가 바로 그 상황을 만듭니다. 옛 워커가 회의 받아쓰기를 아직 돌리는 중인데 30초 뒤 새 워커가 그 워커를 멈춘 것으로 보고, 받아쓰기를 대기열로 되돌려 한 번 더 돌리거나 실행 중인 플레이북을 실패로 확정합니다. 작업을 끝내려고 기다리는 동안 그 작업이 중복되거나 실패하는 셈입니다.

그래서 OwlAI 워커는 정지 신호 뒤에도 도는 작업이 다 끝날 때까지 하트비트를 유지하고, 등록을 푼 다음에 하트비트를 끕니다. 라이브러리 판이 바뀌어 이 동작이 빠지면 워커가 ERROR 로그를 남깁니다. SIGKILL로 죽은 워커는 하트비트도 함께 멈추므로 그 작업은 30초 뒤 다른 워커가 처리합니다.

워커는 드레인 시작, 턴 중단, 비움 완료를 정지 신호부터 잰 경과 시간과 함께 로그로 남깁니다. 다른 워커가 멈춘 작업을 처리한 기록은 정상 교체라면 0건입니다.

DB 커넥션 수는 교체하는 동안 가장 많아진다

교체하는 동안 한 서비스가 잠깐 N+1개가 되고, 컨테이너마다 커넥션 풀을 따로 들고 있습니다. 그래서 PostgreSQL max_connections에 가장 가까워지는 때는 평소가 아니라 교체 중입니다. 상한은 풀 상한의 합으로 셉니다.

컨테이너 하나 커넥션 수(기본값)
웹 서버 = 프로세스 4개 × (ORM 풀 15 + 작업 적재 풀 20 + 상태 감시 1) 144
워커 = ORM 풀 15 + 작업 큐 풀 4 + 큐마다 하나씩 여는 LISTEN 연결 14 + 헬스 체크 2 35

두 벌씩 띄운 평시는 2×144 + 2×35 = 358입니다. 웹 서버를 교체하는 순간에는 (2+1)×144 + 2×35 = 502가 됩니다. 관리용 접속과 슈퍼유저 예약분을 더해 운영 compose의 기본 상한을 600으로 두었습니다. 상한이 300이면 두 벌만 띄워도 평시부터 모자랍니다.

DB 커넥션 수: 평시 358, 웹 서버 교체 중 502, 상한 600
그림 2. DB 커넥션 수: 평시 358, 웹 서버 교체 중 502, 상한 600

웹 서버 프로세스 수나 컨테이너 수를 늘리면 이 합도 바뀌므로, 설치 전 점검이 .env의 OWLAI_BACKEND_REPLICAS·OWLAI_WORKER_REPLICAS·OWLAI_WEB_WORKERS로 같은 식을 계산합니다. 평시 합만으로 상한을 넘으면 FAIL을 내 배포가 컨테이너를 건드리기 전에 멈추고, 교체 중에만 넘으면 WARN을 냅니다.

교체 시간도 같은 방식으로 셉니다. 기본 2+2벌에서 도는 일이 없으면 2~3분, 옛 컨테이너마다 긴 작업을 물고 있으면 정지 유예까지 기다려 26분 가까이 걸립니다. 배포를 CI 작업으로 돌린다면 시간 상한을 이보다 넉넉히 잡습니다. 교체 도중에 취소되면 스택이 반쯤 교체된 채 멈춥니다.

작업 형식이 바뀌는 업데이트에서는 워커를 먼저 비운다

새 워커를 먼저 띄우는 방식에는 옛 워커와 새 워커가 같은 큐를 함께 보는 시간이 있습니다. 같은 판의 재시작이라면 문제가 없습니다. 하지만 새 판이 작업의 이름이나 인자를 바꿨다면 옛 워커가 새 형식의 작업을 집거나 그 반대가 일어납니다.

그래서 관리 화면 업데이트(「업데이트 버튼은 승인만 하고, docker 소켓은 실행기만 쥔다」)는 서비스를 교체하기 전에 작업 정리 대기 단계에서 워커를 모두 비웁니다. 업데이트 실행기는 워커 컨테이너를 하나씩 멈추고, 각 컨테이너는 위와 같은 정지 유예 안에서 돌던 작업을 끝냅니다. 실행기는 멈춘 컨테이너를 지우고 기다린 시간을 진행 상황에 남깁니다. 그다음 배포 스크립트가 새 판으로 워커를 띄웁니다. 옛 워커와 새 워커가 함께 도는 순간이 없으므로 작업 형식이 바뀌어도 서로의 작업을 잘못 집지 않습니다.

대가는 워커가 하나도 없는 시간입니다. 그동안 들어온 작업은 큐에서 기다립니다. 채팅 질문은 접수 뒤 420초 안에 워커가 집지 못하면 「서버가 요청을 제때 받지 못해 답변하지 못했습니다. 잠시 후 다시 시도해주세요.」로 닫힙니다. 업데이트 확인 창은 매니페스트에 적힌 적용 중 영향을 관리자에게 미리 보여 줍니다. 서비스 교체 단계가 실패하면 무엇을 되돌리는지는 「업데이트가 실패하면 스키마 변경에 따라 코드만 되돌리거나 덤프로 복원한다」에서 다룹니다.

설계에서 고른 것

270초를 넘긴 턴을 다른 워커로 옮겨 이어 가는 대신 실패로 닫았습니다. 이어 가려면 결과를 아직 받지 못한 도구 호출, 이미 실행한 외부 도구의 효과 같은 턴 도중의 상태를 다른 프로세스로 넘겨야 합니다. 그러다 같은 도구가 두 번 실행되는 쪽이 더 위험하다고 봤습니다. 대가로 아주 긴 턴은 사용자가 다시 보내야 하지만, 스피너 대신 다시 시도하라는 문구를 바로 받습니다.

턴의 완주 시간은 가장 긴 턴 대신 측정한 턴 길이의 p95에 맞췄습니다. 재시작 시간이 가장 느린 턴에 끌려가지 않는 대신, 정지 신호 뒤 270초 안에 끝나지 않는 턴은 실패로 닫힙니다.

멈춘 작업은 처음부터 다시 돌려도 결과가 같은 것만 되돌리고, 나머지는 실패로 닫습니다. 되돌리면 사용자가 다시 누를 일이 줄지만, 외부에 같은 일을 또 하는 위험이 더 큽니다. 새 작업 종류가 이 분류에서 빠지면 빌드 단계의 테스트가 막습니다.

업데이트에서는 겹쳐 띄우기 대신 먼저 비우기를 골랐습니다. 워커가 없는 몇 분 동안 작업이 큐에서 기다리지만, 작업 형식이 바뀐 판에서 옛 워커와 새 워커가 서로의 작업을 잘못 집는 일은 생기지 않습니다. 같은 판을 다시 띄우는 수동 재시작은 형식이 바뀌지 않으므로 겹쳐 띄웁니다.

길게 도는 작업을 큐 워커에서 돌리는 서비스라면 같은 순서를 옮길 수 있습니다. 새 컨테이너가 healthy가 된 뒤에 옛 컨테이너를 내리고, 작업 종류마다 정지 유예 안에서 끝낼 시간 예산과 끝나지 못했을 때의 처리를 정하고, 정지하는 동안에도 하트비트를 유지합니다.

어떤 문제를 해결하나요?

설치·운영과 품질의 다른 글