ES /docs

Bulk operation failed

RCA: Bulk operation failed

Overview#

What Happened#

2026-06-29 07:32 KST부터 약 45분 동안 cupixworks-worker (us-west-2)에서 BimRevision.bulk_operation이 29회 연속 실패했다. 모든 실패의 근본 원인은 동일하며, Elasticsearch bulk 인덱싱 도중 BimRevisionas_indexed_json 직렬화 경로가 s3.me-south-1.amazonaws.com:443로 TCP 연결을 시도하다 timeout(execution expired)되었다. 동일 시간대에 별도 워커 Record.flush_geo_coordinate도 같은 me-south-1 endpoint에서 동일한 Seahorse::Client::NetworkingError로 실패하고 있어, BimRevision 코드 결함이 아니라 us-west-2 worker → me-south-1 S3 endpoint 구간의 네트워크 도달성 문제로 판단된다.

Quick Facts#

Field Value
exception.class Seahorse::Client::NetworkingError
exception.message Failed to open TCP connection to s3.me-south-1.amazonaws.com:443 (execution expired)
top_frame app/models/concerns/searchable.rb:173 (rescued in bulk_operation)
originating_call app/models/concerns/bim_revision_result.rb:48-57 (bim_revision_result_urls)
operation update (Elasticsearch bulk update)
batch_size 300 BimRevision IDs per call
deploy production-us-west-2-20260628T2222Z0-bfdc5ebd-cupixworks
env production, region us-west-2, host ip-10-1-18-149.us-west-2.compute.internal

Affected Teams#

Team / Domain Error Count Impact
BIM / Search indexing (BimRevision) 29 (cluster) me-south-1 tenant의 BimRevision Elasticsearch 인덱스 갱신이 지연/누락. 사용자가 검색·목록에서 최신 상태를 보지 못할 수 있음
Record geo-coordinate (Record.flush_geo_coordinate) 동시 발생 (별도 cluster) 같은 root cause로 worker 실패. capture/record 지오 좌표 동기화 지연

영향은 storage가 me-south-1에 있는 단일(또는 소수) tenant 범위로 추정되나, tenant ID는 로그에 포함되어 있지 않다 — uncertain, needs verification.

Timeline#

  1. 2026-06-29 07:32 KST — 첫 Bulk operation failed 발생 (first_seen)
  2. 2026-06-29 07:32–08:17 KST — 약 45분간 29건 연속 발생. 모두 동일 host ip-10-1-18-149.us-west-2에서 me-south-1 endpoint로 TCP 연결 timeout
  3. 2026-06-29 08:17 KST — 클러스터 마지막 발생 (last_seen). 동일 시간대에 별도 cluster (Record.flush_geo_coordinate)도 같은 endpoint로 실패 중

Error Log#

Datadog Logs

text
Bulk operation failed

Impact#

  • Service: cupixworks-worker
  • 발생 횟수: 29
  • 최초 발생: 2026-06-29 07:32 KST
  • 최근 발생: 2026-06-29 08:17 KST

Root Cause Summary#

us-west-2 worker가 BimRevision.bulk_operation을 처리하면서 as_indexed_jsonBimRevisionSerializerbim_revision_result_urls 경로를 거치는데, 이 경로의 StorageOption#set_bucket_url이 me-south-1 tenant의 S3 bucket URL을 해석하기 위해 s3.me-south-1.amazonaws.com에 HTTP(S) 호출을 시도한다. 인시던트 구간 동안 us-west-2 worker에서 me-south-1 S3 endpoint로의 TCP 연결이 모두 timeout(execution expired)되어 Seahorse::Client::NetworkingError가 발생했고, searchable.rb의 광역 rescue StandardError가 이를 잡아 Bulk operation failed로 기록한 채 false를 리턴하면서 Sidekiq retry도 트리거되지 않았다. 같은 시간대에 동일 endpoint를 향한 Record.flush_geo_coordinate 워커도 실패하고 있어, BimRevision 코드의 결함이 아니라 us-west-2 ↔ me-south-1 구간의 네트워크/AWS 가용성 문제가 root cause다.

Technical Analysis#

