콘텐츠로 이동

운영 — 살아 있는가, 일할 수 있는가, 되돌릴 수 있는가

이 문서가 답하는 것은 셋이다: 지금 괜찮은가(헬스체크), 무엇이 도는가 (메트릭), 잃으면 되찾을 수 있는가(백업). 앞의 둘이 없으면 고장을 사람이 전화로 알려 주고, 셋째가 없으면 고장 한 번이 끝이다.


1. 헬스체크

두 개다. 다른 질문을 한다.

경로 무엇을 보나 실패하면
GET /healthz 프로세스가 살아 있다 아무것도 오케스트레이터가 재시작한다
GET /readyz 지금 일할 수 있다 DB·Redis·S3 로드밸런서가 트래픽을 뺀다

/healthz 에서 DB 를 보지 않는다. 보면 DB 가 잠깐 흔들릴 때 앱이 통째로 재시작되고, 재시작한 앱도 DB 가 여전히 흔들리므로 다시 재시작한다 — 고장 하나가 재시작 폭풍이 된다.

/readyz 는 상태 코드로 말한다. 준비됐으면 200, 아니면 503. 몸에 {"status": "degraded"} 를 담아도 로드밸런서는 안 읽는다 — 한동안 200 만 주었고, 그러면 죽은 인스턴스로 트래픽이 계속 간다.

$ curl -s -w " [%{http_code}]" localhost:8000/readyz
{"status":"ready","checks":{"database":"ok","redis":"ok","storage":"ok"}} [200]

$ docker compose stop redis && curl -s -w " [%{http_code}]" localhost:8000/readyz
{"status":"degraded","checks":{"database":"ok","redis":"error: ConnectionError","storage":"ok"}} [503]

하나가 죽어도 나머지를 마저 본다. 운영자는 "안 된다" 가 아니라 "무엇이 안 되는지" 를 알아야 한다.

셋을 다 보는 이유

  • DB — 없으면 아무것도 안 된다.
  • Redis — 워커의 큐다. 죽으면 알림·메일·웹훅·SLA 가 전부 멈추는데 화면은 멀쩡하다. 티켓은 계속 들어오고 아무도 답을 못 받는다.
  • S3 — 첨부가 통째로 안 된다. 업로드 화면은 열리고 저장만 실패한다.

쿠버네티스라면

livenessProbe:
  httpGet: { path: /healthz, port: 8000 }
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet: { path: /readyz, port: 8000 }
  periodSeconds: 5
  failureThreshold: 2

/readyz 는 매번 세 곳을 찌르므로 periodSeconds 를 1초까지 줄이지 않는다.


2. 메트릭

GET /metrics — 프로메테우스 형식. 인증이 없으므로 바깥에 노출하지 않는다(인그레스에서 막거나 별도 포트로 뺀다).

지표 언제 보나
ieum_http_requests_total{method,route,status} 요청 수 5xx 비율
ieum_http_request_duration_seconds{method,route} 처리 시간 p95 가 튀는 경로
ieum_http_requests_in_flight 처리 중인 요청 몰릴 때
ieum_outbox_pending 안 나간 이벤트 수 밀림
ieum_outbox_oldest_age_seconds 그중 가장 오래된 것의 나이 밀림은 여기서 드러난다
ieum_worker_last_run_timestamp_seconds{task} 워커가 마지막으로 끝낸 시각 워커 죽음
ieum_worker_last_duration_seconds{task} 그때 걸린 시간 느려짐
ieum_worker_last_failed{task} 마지막 실행이 실패했나 (1/0) 계속 터지는 작업

라벨에 무엇을 두지 않는가

사용자 id·이슈 키·실제 주소를 라벨로 두지 않는다. 시계열이 곱셈으로 늘어나고(카디널리티 폭발), 며칠이면 프로메테우스가 무릎을 꿇는다. 경로는 매칭된 라우트 틀(/api/v1/issues/{issue_id})로 묶고, 라우트를 못 찾은 요청은 route="unmatched" 하나로 모은다 — 스캐너가 시계열을 만들게 두지 않는다.

