EntityIndexable missing retry on Elasticsearch timeout
RCA: Api::V1::ClustersController#update_meta_by_key latency (11.2s)
Overview#
What Happened#
2026-07-17 01:03 KST에 cupixworks-api production (us-west-2) 에서 PUT /api/v1/clusters/1454197/meta/constr 한 요청이 11.2초 걸린 뒤 200으로 정상 종료했다. 같은 시간대에 다른 요청은 정상 응답 (< 1s) 을 보였으며, 약 20분 전후로 Cupix::Errors::BadGateway("Bad Gateway error on Elasticsearch") 502 응답이 산발적으로 관측되어, 이 latency 스파이크는 Cluster#save after_commit 콜백이 호출하는 Elasticsearch 인덱스 요청의 일시적 지연에 기인한 것으로 판단된다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | (없음 — HTTP 200 응답) |
| resource_name | Api::V1::ClustersController#update_meta_by_key |
| top_frame | app/controllers/concerns/metable_controller.rb:51 (@model.save) |
| avg_duration_ms | 11248 |
| max_duration_ms | 11248 |
| env | production, us-west-2 |
| tenant | cupix |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| cupixworks-api (Cluster meta 업데이트 경로) | 1 slow request | 단일 사용자 요청이 11초 지연되었으나 실패 없이 200 응답. 사용자 체감 지연. |
Timeline#
- 2026-07-17 00:36 KST —
Api::V1::Admin::EditingsController#index에서Cupix::Errors::BadGateway(Elasticsearch) 502 발생 (2회) — ES 불안정 신호. - 2026-07-17 01:03:14 KST 무렵 —
PUT /api/v1/clusters/1454197/meta/prop요청 정상 처리, 이후PUT /api/v1/clusters/1454197/meta/constr요청 시작 (trace1192601485234977257). - 2026-07-17 01:03:15 KST — Datadog APM 이 해당 트레이스를 latency 이상치 (11248 ms) 로 감지 (
first_seen). - 2026-07-17 01:03:25 KST —
Cluster 1454197Meta updated by key: constrinfo 로그 및[200] PUT ... /meta/constr액세스 로그 기록 → 요청 종료. - 2026-07-17 01:25-26 KST —
Api::V1::PanosController#index에서Cupix::Errors::BadGateway(Elasticsearch) 502 재발생 (2회) — ES 지속 불안정 확인.
Error Log#
{
"resource_name": "Api::V1::ClustersController#update_meta_by_key",
"service": "cupixworks-api",
"occurrences": 1,
"avg_ms": 11248,
"max_ms": 11248,
"sample_trace_id": "1192601485234977257"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 1
- 최초 발생: 2026-07-17 01:03 KST
- 최근 발생: 2026-07-17 01:03 KST
Root Cause Summary#
Cluster#update_meta_by_key 는 MetableController#update_meta_by_key 에서 @model.save 를 호출하며, Cluster 모델은 EntityIndexable concern 을 include 하고 있어 update 시 after_commit :_entity_update_document 콜백이 요청 스레드에서 동기적으로 Elasticsearch::Model.client.index(...) 를 호출한다. 같은 시간대에 Cupix::Errors::BadGateway ("Bad Gateway error on Elasticsearch") 502 응답이 관측된 것으로 보아 Elasticsearch 클러스터가 일시적으로 지연/불안정 상태였고, 이 인덱스 호출이 클라이언트 타임아웃 (약 10초 + 재시도/커넥션 정리 오버헤드) 까지 대기하면서 전체 요청 지속시간이 11.2초까지 늘어난 것으로 판단된다. _entity_update_document 는 rescue StandardError 로 감싸져 있어 최종적으로 200이 반환되었지만, 요청 latency 는 그대로 사용자에게 전달되었다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/clusters_controller.rb:1(Api::V1::ClustersController) - 요청 흐름:
- 라우팅:
PUT /api/v1/clusters/:id/meta/:meta_key→MetableController#update_meta_by_key before_action :set_cluster→ClusterRepository.show(params[:id])로 대상 로드@model.meta[params[:meta_key]] = parsed_meta후@model.save- save 이후
after_commit on: :update콜백들이 순차 실행됨. 그 중EntityIndexable#_entity_update_document가 ES 로 동기 인덱스 요청.
- 라우팅:
- Failure point (latency 원인):
app/models/concerns/entity_indexable.rb:172-182(_entity_update_document— ES 동기 호출).
Metable 컨트롤러 진입 및 save:
def update_meta_by_key
if !@model.updatable_by?(current_user) && (@review.present? && !@review.updatable_by?(current_user))
raise Cupix::Errors::PermissionDenied.new(code: 'PERM10000', reason: 'Permission denied')
end
begin
parsed_meta = JSON.parse(request.raw_post)
@model.meta[params[:meta_key]] = parsed_meta
@model.skip_entrypoint_flush = true if @model.respond_to?(:skip_entrypoint_flush)
@model.save
# ... (cluster 인 경우 info 로그)
rescue Cupix::Errors::Parameter => e
raise Cupix::Errors::Parameter.new(code: 'ARG10004', reason: e.to_s, message: e.message)
else
render_json 200, @model.meta[params[:meta_key]]
Cupix::Logger.info("Meta updated by key: '#{params[:meta_key]}' - keys: ...", ...)
end
end
Cluster 모델은 EntityIndexable 을 include 하고 있음:
class Cluster < ApplicationRecord
include CaptureEntity
include EntityIndexable
include Storagable
# ...
include Metable
# ...
end
Save 후 실행되는 동기 ES 인덱스 호출:
included do
after_commit :_entity_index_document, on: [:create]
after_commit :_entity_update_document, on: [:update]
after_commit :_entity_delete_document, on: [:destroy]
end
# ...
def _entity_update_document
return if @skip_index_document == true
Elasticsearch::Model.client.index(
index: self.class.entity_index_name,
id: entity_document_id,
body: as_entity_indexed_json
)
rescue StandardError => e
Cupix::Logger.error("Entity update error - #{e.message}", class: self.class.name, function: __method__)
end
이 콜백은 요청 스레드에서 실행되고, ES 응답이 늦어지면 Rack 요청 duration 이 그대로 늘어난다. rescue StandardError 로 감싸져 있어 실패해도 200 응답이 나가지만, 성공/실패에 관계없이 latency 는 사용자에게 노출된다. 또한 as_entity_indexed_json 은 ancestry 확보 과정에서 Facility.where(id: ...).pick(:name, :key) 같은 추가 DB 쿼리를 수행 (entity_indexable.rb:132-147), ES 가 느릴 때 DB round-trip 오버헤드까지 겹칠 수 있다.
기대 동작: Cluster#save 는 Cluster 레코드만 DB 에 기록하고 반환, 인덱스 갱신은 비동기 워커 (예: BulkPartialIndexWorker) 로 위임되어 요청 latency 는 100ms~수백 ms 이내여야 한다.
실제 동작: ES 호출이 요청 스레드에서 동기 실행되어, ES 가 502 를 산발적으로 반환하던 시점에 이 트레이스가 11.2초까지 대기했다.
Log Evidence#
Datadog Query (해당 리소스, 이상 latency):
service:cupixworks-api resource_name:"Api::V1::ClustersController#update_meta_by_key" env:production @duration:>500ms
Datadog Query (동시간대 ES 관련 신호):
service:cupixworks-api "Bad Gateway"
문제의 트레이스에 대응하는 액세스/정보 로그 (같은 cluster 1454197 대상, 순서와 시각 일치):
2026-07-17 01:03:25 KST info Meta updated by key: constr. Cluster 1454197
(class=Cluster, function=update_meta_by_key)
2026-07-17 01:03:25 KST info [200] PUT /api/v1/clusters/1454197/meta/constr (Api::V1::ClustersController#update_meta_by_key)
2026-07-17 01:03:21 KST info Meta updated by key: prop. Cluster 1454197
2026-07-17 01:03:21 KST info [200] PUT /api/v1/clusters/1454197/meta/prop (Api::V1::ClustersController#update_meta_by_key)
Cluster 파일의 first_seen (2026-07-17 01:03:15 KST) 은 위 /meta/constr 완료 시각 (01:03:25 KST) 에서 duration 11.2s 를 뺀 시각과 정확히 일치한다 → 이 요청이 문제의 슬로우 트레이스임.
같은 시간대 ES 불안정을 보여주는 502 로그:
{
"timestamp": "2026-07-17 01:25:57 KST",
"status": "info",
"message": "[502] GET /api/v1/panos (Api::V1::PanosController#index)",
"error": {
"reason": "Bad Gateway error on Elasticsearch",
"code": "BG10002",
"message": "Bad Gateway error on Elasticsearch",
"class": "Cupix::Errors::BadGateway"
}
}
같은 창(2026-07-17 00:36 KST) 에도 Cupix::Errors::BadGateway 502 가 Admin::EditingsController#index 에서 2회 관측됨. 즉 슬로우 요청 전후로 Elasticsearch 지연/에러가 존재.
Entity index error / Entity update error (해당 rescue 블록의 로그) 는 슬로우 시점 근방에서 관측되지 않았다:
Query: service:cupixworks-api "Entity index error" OR "Entity update error"
Range: 2026-07-16T15:00:00Z ~ 2026-07-16T17:00:00Z
Result: 0 logs
즉 ES 호출은 최종적으로 성공했거나, 로그로 남지 않는 방식으로 지연되었다 → 슬로우 트레이스만 남고 예외는 발생하지 않은 시나리오와 일치.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | EntityIndexable#_entity_update_document after_commit 이 요청 스레드에서 Elasticsearch 를 동기 호출, ES 일시 지연으로 request duration 이 11.2s 까지 늘어남 |
Cluster 는 EntityIndexable include (app/models/cluster.rb:3), after_commit ..., on: [:update] 가 Elasticsearch::Model.client.index(...) 를 동기 호출 (entity_indexable.rb:41-45,172-182). 동시간대 [502] Bad Gateway error on Elasticsearch 로그 2건 (01:25 KST), 27분 전 2건 (00:36 KST). 슬로우 트레이스에는 예외 로그 없음 → rescue StandardError 가 없더라도 응답은 200 이므로 부합 |
— | Confirmed |
| H2 | DB 쿼리 (ClusterRepository.show 또는 Cluster.save) 자체가 느려서 발생 |
set_cluster 는 단일 레코드 조회이며 다른 동일 endpoint 요청은 <1s 로 정상 응답 |
동일 사용자가 4초 전 (/meta/prop) 같은 cluster 1454197 을 정상적으로 저장 → DB primary key 조회/저장은 정상 |
Rejected |
| H3 | Cluster 저장 자체가 무거운 counter_culture / ancestry 재계산 유발 | Cluster 에 counter_culture :capture 존재 (cluster.rb:41-43), ancestry 콜백 존재 (ancestryable/cluster.rb:15-16) |
meta 컬럼만 변경되므로 untrashed? 상태 변화 없음, counter_culture 는 카운트 증분 조건이 매치되지 않으면 no-op. 같은 시간대 다른 cluster meta 업데이트가 정상 처리됨 (clusters/130047/meta/prop 등) |
Rejected |
| H4 | 애플리케이션 노드 자체가 CPU/GC 로 stall | 이론적으로 가능 | 같은 인스턴스로 라우팅될 인접 요청 (동일 프로세스 여러 endpoint) 이 정상 응답, cluster 1454197 의 /meta/prop 은 정상 처리 후 곧바로 /meta/constr 만 slow → 요청 단위 external I/O 대기가 더 자연스러운 설명 |
Inconclusive but weaker |
| H5 | 외부 dependency 인시던트 (dep:elasticsearch 등) 진행 중 |
상태보드가 known 이슈로 연결하면 root cause 를 dependency 로 확정 가능 | bun run cli/incident-board.ts for-cluster e6e59695-233c-4a04-939d-0fb3a861a871 결과 scope 는 svc:cupixworks-api::unknown, active null. 최근 svc 인시던트는 있으나 dep:elasticsearch 로 그룹화된 활성 인시던트는 없음 |
Rejected (scope 상 dep 로 확정 불가, 그러나 ES 지연 신호는 존재) |
Fix Recommendation#
즉시 조치 (Critical)#
별도의 즉시 코드 변경 없음. 이번 이벤트는 단일 슬로우 트레이스이고 200 응답으로 종료됨. 하지만 재발 방지 관점에서 short-term 개선을 우선 순위로 진행할 것을 권장.
단기 개선 (1주 이내)#
EntityIndexable#_entity_update_document(그리고_entity_index_document) 를 비동기 워커로 이동한다. 대상 파일:app/models/concerns/entity_indexable.rb:41-45, 160-194. 현재after_commit에서 직접Elasticsearch::Model.client.index를 호출하는 대신,execute_after_commit { EntityIndexWorker.perform_async(self.class.name, id, :update) }형태로 위임하고 워커 안에서as_entity_indexed_json을 계산해 ES 로 전송. 이렇게 하면 ES 지연이 사용자 응답 latency 로 새어나오지 않는다.- ES 클라이언트에 request 타임아웃과 재시도 상한을 명시한다. 현재
Elasticsearch::Model.client는 client-level 타임아웃이 명시적이지 않음.request_timeout을 예: 2~3초로 설정하고, retry 는 워커 (Sidekiq) 에 위임하도록 조정. _entity_update_document안의 rescue 를 network 예외 (예:Faraday::TimeoutError,Elasticsearch::Transport::Transport::Errors::*) 로 좁혀 로그 level 을 warn 으로 낮추고, 진짜 예상 밖 예외는error로 유지. Memory 룰 참조: warn-level 다운그레이드는 예외 클래스 범위로 좁혀야 함.- 동일 endpoint (
update_meta_by_key) 의 p95/p99 latency 모니터를 추가하여, ES 지연이 latency 로 새어나오는지 상시 감지.
장기 개선 (재발 방지)#
- 모델 save 경로의 동기 외부 I/O 콜백을 전수 감사한다. 특히
data_ware_house/full_json.rb:6-7,annotatable.rb:7,firebase/*,pub_sub/publisher.rb:37-41처럼after_save/after_commit에서 외부 시스템 (S3, Firebase, PubSub) 을 건드리는 콜백이 다수 존재. 각각을 워커 위임으로 표준화하고, 콜백에서 외부 I/O 를 금지하는 리뷰 룰을 문서화. Cluster처럼 concerns 가 20개 넘게 include 된 모델은 save 시 콜백 그래프를 시각화 (예:bundle exec rake callbacks:list[Cluster]같은 진단 태스크) 하여 어떤 콜백이 request path 에서 실행 중인지 개발자가 인지하도록 한다.- ES 인덱싱 sink 를 별도 out-of-band pipeline (예:
BulkPartialIndexWorker처럼 CDC / Sidekiq) 으로 통일하여, 요청 경로에서 ES 로의 직접 write 를 원천적으로 제거.
Monitoring#
Datadog release-dashboard 용 timeseries 쿼리 (모두 metric-only, monitor 문법 사용 금지):
p95:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::clusterscontroller#update_meta_by_key}
p99:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::clusterscontroller#update_meta_by_key}
sum:trace.rack.request.hits{service:cupixworks-api,resource_name:api::v1::clusterscontroller#update_meta_by_key,http.status_code:2xx}.as_rate()
sum:trace.rack.request.errors{service:cupixworks-api,resource_name:api::v1::clusterscontroller#update_meta_by_key}.as_rate()
Elasticsearch health signal (동일 서비스 관점):
sum:trace.faraday.request.errors{service:cupixworks-api,http.status_code:502}.as_rate()
권장 알림 (모니터 화면 별도 구성):
update_meta_by_keyp99 duration > 3s (5분 지속)service:cupixworks-api "Bad Gateway error on Elasticsearch"로그 카운트 > 5 in 10m
Risk Assessment#
- Risk level: medium — 현재 이벤트 자체는 200 응답 1건이지만,
EntityIndexable는Cluster/Capture/Record/Facility등 다수의 핵심 모델에 include 되어 있어 ES 가 지연되면 다수 endpoint 의 write latency 로 동시에 전파될 잠재력이 크다. - 예상 복잡도: standard —
EntityIndexable콜백을 워커 위임으로 바꾸는 리팩터. Sidekiq 큐/재시도 정책, 인덱스 최신성 (order guarantee) 요구사항 검토 필요.execute_after_commit + Sidekiq패턴은 이미 코드베이스에 존재 (statable/pix_genie.rb:66,taskable/job.rb등) 하므로 새로운 인프라는 불필요.