Code Path#

  • Entry point: app/workers/bulk_index_worker.rb:14 — Sidekiq worker가 BimRevision.bulk_operation! 호출
  • Bulk 래퍼: app/models/concerns/searchable.rb:170bulk_operationbulk_operation!을 감싸고 StandardError를 rescue
  • 직렬화 루프: app/models/concerns/searchable.rb:207-216records.find_each 안에서 각 record의 as_indexed_json 호출
  • BimRevision serializer: app/models/concerns/searchable/bim_revision.rb:91-123BimRevisionSerializer로 직렬화
  • S3 호출 발생: app/models/concerns/bim_revision_result.rb:48-57bim_revision_result_urls가 storage_option을 사용해 URL 생성
  • Failure point: app/models/storage_option.rb:118-126set_bucket_urlCupix::StorageService.bucket(...)을 통해 me-south-1 S3 endpoint에 도달 시도

bulk_operation의 rescue 핸들러 (로그 메시지를 만드는 지점):

app/models/concerns/searchable.rb:170-184ruby
def bulk_operation(ids, operation = 'index', refresh_cached = false)
  bulk_operation!(ids, operation, refresh_cached)
rescue StandardError => e
  Cupix::Logger.error('Bulk operation failed',
                      class: self.name, function: __method__,
                      operation: operation,
                      ids_count: ids&.size,
                      ids_first: ids&.first,
                      ids_last: ids&.last,
                      error_class: e.class.name,
                      error_message: e.message)
  false
else
  true
end

as_indexed_json 직렬화 도중 외부 S3 호출이 발생하는 경로:

app/models/concerns/bim_revision_result.rb:48-57ruby
def bim_revision_result_urls
  return nil if revision.zero?

  bim_revision_objects.map do |path|
    Cupix::StorageService.object_url(
      storage_option: storage_option,
      key: "#{bim_revision_basepath(revision)}/#{path}"
    )
  end
end

object_url 자체는 cf_hostname/endpoint만 있으면 문자열 조합으로 끝나지만, storage_options3_source_bucket_url을 캐시하지 못한 경우 StorageOption 초기화 단계에서 다음 경로가 실행되어 AWS SDK가 me-south-1 endpoint로 TCP 연결을 시도한다:

app/models/storage_option.rb:106-130ruby
def set_bucket_url
  raise Cupix::Errors::Argument.new(code: 'ARG10000', reason: 's3_source_bucket_name is required') if @s3_source_bucket_name.blank?
  raise Cupix::Errors::Argument.new(code: 'ARG10000', reason: 's3_bucket_region is required') if @s3_bucket_region.blank?

  cache_key = "bucket_url_#{@s3_source_bucket_name}_#{@s3_bucket_region}"
  cached = Rails.cache.read(cache_key)

  if cached.present?
    @s3_source_bucket_url = cached
    return
  end

  begin
    bucket = Cupix::StorageService.bucket(name: @s3_source_bucket_name, storage_option: self)
    _url = bucket.url
    Rails.cache.write(cache_key, _url)
    @s3_source_bucket_url = _url
  rescue => e
    raise e
  end
end
app/services/cupix/storage_service.rb:20-27ruby
def bucket(storage_option: nil, **kwargs)
  opts = parse_storage_option(storage_option).merge(kwargs)
  opts[:force_path_style] = true

  check_required_params(opts, %i[name region])

  Aws::S3::Bucket.new(opts)
end

기대 동작: ES 인덱싱 직렬화(as_indexed_json)는 in-memory 변환이어야 하며 외부 네트워크 호출이 발생하지 않아야 한다. 실제 동작: tenant storage가 me-south-1일 때 us-west-2 worker가 me-south-1 S3 endpoint로 TCP 연결을 시도하고, 인시던트 구간에는 해당 endpoint가 도달 불가하여 timeout. batch 처리 중 한 record에서 예외가 raise되면 동일 batch 내 다른 record는 처리되지 않은 채 batch 전체(300개)가 실패한다.

또한 bulk_operation은 예외를 false로 swallow하므로 Sidekiq의 재시도(BulkIndexWorkerretry: 5)가 트리거되지 않는다:

app/workers/bulk_index_worker.rb:1-26ruby
class BulkIndexWorker
  include Sidekiq::Worker
  sidekiq_options queue: :default, retry: 5

  def perform(model_name, ids, operation = 'index', refresh_cached = false)
    return if ids.blank?
    return unless model_name.is_a?(String)

    model_name.classify.constantize.bulk_operation!(ids, operation, refresh_cached)
  end
end