워커는 다른 프로세스다

워커의 메모리에 있는 지표는 API 의 /metrics 에 실을 수 없다. 그래서 워커는 DB 의 heartbeat 한 줄에 "마지막으로 끝난 시각" 을 남기고, API 가 긁힐 때 그것을 읽어 낸다. 시작이 아니라 끝인 이유: 시작만 남기면 도중에 멈춘 워커가 계속 살아 있는 것으로 보인다.

꼭 걸어야 할 경보 셋

# 1. 워커가 죽었다. 스윕은 15초마다 돌므로 2분이면 확실하다.
time() - ieum_worker_last_run_timestamp_seconds{task="sweep"} > 120

# 2. 아웃박스가 밀린다. 개수가 아니라 나이로 본다 —
#    백 건이 방금 들어온 것은 정상이고, 한 건이 십 분째 남은 것은 고장이다.
ieum_outbox_oldest_age_seconds > 300

# 3. 5xx 가 늘었다.
sum(rate(ieum_http_requests_total{status=~"5.."}[5m]))
  / sum(rate(ieum_http_requests_total[5m])) > 0.01

첫째가 가장 중요하다. 워커가 죽으면 아무 일도 안 일어나는데 화면은 멀쩡하다 — 사람이 알아채는 데 며칠이 걸린다.

워커가 못 돌면 되찾을 수 없는 것이 하나 있다

스윕이 하는 일은 대부분 늦게라도 된다: 아웃박스는 쌓여 있고, 메일은 나중에 나가고, SLA 위반은 다음 주기에 걸린다.

번다운 스냅숏은 다르다. 그날 값을 그날 적는 것이 전부이므로, 워커가 하루 꺼져 있으면 그날 점은 영원히 없다. 되짚어 계산해서 채우면 그건 그날의 값이 아니라 오늘 값이고, 그러면 스냅숏을 두는 이유가 사라진다.

GET /sprints/{id}/burndown 이 날짜에 구멍이 있으면 그날 워커가 안 돌았다는 뜻이다. 화면은 점 사이를 직선으로 잇는다 — 없는 값을 만들어 넣지 않고, 간격은 날짜에 비례해 그려서 구멍이 구멍으로 보인다.


3. 이미지가 들고 있어야 하는 것 — 조판 라이브러리와 CJK 글꼴

내보내기(PDF·Word)는 앱 프로세스 안에서 조판한다(ADR-0011). 그래서 API 와 워커 이미지에 두 가지가 있어야 한다.

무엇 없으면 어떻게 드러나나
libpango-1.0-0·libpangoft2-1.0-0·libharfbuzz0b·libfribidi0·libcairo2·libgdk-pixbuf-2.0-0 WeasyPrint import 가 터진다 첫 PDF 요청이 500. 눈에 보인다
fonts-noto-cjk 한글·일본어·중국어가 사각형(□) 아무것도 안 터진다. 멀쩡한 PDF 가 나오고, 파일을 연 사람만 안다

아래쪽이 이 절의 이유다. 로그에 아무것도 안 남고 지표도 안 움직이므로, 경보로 잡을 수 없다. 이미지를 직접 만들거나 베이스를 바꿀 때 글꼴이 따라왔는지 확인한다:

docker compose exec api fc-list :lang=ko | head -1   # 한 줄이라도 나와야 한다

fonts-noto-cjk 는 100MB 가 넘는다. 이미지 크기를 줄이려고 뺄 자리가 아니다 — 빼면 한국어 문서의 내보내기가 조용히 못 쓰게 된다. 언어를 좁혀 줄이려면 fonts-noto-cjk-extra 없이 기본만 두는 선까지다.

조판은 CPU 를 쓴다. 라우터가 스레드로 넘기지만(asyncio.to_thread) 워커 프로세스 수가 곧 동시 조판 수의 상한이다. 큰 스페이스를 통째로 내보내는 설치라면 --workers 를 그만큼 두거나 요청 타임아웃을 함께 본다.

4. 백업

