ES /docs

Api::V1::AnnotationsController#index (avg 10050ms, max 10050ms)

RCA: Api::V1::AnnotationsController#index latency (avg/max 10050ms)

Overview#

What Happened#

2026-06-26 23:12:43 KST에 cupixworks-apiApi::V1::AnnotationsController#index 요청이 Elasticsearch 호출에서 10초 timeout 에 걸려 502 Bad Gateway (code BG10002) 로 응답했다. 동일 review (v4efud) 에 대한 1ms 뒤 재시도 요청은 122ms 에 정상 완료된 것으로 보아, 쿼리 자체가 무거운 것이 아니라 Elasticsearch 호출이 일시적으로 지연된 단발성 사건이다.

Quick Facts#

Field Value
controller#action Api::V1::AnnotationsController#index
http.status_code 502
error.class Cupix::Errors::BadGateway
error.code BG10002
underlying error Operation timed out after 10002 milliseconds with 0 bytes received (Patron / Elasticsearch transport)
top_frame app/repositories/base_repository.rb:94-97
duration 10048.24 ms
db time 10.03 ms (≪ 전체 duration)
host ip-10-1-80-134.us-west-2.compute.internal
deploy production-us-west-2-20260626t0223z0-bfdc5ebd-cupixworks
env production / us-west-2
affected team rogers-obrien (team_id 631)
review_key v4efud

Affected Teams#

Team / Domain Error Count Impact
rogers-obrien 1 annotations 목록 조회 1건 실패 (10s 대기 후 502). 즉시 재시도는 122ms 만에 성공.

Timeline#

  1. 2026-06-26 23:12:33 KST — 클라이언트가 GET /api/v1/reviews/v4efud/annotations 호출 시작.
  2. 2026-06-26 23:12:43 KST — Elasticsearch transport 가 10,002 ms 만에 timeout (Operation timed out after 10002 milliseconds with 0 bytes received).
  3. 2026-06-26 23:12:43 KSTbase_repository.rb#searchStandardError 로 catch 하여 Cupix::Errors::BadGateway (code BG10002) 를 raise. 응답 코드 502, 총 소요 10048.24 ms.
  4. 2026-06-26 23:12:43 KST — 동일 review/세션의 재시도 요청 (request_id=4c6f617c…) 이 122ms 에 200 으로 성공.

Error Log#

Datadog Logs