위 워커는 bulk_operation!(예외 전파 버전)을 호출하므로 자체적으로는 재시도가 동작한다. 그러나 동일한 me-south-1 timeout이 5번 재시도되는 동안에도 같은 endpoint가 계속 불통이라면 결과적으로 모든 재시도가 실패한다(retry interval: 300, 600, 1200, 2400, 2400초). 그리고 모델의 after_commit 콜백을 거쳐 들어오는 단일 record 인덱싱(_index_documentBulkIndexWorker.perform_async(self.class.name, [id], 'index'))은 동일 클래스의 bulk_operation!로 가지만, 본 클러스터에 기록된 ids_count: 300 배치는 재시도되지 않고 swallow된 것으로 보인다 — needs verification (어느 호출 site에서 bulk_operation(swallow)을 부르는지 추가 확인 필요).

Log Evidence#

사용한 Datadog 쿼리:

text
service:cupixworks-worker "Bulk operation failed" @class:BimRevision

대표 로그 (가장 최근 발생, raw JSON):

json
{
  "timestamp": "2026-06-28T23:17:13.296Z",
  "level": "error",
  "message": "Bulk operation failed",
  "class": "BimRevision",
  "function": "bulk_operation",
  "operation": "update",
  "ids_count": 300,
  "ids_first": 19530,
  "ids_last": 19829,
  "error_class": "Seahorse::Client::NetworkingError",
  "error_message": "Failed to open TCP connection to s3.me-south-1.amazonaws.com:443 (execution expired)",
  "service": "cupixworks-worker",
  "environment": "production",
  "host": "ip-10-1-18-149.us-west-2.compute.internal",
  "version": "production-us-west-2-20260628T2222Z0-bfdc5ebd-cupixworks"
}

연속 batch ID 분포 (300개씩 진행되며 모두 동일 endpoint timeout):

text
ids_first=18930  ids_last=19229  (@timestamp 22:14:45Z = 07:14 KST 다음 날 — 직전 batch)
ids_first=19230  ids_last=19529  (@timestamp 23:15:49Z = 08:15 KST)
ids_first=19530  ids_last=19829  (@timestamp 23:17:13Z = 08:17 KST)

동일 시간대 다른 코드 경로에서도 같은 endpoint timeout이 관찰되어 BimRevision 한정 문제가 아님:

text
service:cupixworks-worker "execution expired" "s3."
json
{
  "timestamp": "2026-06-28T23:29:13Z",
  "level": "error",
  "class": "Record",
  "function": "flush_geo_coordinate",
  "message": "flush_geo_coordinate - error - message: Failed to open TCP connection to s3.me-south-1.amazonaws.com:443 (execution expired)"
}

us-west-2 worker가 me-south-1 S3에 성공적으로 도달한 info 레벨 로그는 인시던트 시간대(2026-06-28 22:00–23:30 UTC)에 발견되지 않았다:

text
service:cupixworks-worker "me-south-1" status:info  →  0 logs

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 us-west-2 worker → me-south-1 S3 endpoint 네트워크 도달 불가 (또는 AWS 측 endpoint 이슈) 29건 모두 Seahorse::Client::NetworkingError: Failed to open TCP connection to s3.me-south-1.amazonaws.com:443 (execution expired). 같은 시간대 별도 클래스(Record.flush_geo_coordinate)도 동일 endpoint로 동일 에러. 단일 host(ip-10-1-18-149.us-west-2)에서 집중 발생 Confirmed
H2 BimRevision/bim_revision_result_urls 코드 로직 결함 (nil reference, 직렬화 오류 등) error_classSeahorse::Client::NetworkingError로 네트워크 계층 오류임이 명확. 같은 직렬화 경로가 다른 region에서는 평소 정상 동작 Rejected
H3 Elasticsearch 클러스터 장애 bulk_operation이 ES 호출을 포함하므로 ES 장애일 가능성 검토 error message가 s3.me-south-1.amazonaws.com:443 (S3 endpoint). ES 관련 에러 클래스(Elasticsearch::Transport::Transport::*)나 timeout 로그 없음 Rejected
H4 Worker host의 일반 outbound 네트워크 장애 (모든 region 영향) 단일 host에서 집중 발생 timeout이 me-south-1 endpoint로 한정. 같은 host의 다른 outbound 실패 로그 없음 — 문제 범위가 region-pair 한정 Rejected (scope is region-pair, not host-wide)
H5 tenant storage_option에 잘못된 region 값(오설정)으로 me-south-1으로 잘못 라우팅 me-south-1는 StorageOption#set_region_code (line 99-100)에 정상 정의된 region. 인시던트 이전 정상 동작 여부 확인 어려움 Inconclusive (needs tenant storage_option verification)