지켜야 할 것은 둘이다. 둘 다 없으면 복구가 아니다.

  • Postgres — 이슈·문서·티켓·설정·감사 로그. 전부.
  • 오브젝트 스토리지(S3/MinIO) — 첨부 파일. DB 에는 키만 있다.

IEUM_SECRET_KEY 도 함께 보관한다. 이것을 잃으면 DB 가 멀쩡해도 암호화된 칸을 열 수 없다 — IdP 클라이언트 시크릿, 메일함 비밀번호, TOTP 비밀, SAML SP 키. 백업이 아니라 비밀 관리의 일이지만, 잊으면 복구가 반쪽이 된다.

뜨는 방법

# Postgres  커스텀 포맷(-Fc). 병렬 복원과 선택 복원이 된다.
pg_dump -h $HOST -U $USER -d ieum -Fc -f ieum-$(date +%F).dump

# 첨부  버킷 통째로.
aws s3 sync s3://ieum-attachments./attachments-$(date +%F)/

pg_dump일관된 스냅샷을 뜬다(하나의 트랜잭션에서 읽는다). 앱을 멈출 필요가 없다.

되돌리는 방법

createdb -h $HOST -U $USER ieum_restored
pg_restore -h $HOST -U $USER -d ieum_restored --no-owner ieum-2026-09-07.dump
aws s3 sync./attachments-2026-09-07/ s3://ieum-attachments

--no-owner 를 붙인다. 덤프에는 원본의 소유자 이름이 들어 있고, 복원하는 곳의 롤 이름이 다르면 그것만으로 실패한다.

되돌려 본 적 없는 백업은 백업이 아니다

분기에 한 번은 실제로 복원해 본다. 스크래치 DB 에 복원하고 세 가지를 확인한다.

# 1. 행이  왔는가
psql -d ieum_restored -tAc "select count(*) from project;"

# 2. 확장이 살아 있는가  없으면 검색이 통째로 죽는다
psql -d ieum_restored -tAc "select extname from pg_extension order by 1;"
# citext / ltree / pg_trgm / pgroonga / plpgsql 다섯이 나와야 한다

# 3. 한국어 검색이 실제로 되는가  인덱스만 있고  도는 경우가 있다
psql -d ieum_restored -tAc "select count(*) from issue where summary &@~ '프린터';"

셋째까지 봐야 하는 이유: PGroonga 인덱스는 확장이 없으면 복원 자체가 실패하지만, 확장 버전이 다르면 인덱스는 만들어지고 결과만 이상해진다. 개수를 세는 것으로는 안 드러난다.

마이그레이션 판이 코드와 맞는지도 함께 본다.

alembic current   # 코드의 head 와 같아야 한다

얼마나 자주, 얼마나 오래

정하는 것은 조직이지만, 정하지 않으면 "안 함" 이 된다. 출발점:

주기 보관
Postgres 전체 덤프 매일 30일
Postgres WAL 아카이브(PITR) 연속 7일
첨부 버킷 매일 30일 + 버전 관리

PITR 까지 하면 "어제 밤 백업" 이 아니라 "사고 직전" 으로 돌아갈 수 있다. 사고는 대개 사람이 무언가를 지운 것이고, 그 사이의 하루가 통째로 날아가면 백업이 있어도 아프다.

5. 쿠버네티스에 올린다 (Helm)

차트는 deploy/helm/ieum 에 있고, 그 README.md 가 쓰는 법과 정한 이유를 담고 있다. 여기서는 운영에서 먼저 알아야 할 것만 옮긴다.

이 차트는 붙는 것을 만들지 않는다

Postgres·Redis·S3·SMTP 는 주소만 받는다. helm uninstall 이 데이터를 지우는 명령이 되지 않게 하려는 것이고, 운영 DB 는 백업과 이중화를 따로 받아야 하기 때문이다. 그리고 Postgres 는 PGroonga 확장이 필요해서(ADR-0005) 흔한 Postgres 차트로 대체되지 않는다 — 되는 것처럼 기본값을 두면 검색이 조용히 안 되는 설치가 생긴다.

스키마는 훅 Job 이 올린다