text
{
  "resource_name": "Api::V1::AnnotationsController#index",
  "service": "cupixworks-api",
  "occurrences": 1,
  "avg_ms": 10050,
  "max_ms": 10050,
  "sample_trace_id": "3102542776567078022"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 1
  • 최초 발생: 2026-06-26 23:12:32 KST
  • 최근 발생: 2026-06-26 23:12:32 KST

Root Cause Summary#

Elasticsearch 클라이언트가 초기화 시 transport_options.request.timeout: 10 초로 설정되어 있는데 (config/initializers/elasticsearch.rb:25), 해당 호출이 정확히 그 시점에 timeout 되었다. Datadog request log 에 기록된 db: 10.03 (DB query 10 ms), 그리고 1 ms 뒤 동일 review 에 대한 재시도가 122 ms 에 성공한 사실은 본 쿼리/리뷰의 데이터 양이나 SQL 자체가 무거워서 발생한 latency 가 아니라, Elasticsearch 측 또는 그 사이의 네트워크/커넥션이 일시적으로 응답을 보내지 못한 단발성 stall 임을 보여준다. BulkRepository#searchrescue StandardError 는 timeout 도 일반 ES 오류로 묶어 502/BG10002 로 변환한다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/annotations_controller.rb:16-25
  • Repository search: app/repositories/annotation_repository.rb_search::Annotation.search(query_option.serializable_hash).paginate(...) 호출
  • Failure point: app/repositories/base_repository.rb:94-97
  • Transport configuration: config/initializers/elasticsearch.rb:17-34request.timeout: 10

Controller 는 Elasticsearch 기반 search 만 호출한다.

app/controllers/api/v1/annotations_controller.rb:16-25ruby
def index
  annotation_query_option = Cupix::QueryOption::Annotation.new(get_query_option, params)
  annotations = repository_instance.search(annotation_query_option)

  render_api Renderable.new({
    search_result: annotations,
    is_collection: true,
    serializer_option: @serializer_option
  })
end

_search 내부에서 ::Annotation.search 가 Elasticsearch 로 HTTP 요청을 보낸다.

app/repositories/annotation_repository.rb:427-432ruby
response = ::Annotation.search(
  self.query_option.serializable_hash
).paginate(
  per_page: self.query_option.per_page,
  page: self.query_option.page
)

Elasticsearch client 는 Patron adapter 와 함께 request timeout 10 초로 초기화되어 있다.

config/initializers/elasticsearch.rb:17-28ruby
Elasticsearch::Model.client = ConnectionPool::Wrapper.new(size: 10, timeout: 7) {
  Elasticsearch::Client.new(
    host: ENV.fetch('RAILS_ES_HOST') { 'localhost' },
    port: ENV.fetch('RAILS_ES_PORT') { DEFAULT_RAILS_ES_PORT },
    user: ENV['RAILS_ES_USER'],
    password: ENV['RAILS_ES_PASSWORD'],
    transport_options: {
      request: {
        timeout: 10
      }
    }
  )

base_repository.rb#search 는 ES 호출 중 발생한 StandardError 를 모두 Cupix::Errors::BadGateway (BG10002) 로 변환한다. timeout 도 여기서 502 로 응답된다.

app/repositories/base_repository.rb:70-98ruby
def search(query_option = nil)
  _search(query_option)

  begin
    if self.review.present?
      contents = self.class.permission_joins(self.class.default_joins(self.response.records), ...)
    # ...
    end
  rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e
    # ...
  rescue Elasticsearch::Transport::Transport::ServerError => e
    raise unless e.message.start_with?('[429]')
    # ...
  rescue StandardError => e
    Cupix::Logger.error(e.message.to_s, class: self.class.name, method: __method__)

    raise Cupix::Errors::BadGateway.new(code: 'BG10002', reason: 'Bad Gateway error on Elasticsearch')
  end

기대 동작: ES 일시 지연 시에도 client 가 retry 하거나, 짧은 timeout 으로 빠르게 실패한 후 자동 재시도되어 사용자 경험에 영향이 없어야 한다. 실제 동작: 단일 호출이 10초간 응답을 기다린 뒤 502 로 그대로 사용자에게 반환된다. (동일 review 에 대한 클라이언트/브라우저 재시도는 122ms 에 성공 → ES 자체가 망가진 것이 아닌 단발성 stall.)

Log Evidence#

Datadog query (request log):

text
service:cupixworks-api AnnotationsController#index @duration:>5000
from 2026-06-26T14:00:00Z to 2026-06-26T14:30:00Z

해당 요청의 request log (요약):

json
{
  "@timestamp": "2026-06-26T14:12:43.455Z",
  "duration": 10048.24,
  "db": 10.03,
  "http": {
    "status_code": 502,
    "method": "GET",
    "url_details": { "path": "/api/v1/reviews/v4efud/annotations" }
  },
  "controller": "Api::V1::AnnotationsController",
  "action": "index",
  "error": {
    "class": "Cupix::Errors::BadGateway",
    "code": "BG10002",
    "reason": "Bad Gateway error on Elasticsearch",
    "message": "Bad Gateway error on Elasticsearch"
  },
  "team": { "domain": "rogers-obrien", "id": 631 },
  "params": { "per_page": "100", "review_key": "v4efud", "page": "1" },
  "host": { "name": "ip-10-1-80-134.us-west-2.compute.internal" }
}

같은 host 에서 같은 시점에 기록된 error 로그 (Patron transport):

text
2026-06-26T14:12:43.680Z status:error
"Operation timed out after 10002 milliseconds with 0 bytes received"

0 bytes received 메시지는 ES 가 응답 헤더조차 보내지 않은 채 클라이언트가 timeout 한 케이스 — 즉 ES 측의 query latency 가 아닌 connection-level stall 가능성이 높다.

Datadog query (재시도 확인):

text
service:cupixworks-api v4efud annotations
from 2026-06-26T14:12:30Z to 2026-06-26T14:12:50Z

동일 review/세션의 두 인접 요청:

text
ts                          duration  db     status  request_id                              session
2026-06-26T14:12:43.456Z    122.03    13.87  200     4c6f617c-37d7-4d9f-97c1-b5696b499480    684e54bf379b5518...
2026-06-26T14:12:43.455Z    10048.24  10.03  502     43609ac6-e944-4d45-8a3a-ded69103fac5    684e54bf379b5518...

본 timeout 이 산발적임을 확인한 7일 집계:

text
service:cupixworks-api "Operation timed out after 10002"
from now-7d
=> 20 occurrences spread across 2026-06-19 ~ 2026-06-26, 1~3 per hour at most

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Elasticsearch 호출이 10초 transport timeout 에 걸려 502 변환됨 (단발성 stall) request log duration=10048.24, db=10.03, error.code=BG10002; 동시간대 error 로그 "Operation timed out after 10002 milliseconds with 0 bytes received"; config/initializers/elasticsearch.rb:25request.timeout: 10 과 정확히 일치; 동일 review 재시도 122ms 성공 Confirmed
H2 annotations 인덱스 ActiveRecord 쿼리/permission_joins 가 무거워서 latency 발생 permission_joins (annotation_repository.rb:80-305) 는 매우 많은 LEFT JOIN 을 포함 — 일반적으로 latency 후보 db=10.03 ms 로 DB 시간 자체가 거의 없음. 또 동일 review 재시도가 122ms 만에 성공 → 쿼리 자체는 문제 없음 Rejected
H3 ES 클러스터 광범위 outage (전체 영향) 같은 시점 ES timeout 1건만, 다른 review/endpoint 는 정상 200 응답 (예: v4efud/panos 110~750ms, v4efud/bookmarks/me 127ms) 7일 누적 timeout 20건이 시간대별로 흩어져 있음 — 지속적 outage 가 아닌 산발적 stall Rejected
H4 요청 파라미터 (per_page=100, 큰 fields 리스트) 가 ES 부하 폭증을 유발 params 에 50여 개 field 와 per_page=100 같은 review 의 122ms 성공 요청 (동일 페이지·필드) 가 존재. 다른 시간대 동일 endpoint 호출들도 정상 Rejected
H5 Datadog status-board 가 신호한 svc:cupixworks-api::unknown 의 active incident 의 일부 같은 service 에서 최근 5건의 degraded 인시던트 (status: resolved) 본 cluster 발생 시각 (14:12 UTC) 에는 active incident 가 없음 (active: null). 가장 가까운 resolved incident 는 12:49 UTC 종료 Rejected (참고용 context 만)

Fix Recommendation#

즉시 조치 (Critical)#

  • 본 발생 자체는 단발성 (1건) 이고 클라이언트 재시도가 122ms 에 성공 → 별도의 코드 hotfix 불필요. 운영 차원에서 7일간 20건의 ES timeout 추세만 모니터링.
  • 만약 BG10002 발생 빈도가 시간당 5건 이상으로 늘어나면 ES 클러스터의 P99 latency, search queue rejection, JVM GC pause 를 우선 점검 (cupix-infrastructure 의 ES 모듈 / OpenSearch 도메인).

단기 개선 (1주 이내)#

  • app/repositories/base_repository.rb:94-97rescue StandardError 가 timeout 까지 BG10002 한 가지로 묶고 있어 운영 가시성이 떨어진다. Datadog 에서 timeout 케이스만 분리해 alert/대시보드를 만들 수 있도록 별도 분기를 두는 방향을 검토 (예: Elasticsearch::Transport::Transport::Error 중 timeout 계열은 별도 error code 와 warn 레벨 메트릭 + error.cause 정보 보존). 구현 코드는 본 RCA 범위 외.
  • 502 가 사용자에게 그대로 노출되면 UX 가 나쁘므로, 클라이언트 (review 페이지 로딩 흐름) 에 idempotent retry 가 없다면 1회 정도의 client-side retry 를 추가하는 것을 고려 (frontend 영역).

장기 개선 (재발 방지)#

  • ES 호출 path 의 SLO 를 명시적으로 설정 (p99 < 1s, timeout error budget). cupixworks-api 의 모든 search controller (annotations, element_traces, editings, ...) 가 동일 transport 를 공유하므로, 같은 stall 영향을 받는다 — 공통 dashboards 와 alerting 을 일원화.
  • Elasticsearch transport 의 connection pool 동작과 idle/keepalive 설정을 점검 (Patron + ELB/OpenSearch 의 idle timeout 정렬). 0 bytes received 패턴은 종종 idle 커넥션이 죽은 채 재사용된 경우에 나타난다 — 운영 환경의 LB idle timeout 과 client keepalive 를 정렬할 필요가 있는지 검증 (cupix-infrastructure 측 작업).

Monitoring#

ES timeout 발생 추세 (request log 기반):

text
service:cupixworks-api "Operation timed out after 10002"

AnnotationsController#index 의 502 발생 빈도:

text
service:cupixworks-api controller:Api::V1::AnnotationsController action:index @http.status_code:502

ES BG10002 전체 (모든 search controller 공통 ES timeout/error 신호):

text
service:cupixworks-api "Bad Gateway error on Elasticsearch"

AnnotationsController#index 의 응답 시간 분포 (10초 timeout 클리프 시각화용):

text
service:cupixworks-api controller:Api::V1::AnnotationsController action:index @duration:>5000

위 4개 쿼리는 dashboard timeseries widget 의 count aggregation 으로 사용. (writing-datadog-monitoring-queries 가이드에 따라 monitor-only 문법 | stats, count by(...) 등은 사용하지 않음.)

Alerting 임계치 제안: "Operation timed out after 10002" 이 5분간 5건 이상 발생 시 warn, 15분간 10건 이상 시 critical.

Risk Assessment#

  • Risk level: low (단발성, 동일 review 의 즉시 재시도 성공, 사용자 영향 1건)
  • 예상 복잡도: trivial — 코드 변경 없이 monitoring/dashboard 강화만 권장. 단기 개선 (error code 분리) 은 standard 수준.