Fix Recommendation#

즉시 조치 (Critical)#

  • 외부 의존성 상태 확인: AWS Health Dashboard에서 me-south-1 region(특히 S3)의 인시던트 여부를 확인한다. 인시던트 발생 구간(2026-06-28 22:32–23:17 UTC)과 일치한다면 별도 코드 수정 없이 모니터링으로 종결 가능. 과거 episode cc2e2887에서 cross-region 시나리오는 종종 일시적 운영 조건이라는 학습이 있다.
  • 단일 host 격리 점검: 영향 host ip-10-1-18-149.us-west-2.compute.internal이 us-west-2 worker fleet 중 일부 인스턴스에 국한된 문제인지 확인 (VPC route table, NAT/Egress, EC2 ENI). 동일 fleet 다른 인스턴스에서는 정상이라면 해당 인스턴스를 교체/재시작.

단기 개선 (1주 이내)#

  • bim_revision_result_urls에서 동기 S3 호출 제거: ES 인덱싱(as_indexed_json)은 in-memory 변환이어야 하며 외부 S3 endpoint에 동기 호출이 일어나지 않아야 한다. URL은 사전 계산해 DB/cache에 저장된 값을 사용하거나, cf_hostname/endpoint가 모두 없으면 nil/빈 배열을 리턴하고 background에서 계산하도록 변경한다 (app/models/concerns/bim_revision_result.rb:48-57, app/models/storage_option.rb:106-130).
  • Rescue 범위 축소 및 로그 메시지 정확화: app/models/concerns/searchable.rb:172rescue StandardError는 ES bulk 호출 실패뿐 아니라 직렬화 단계의 외부 호출 실패까지 같은 로그 메시지(Bulk operation failed)로 묶는다. 메시지에 어느 단계에서 실패했는지(serialization vs ES bulk call) 명시하거나, Seahorse::Client::NetworkingError/Faraday::TimeoutError 등 일시적 네트워크 오류는 별도 로그(예: warn)로 분리한다.
  • 재시도 전략 검토: 호출 site가 bulk_operation(swallow, 재시도 없음)을 부르는 경우와 bulk_operation!(예외 전파, Sidekiq retry 5회)을 부르는 경우가 혼재한다. 일시적 네트워크 오류는 재시도되도록 호출 site를 정리한다.

장기 개선 (재발 방지)#

  • 인덱싱 경로에서 외부 의존성 제거: ES 인덱싱 데이터는 모델 자체 컬럼 또는 캐시된 derived 값만 사용. 외부 서비스 URL 생성은 read-time(컨트롤러/serializer)에 수행하거나 별도 background job에서 채워둔다.
  • Cross-region S3 호출 회로 차단(circuit breaker): tenant 별 region 도달성이 일정 시간 실패하면 회로를 차단해 worker thread time을 소진하지 않도록 한다.
  • Per-region health 모니터: us-west-2 worker가 사용하는 각 tenant region에 대한 outbound 연결 상태를 별도 metric으로 노출.

Monitoring#

  • 추가할 메트릭/알림:
    • Seahorse::Client::NetworkingError 발생률을 service/@error_class 기준으로 추적
    • me-south-1 endpoint timeout 발생 시 worker 알림

Datadog 쿼리 예시 (release dashboard timeseries widget 용):

text
service:cupixworks-worker @error_class:Seahorse::Client::NetworkingError
text
service:cupixworks-worker "Bulk operation failed" @class:BimRevision
text
service:cupixworks-worker "s3.me-south-1.amazonaws.com"

Risk Assessment#

  • Risk level: medium — 단일 region/tenant 범위의 외부 의존성 문제이나 ES 인덱스 정합성에 영향을 주며, 일부 호출 site의 swallow 패턴으로 인해 일시적 장애가 인덱싱 누락으로 굳어질 수 있음.
  • 예상 복잡도: standard — 즉시 조치는 외부 의존성 확인 + 인스턴스 격리. 단기 개선(rescue 범위/메시지, 인덱싱 경로의 동기 S3 호출 제거)은 모델/serializer 레이어 리팩토링 수준.