pre-install,pre-upgrade 에서 alembic upgrade head 가 한 번 돈다. initContainer 로 두면 파드 수만큼 동시에 돌아 서로 경쟁한다. 실패한 Job 은 남긴다 — 마이그레이션 실패는 그 로그가 유일한 단서다.

post-install,post-upgrade 에서 시드가 돈다. 새 판이 권한을 하나 더 정의했을 때 내장 역할에 그것을 더해 주는 일이라, upgrade 에서도 돌아야 한다.

워커는 여러 개 띄워도 된다

근거가 셋이고 셋 다 코드에 있다: 아웃박스 폴링이 FOR UPDATE SKIP LOCKED, 주기 작업이 arq 의 unique 크론(job id 가 실행 시각으로 정해져 하나만 큐에 들어간다), 기동 직후 겹칠 수 있는 스윕은 멱등. 다이제스트 메일이 워커 수 만큼 나가지 않는 이유가 두 번째다.

워커 둘을 같은 Redis 에 붙여 확인했다 — 스윕은 한쪽에서만 실행됐다.

프로브를 나눠 쓴다

readinessProbe/readyz(붙는 것들을 본다), livenessProbe/healthz(프로세스만 본다). 둘을 같이 쓰면 안 된다 — DB 가 잠깐 흔들릴 때 쿠버네티스가 파드를 계속 재시작해 복구를 방해한다. 나눠 두면 트래픽만 빠지고 파드는 살아 있다가 DB 가 돌아오면 그대로 다시 받는다.

이 동작은 실제로 확인했다. 스토리지를 끊으면 /readyz 가 503 과 함께 storage: error 를 말하고, /healthz 는 그동안 200 이다.

아직 진짜 클러스터에서 올려 보지 않았다

helm lint, 실제 API 서버 매니페스트 검증(kubectl apply --dry-run=server), 이미지의 네 명령이 읽기 전용 루트 + 비루트에서 도는 것, 프로브의 실제 응답, 워커 복제 안전성 — 여기까지는 확인했다. 파드가 스케줄되어 Ready 가 되는 것은 확인하지 못했다(개발 샌드박스의 중첩 컨테이너 제약). 첫 실배포는 그 전제로 다룬다.

6. 여러 개 띄운다 (HA)

API 를 여러 개 띄워도 된다. 그 말이 성립하려면 프로세스가 자기만 아는 것을 들고 있지 않아야 한다 — 들고 있으면 사람이 어느 파드에 붙느냐에 따라 앱이 다르게 행동하고, 그건 오류로 나타나지 않는다. "로그인이 풀렸다", "내가 쓴 게 없다" 로 나타난다.

여기 적는 것은 인스턴스를 실제로 둘 띄워 확인한 것과, 확인하지 못한 것이다.

반드시 지킬 것 셋

  1. IEUM_SECRET_KEY 를 모든 인스턴스가 같이 쓴다. 이 키는 아홉 자리에서 쓰인다 — 저장된 것(MFA 시크릿, IdP 클라이언트 시크릿, 웹훅 시크릿, 메일 채널, 저장소 연동)과 프로세스를 넘어 다니는 것(OIDC 의 state 봉인, 초대 토큰, CSAT 설문 토큰). 인스턴스마다 키가 다르면 앞의 것은 "어떤 파드에서는 열리고 어떤 파드에서는 안 열리는" 데이터가 되고, 뒤의 것은 "콜백이 다른 파드에 떨어지면 실패하는" 로그인이 된다. 둘 다 재현이 어려운 고장이다. 차트는 Secret 하나를 모든 파드가 보게 하고, 값이 없으면 배포를 실패시킨다(파드마다 무작위로 만들어 주지 않는 것이 요점이다).
  2. Postgres·Redis·S3 를 같이 쓴다. 세 개가 상태의 전부다. Redis 를 인스턴스마다 따로 두면 동시 편집이 조용히 갈라지고, S3 를 따로 두면 첨부가 업로드한 파드에서만 보인다.
  3. 마이그레이션은 파드마다 돌리지 않는다. 훅 Job 이 한 번 돈다 (5절).

세션은 끈적일 필요가 없다 (확인)

인스턴스를 둘 띄우고(:8000, :8001) 같은 Postgres·Redis 를 보게 한 뒤, 한쪽에서 얻은 것을 다른 쪽에 냈다.

한 일 결과
A 에서 로그인해 받은 토큰으로 B 에게 /auth/me 200, 같은 사람
세션 목록을 A·B 에서 각각 조회 같은 목록
B 에서 refresh → 새 토큰을 A 에 냄 200 (회전한 옛 토큰은 A 에서도 401)
B 에서 로그아웃 → A 에게 /auth/me 401
B 가 회전시킨 뒤 옛 refresh 를 A 에 재사용 A 가 거절하고, B 쪽 세션까지 끊긴다
A 에서 step-up(MFA verify) → step-up 이 필요한 자리를 B 에서 호출 verify 전 A·B 모두 403, 후 A·B 모두 200
A 에서 발급한 편집 표(ticket)를 B 의 웹소켓에 냄 받아 준다. 두 번째는 거절(한 번 쓰기)
A 에서 만들고 고친 스페이스를 B 에서 조회 즉시 보인다

마지막 줄에서 두 번째가 중요하다: 재사용 탐지는 침해 신호여서 세션을 끊는데, 그 끊김이 인스턴스를 넘는다. 프로세스 메모리에 세션을 뒀다면 다른 파드는 계속 통과시켰을 것이다.

권한도 같다. 요청마다 DB 에서 풀고, 캐시는 요청 하나 안에서만 산다 (core/context.pyActor.cached). 그래서 A 에서 회수한 역할이 B 의 다음 요청부터 적용된다 — 회수가 파드마다 늦게 듣는 일은 없다.

프로세스에 남는 것은 파생값뿐이다: 설정, 번역 카탈로그, IQL 문법, 마크다운 방언, JWKS(키 회전을 스스로 처리한다). 모듈 수준의 가변 상태는 임포트 때 채워지는 등록기 둘뿐이다.

동시 편집은 Redis 로 잇는다 (확인, 그리고 고장 둘을 찾았다)

편집 방(CRDT 상태)은 프로세스 메모리에 있고 프로세스 사이는 Redis pub/sub 로 잇는다. 코드가 그렇게 보이는 것과 실제로 이어지는 것은 다른 일이었다 — 인스턴스를 둘 띄우자 곧바로 두 가지가 드러났고, 둘 다 오류를 내지 않는 고장이었다.

하나. 늦게 생긴 방은 이미 붙어 있는 사람을 못 봤다. pub/sub 은 과거를 주지 않고 프레즌스는 바뀔 때만 방송되므로, 두 번째 파드에 붙은 사람에게는 빈 방으로 보였다. 방이 생기면 채널에 인사를 던지고, 받은 방이 아는 프레즌스를 내놓게 했다.

둘. 늦게 생긴 방은 남의 안 저장된 편집을 영원히 못 받았다. 스냅샷은 3초에 한 번이고 방은 자기가 쓰기 전까지 스냅샷을 다시 읽지 않는다. 그래서 그 사람은 낡은 본문을 "지금 문서" 로 믿고 편집했다 — 5초를 기다려도, 10초를 기다려도 그대로였다. 인사와 함께 상태 벡터를 던져 "내가 없는 것을 달라" 고 묻고, 받은 방이 그 답을 채널로 되돌리게 했다.

둘 다 저장을 아예 하지 않는 방으로 세워 시간이 아니라 구조로 재현하는 시험을 붙였다(test_a_late_room_gets_edits_that_are_not_saved_yet). 3초를 기다리는 시험은 느린 기계에서 뜻이 뒤집힌다.

브라우저에서도 확인했다. 창 둘이 서로 다른 API 에 붙어 편집과 프레즌스가 양방향으로 오간다(collab.spec.ts다른 API 인스턴스에 붙어도 같이 편집된다, E2E_SECOND_ORIGIN 이 있을 때만 돈다).

스냅샷을 두 프로세스가 동시에 저장해도 안전하다: 저장은 행 잠금을 걸고 읽기-합치기-쓰기를 하므로, 나중에 쓰는 쪽이 먼저 쓴 것을 흡수한다.

롤링 업데이트가 마지막 편집을 지우고 있었다 (고침)

RoomRegistry.aclose() 는 "앱이 내려갈 때 방을 다 닫고 저장까지 기다린다" 는 함수인데, 아무도 부르지 않았다. 소켓이 끊길 때 닫기가 태스크로 뜨긴 하지만 그것을 기다리는 사람이 없어서, 프로세스가 내려가며 루프가 먼저 걷히면 마지막 몇 초의 편집이 사라진다. 파드를 하나씩 내리는 롤링 업데이트에서는 그게 예외가 아니라 매번이다.

lifespan 의 마무리에 배선했다 — 엔진을 버리기 전에 닫는다(닫기가 저장을 포함하고, 저장은 세션을 쓴다). 그리고 그 마지막 저장이 실패하면 로그를 남긴다: 마지막 기회였고, 실패하면 그 편집은 이제 아무 데도 없다.

이 배선은 시험이 실제 앱의 lifespan 을 열고 닫아서 붙잡는다 (test_shutting_down_saves_open_rooms). 레지스트리를 직접 부르는 시험은 함수가 있다는 것만 말해 주고, 빠진 것은 배선이었다.

인그레스에 필요한 것

웹소켓이 지나가야 한다(/api/v1/pages/{id}/collab). 끈적임(sticky session)은 필요 없다 — 표를 A 에서 받아 B 에 내도 통한다. 다만 프록시의 유휴 타임아웃이 짧으면 편집 중에 연결이 끊긴다. 끊기면 클라이언트가 다시 붙고, 다시 붙으면 위의 인사·상태 벡터로 따라잡는다 — 그러나 그 사이의 몇 초는 사람이 알아차린다.

확인하지 못한 것

  • 진짜 클러스터에서 파드가 Ready 되는 것. 5절 마지막과 같은 제약이다.
  • 인스턴스 셋 이상, 실제 로드밸런서 뒤. 확인한 것은 둘이고, 둘에서 나온 성질(끈적임이 필요 없다, 방이 서로를 찾는다)은 셋에서도 같아야 하지만 같아야 한다는 것과 같다는 것은 다르다.
  • 인그레스의 웹소켓 설정. 프록시를 거치지 않고 두 오리진에 직접 붙여 확인했다.
  • 파드가 갑자기 죽을 때(SIGKILL, 노드 상실). 위의 고침은 정상적인 종료를 다룬다. 갑작스러운 죽음에서는 마지막 스냅샷 이후의 편집이 사라진다 — 최대 3초다. 그것을 0 으로 만들려면 편집마다 쓰거나 Redis 에 append-only 로그를 둬야 하고, 둘 다 지금 값에 비해 비싸다.

7. 검색 백엔드를 바꾼다 (OpenSearch)

기본값은 PGroonga 이고, 그걸로 충분한 규모가 넓다(ADR-0005: 문서 60만 건). 바꾸기 전에 바꿀 이유가 있는지 본다 — 아래 셋 중 하나도 아니면 바꾸는 쪽이 손해다.

  • 색인 문서가 100만 건을 넘었다
  • 검색 p95 가 800ms 를 넘었다 (/metrics 의 요청 히스토그램)
  • 유사 문서 추천 같은 것이 필요해졌다

무엇이 바뀌고 무엇이 안 바뀌는가

쓰기는 안 바뀐다. 색인은 여전히 Postgres 의 search_document 에 원본과 같은 트랜잭션으로 쓴다. OpenSearch 는 그 표를 비추는 읽기 쪽이다(ADR-0015). 그래서:

  • OpenSearch 가 죽어도 이슈 저장은 실패하지 않는다. 검색만 낡는다.
  • 되돌리는 데 되색인이 필요 없다. postgres 로 되돌리면 그 순간 최신이다.

읽기는 바뀐다. 그리고 대가가 셋이다. 셋 다 실제로 확인한 것이다.

Postgres (기본) OpenSearch
방금 만든 것이 검색되는 시점 즉시 (같은 트랜잭션) 약 4초 (미러 5초 주기 + 리프레시)
한국어 형태소 두 글자 바이그램 (cjk) 또는 nori
붙는 것 없음 (Postgres 안) 클러스터 하나 + 그 운영

첫 줄이 제일 크다. "방금 만든 이슈가 곧바로 검색된다" 는 브라우저 시험의 이름이고, OpenSearch 백엔드에서는 그 시험 다섯이 붉어진다 — 고장이 아니라 이 약속의 차이다.

켜는 순서

# 1. 클러스터를 세운다 (이 차트·컴포즈는 만들지 않는다).
#    개발이라면: docker compose --profile opensearch up -d opensearch
# 2. 설정을 준다.
IEUM_SEARCH_BACKEND=opensearch
IEUM_OPENSEARCH_URL=https://search.internal:9200
# 3. **한 번은 되색인한다.** 켜기 전에 쌓인 것은 미러 큐에 없다.
uv run --project apps/api python -m ieum.cli reindex

3번을 빼먹으면 옛 문서가 검색에 하나도 안 나온다. 오류는 없다 — 새로 만든 것만 나오므로, 며칠 뒤에 "옛 이슈가 안 찾아진다" 로 발견된다.

되색인은 빈 색인에 다 담은 뒤 별칭을 한 번에 옮긴다. 그래서 담는 동안에도 검색이 되고, 끝나면 옛 색인을 지운다.

한국어 — 기본 설정에서 나빠진다

OpenSearch 에 analysis-nori들어 있지 않다. 기본값 cjk 는 루씬에 있는 것이라 플러그인 없이 도는데, 한국어를 두 글자씩 쪼갠다:

배포 절차를 문서로 남긴다  →  배포 · 절차 · 차를 · 문서 · 서로 · 남긴 · 긴다

되찾기는 된다(문서 로 찾으면 나온다). 대신 헛맞음이 생긴다 — 서로 를 검색한 사람에게 이 문서가 나온다. PGroonga 는 형태소를 보므로 이 자리에서 더 낫다.

analysis-nori 를 넣은 이미지를 쓴다면:

IEUM_OPENSEARCH_ANALYZER=nori
uv run --project apps/api python -m ieum.cli reindex   # 분석기는 색인 시점에 적용된다

무엇을 지켜봐야 하나

# 1. 미러가 밀린다. 사람은 이것을 "새 문서가 검색에 안 뜬다" 로 만난다.
#    개수가 아니라 나이로 본다 — 백 건이 방금 들어온 것은 정상이다.
ieum_search_mirror_oldest_age_seconds > 120

# 2. 미러 작업이 죽었다. 죽으면 아무 오류도 안 나고 검색만 점점 낡는다.
time() - ieum_worker_last_run_timestamp_seconds{task="search_mirror"} > 60
ieum_worker_failing{task="search_mirror"} == 1

/readyz 는 OpenSearch 를 백엔드로 쓸 때만 본다(checks.search). 안 쓰는 설치에서 그 줄이 붉으면 운영자가 상관없는 것을 좇는다.

되돌리기

IEUM_SEARCH_BACKEND=postgres   # 이게 전부다

되색인도, 클러스터 정리도 나중에 해도 된다. Postgres 색인은 그동안 계속 갱신되고 있었다 — 그게 사본을 사본으로 둔 이유다.

아직 확인하지 못한 것

  • 큰 색인. 확인한 것은 5천 문서다. 60만·100만에서의 되색인 시간과 bulk 크기(현재 100 문서)는 재 보지 않았다.
  • 여러 노드 클러스터. 한 노드로만 확인했다. 샤드·복제본 기본값(1/0)은 단일 노드 값이므로, 실배포에서는 클러스터에 맞게 바꿔야 한다.
  • 보안이 켜진 클러스터. 사용자·비밀번호 설정은 넣었지만 실제로 인증을 요구하는 클러스터를 상대로는 돌려 보지 않았다. TLS 도 마찬가지다.