ES /docs

Failed to put records: Failed to open TCP connection to kinesis.us-west-2.amazonaws.com:443 (executi

RCA: Failed to put records — Kinesis us-west-2 TCP timeout

Overview#

What Happened#

2026-07-17 22:45 KST, cupixworks-api (production, us-west-2) 에서 model update callback 이 site-insights 이벤트를 Kinesis 스트림에 동기 publish 시도하다가 kinesis.us-west-2.amazonaws.com:443 로의 TCP 소켓 open 이 timeout 되며 실패했다. 단일 요청 1회 발생이며, 같은 초에 caller-level(Failed to create event) 과 SDK-level(Failed to put records) 두 개의 error 로그가 페어로 남았다.

Quick Facts#

Field Value
exception.class Seahorse::Client::NetworkingError (wraps Net::OpenTimeout)
exception.message Failed to open TCP connection to kinesis.us-west-2.amazonaws.com:443 (execution expired)
top_frame lib/cupix/aws/kinesis.rb:9 (kinesis_client.put_records)
runtime Ruby / Rails (cupixworks-api)
env production, us-west-2, tenant cupix

Affected Teams#

Team / Domain Error Count Impact
cupixworks-api (site-insights event publish) 2 로그 / 1 요청 단일 API request 의 모델 업데이트 콜백이 500 으로 실패했을 가능성 (site-insights 이벤트 소실)

Timeline#

  1. 2026-07-17 22:45:51 KSTEventable::Events::Update.create_event 콜백이 Cupix::EventService.publish_event 를 호출해 Kinesis put_records! 로 진입.
  2. 2026-07-17 22:45:51 KSTAws::Kinesis::Client#put_records 내부의 HTTP open 이 timeout, Net::OpenTimeout (execution expired) 발생. Cupix::Aws::Kinesis.put_records! 의 rescue 블록이 Failed to put records: ... 를 로깅하고 raise.
  3. 2026-07-17 22:45:51 KSTEventable::Events::Base.create_eventrescue StandardError 가 이 예외를 잡아 Failed to create event: ... 를 로깅하고 재-raise. 이후 자동 재시도 없음.
  4. 2026-07-17 22:45:51 KST — status-board 가 sibling cluster 28729bf3-51f6-47d0-80ba-c79bd2a8ee61 와 함께 svc:cupixworks-api::unknown 인시던트 2026-07-17-svc-cupixworks-api--unknown-2 로 자동 그룹핑 (동일 초, 이후 재발 없음 — resolved).

Error Log#

[Datadog Logs](https://app.datadoghq.com/logs?query=service%3Acupixworks-api%20status%3Aerror%20%40environment%3Aproduction%20%22Failed%20to%20put%20records%3A%20Failed%20to%20open%20TCP%20connection%20to%20kinesis.us-west-2.amazonaws.com%3A443%20(executi%22&from_ts=1784292300000&to_ts=1784299560000&live=false)

text
Failed to put records: Failed to open TCP connection to kinesis.us-west-2.amazonaws.com:443 (execution expired)

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 1
  • 최초 발생: 2026-07-17 22:45 KST
  • 최근 발생: 2026-07-17 22:45 KST

Root Cause Summary#

Production API 프로세스에서 Kinesis us-west-2 endpoint 로의 TCP connect 가 aws-sdk-core 의 기본 http_open_timeout (15초) 안에 완료되지 못했다. Ruby 의 Net::OpenTimeout"execution expired" 메시지로 표면화되며, Cupix::Aws::Kinesis.put_records! 는 이를 잡아 로깅한 뒤 그대로 raise 한다. 상위 호출자인 Cupix::EventService.publish_eventRestClient::Exception 만 rescue 하므로 이 Seahorse::Client::NetworkingError 는 통과하고, model callback 을 통해 실행 중이던 API 요청 자체가 실패한다. 발생 빈도(단발, 14일 창에서 유일)로 볼 때 지속적 문제가 아니라 순간적 네트워크/TLS handshake 지연(전송량 스파이크, VPC endpoint 흔들림 등)에 노출된 것으로 판단되며, 애플리케이션 수준에서는 동기 publish 를 API request path 에 두는 설계가 이 순간적 이슈를 사용자 요청 실패로 확대시키는 구조적 원인이다.

Technical Analysis#

Code Path#

Entry point: model callback → Eventable::Events::Update.create_event(self) (예: app/models/concerns/notifiable/record.rb:27).

  1. Eventable::Events::Base.create_event 가 이벤트를 만든 뒤 동기로 Kinesis publish 를 호출한다.
app/models/concerns/eventable/events/base.rb:7-26ruby
def create_event(model)
  return nil if invalid_event?(model)

  begin
    event = _create_event(model)
    reason = extract_reason(event, model)
    properties = build_properties(model)

    track_event(model, reason, properties)

    Cupix::EventService.publish_event([event])
    Cupix::Event.publish(event.serializable_hash(stringify_nested_fields: false))
  rescue StandardError => e
    Cupix::Logger.error("Failed to create event: #{e.message}", function: __method__, class: self.name, model: model.class.to_s, id: model.id, event_params: model.event_params, error: e)
    raise e
  else
    model.event_created!
    event
  end
end
  1. Cupix::EventService.publish_event 가 Kinesis SDK 를 직접 호출한다. 예외 rescue 는 RestClient::Exception 만 다루므로 Seahorse networking error 는 통과한다.
lib/cupix/event_service.rb:21-54ruby
def self.publish_event(events = [])
  return if events.blank?
  return if %w[development test].include?(Rails.env)
  return unless release_date_by(Rails.env)

  _event = events.first
  records = events.map do |event|
    {
      data: event.serializable_hash.to_json,
      partition_key: Current.request_id || SecureRandom.uuid
    }
  end
  # ... stream_name 결정 ...
  response = Cupix::Aws::Kinesis.put_records!({ stream_name: stream_name, records: records })
  # ...
rescue RestClient::Exception => e
  Cupix::Logger.error("Failed to publish event: #{e.message}", ...)
  raise Cupix::Errors::System.new(code: 'SYS20000', reason: "Failed to publish event: #{e.message}")
end
  1. Failure point — Cupix::Aws::Kinesis.put_records! 는 SDK 호출 직전/직후에 아무 timeout 옵션도 지정하지 않아 aws-sdk-core 의 기본값(http_open_timeout: 15s, retry_limit: 3) 을 사용한다. rescue 는 로그만 남기고 재-raise 한다.
lib/cupix/aws/kinesis.rb:5-22ruby
def put_records!(opts = {})
  raise Cupix::Errors::Argument.new(code: 'ARG10001', reason: 'stream_name is blank') if opts[:stream_name].blank?
  raise Cupix::Errors::Argument.new(code: 'ARG10001', reason: 'records is blank') if opts[:records].blank?

  kinesis_client.put_records({
    stream_name: opts[:stream_name],
    records: opts[:records]
  })
rescue => e
  Cupix::Logger.error("Failed to put records: #{e.message}", class: self.name, function: __method__, stream_name: opts[:stream_name], record_count: opts[:records]&.size, record_sizes: opts[:records]&.map { |r| r[:data]&.bytesize })

  raise e
end

def kinesis_client
  @kinesis_client ||= ::Aws::Kinesis::Client.new(region: ::Cupix::Tesla.region)
end

기대 동작 vs 실제 동작:

  • 기대: Kinesis publish 실패는 사용자 요청과 분리되어 별도 워커(KinesisPutRecordsWorker, retry: 3) 에서 흡수되고 재시도되어야 한다. Site-insights 자체 경로(KinesisPublisher) 는 이미 Sidekiq 로 분리되어 있다 (app/services/cupix/siteinsights_service/kinesis_publisher.rb:10).
  • 실제: Cupix::EventService.publish_event 는 위 워커 경로를 사용하지 않고 request thread 에서 put_records! 를 직접 호출한다. 순간적 네트워크 지연이 API 요청 실패로 이어지고, 애플리케이션 수준의 재시도가 없다.

Log Evidence#

Datadog query used:

text
service:cupixworks-api "kinesis.us-west-2.amazonaws.com"

Time range: 2026-07-17T13:00:00Z2026-07-17T14:15:00Z (2 hits).

두 로그가 같은 초에 페어로 발생:

json
{
  "timestamp": "2026-07-17 22:45:51",
  "status": "error",
  "message": "Failed to put records: Failed to open TCP connection to kinesis.us-west-2.amazonaws.com:443 (execution expired)",
  "class": "Cupix::Aws::Kinesis",
  "function": "put_records!"
}
json
{
  "timestamp": "2026-07-17 22:45:51",
  "status": "error",
  "message": "Failed to create event: Failed to open TCP connection to kinesis.us-west-2.amazonaws.com:443 (execution expired)",
  "class": "Eventable::Events::Update",
  "function": "create_event",
  "error": {
    "msg": "Failed to open TCP connection to kinesis.us-west-2.amazonaws.com:443 (execution expired)"
  }
}

확장 검색 결과:

  • service:cupixworks-api status:error "execution expired" (now-24h) → 위 2건만.
  • service:cupixworks-api status:error (13:40Z13:55Z) → 위 2건만 (window 내 다른 에러 없음).
  • Status board: svc:cupixworks-api::unknown scope, active: null, 이 클러스터는 sibling 28729bf3-... 와 함께 인시던트 2026-07-17-svc-cupixworks-api--unknown-2 로 자동 그룹핑되었고 이미 resolved.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 순간적 네트워크/TLS 지연으로 Kinesis endpoint TCP open 이 aws-sdk-core 기본 http_open_timeout (15s) 을 초과 메시지가 Net::OpenTimeout 시그니처인 "execution expired". 14일 창에서 유일 1회, 동일 초 sibling 만 존재. status-board 로 이미 resolved. Confirmed
H2 Kinesis 서비스의 광범위 outage (dep:kinesis 장애) status-board 가 dep:* 대신 svc:cupixworks-api::unknown 으로 그룹핑함. 같은 시간대 다른 서비스에서 kinesis 실패 없음. AWS us-west-2 kinesis 로 향하는 다른 클라이언트/워커의 실패 로그 없음 Rejected
H3 애플리케이션 버그 (잘못된 stream_name, 잘못된 records payload) Cupix::Aws::Kinesis.put_records! 는 blank stream/records 를 명시적으로 Cupix::Errors::Argument 로 raise 하며, 실제 로그는 그 예외가 아닌 TCP 소켓 timeout. records 자체가 문제라면 SDK 는 ProvisionedThroughputExceededException 등 응답 레벨 에러를 반환했을 것이나 그런 흔적 없음. Rejected
H4 Ruby 프로세스 fd 고갈/커넥션 풀 소진으로 새 소켓 open 불가 이론적으로 Net::OpenTimeout 유발 가능 같은 시간 창에서 다른 AWS/HTTP 클라이언트의 유사 timeout 이 관측되지 않음. 프로세스가 문제라면 후속 요청도 실패했어야 함. Rejected (uncertain — process-level metric 확인 안 됨, needs verification via system.net.* metric 확인)

Fix Recommendation#

즉시 조치 (Critical)#

  • 없음. 단발성 순간 네트워크 지연으로 확인되었고 자동 그룹 인시던트도 이미 resolved 상태이다. 코드 변경 없이도 재현/재발이 관측되지 않는다.

단기 개선 (1주 이내)#

  • lib/cupix/event_service.rb:43 의 동기 put_records! 호출을 이미 존재하는 비동기 경로(KinesisPutRecordsWorkerapp/workers/kinesis_put_records_worker.rb:6, retry: 3, queue :aws) 로 이관하는 방향을 검토. KinesisPublisher.publish (app/services/cupix/siteinsights_service/kinesis_publisher.rb:10) 가 이미 batch → worker 패턴을 구현하고 있으므로 재사용 가능. 이렇게 하면 순간적 Kinesis 네트워크 지연이 API request path 를 실패시키지 않고, Sidekiq 3회 재시도로 자연 흡수된다.
  • Cupix::EventService.publish_eventrescue RestClient::Exception 는 실제 발생하는 예외(Seahorse::Client::NetworkingError, Aws::Kinesis::Errors::ServiceError) 를 커버하지 못한다. 동기 호출을 유지한다면 최소한 rescue Seahorse::Client::NetworkingError, Aws::Kinesis::Errors::ServiceError 를 추가해 log level 을 warn 으로 낮추고 상위로 raise 하지 않는 방향을 검토 (event publish 실패가 API 성공/실패의 하드 게이트인지 도메인 확인 필요).
  • Aws::Kinesis::Client.new 에 명시적 http_open_timeout / retry_limit 을 설정해 request path 에서 최악 15s block 이 튀는 것을 3~5s 수준으로 낮추는 것을 검토.

장기 개선 (재발 방지)#

  • Site-insights, model event 등 "부수 효과" 성격의 외부 pub/sub 은 원칙적으로 request path 에서 분리하고, 실패 시 재시도 가능한 Sidekiq 워커로 위임하는 컨벤션을 문서화. KinesisPublisher 를 표준 경로로 하고 EventService.publish_event 의 직접 SDK 호출을 제거.
  • Kinesis publish 실패에 대한 서비스 레벨 SLO/알림 정책 정의: 예) 5분 rolling 3회 이상일 때만 페이지, 단발은 warn 으로.

Monitoring#

writing-datadog-monitoring-queries skill 규칙에 따라 timeseries 위젯에 그대로 붙여 넣을 수 있는 형식으로 작성.

  • Kinesis put_records 실패 건수 (per-minute count, cupixworks-api):
text
count:logs("service:cupixworks-api @class:Cupix::Aws::Kinesis @function:put_records! status:error").index("*").rollup("count").by("environment")
  • Kinesis TCP timeout 특정 문자열 발생:
text
count:logs("service:cupixworks-api \"kinesis\" \"execution expired\" status:error").index("*").rollup("count")
  • Event publish 실패로 인한 create_event 실패:
text
count:logs("service:cupixworks-api @function:create_event status:error \"Failed to create event\"").index("*").rollup("count").by("class")

알림 방향 (본 위젯 쿼리와 별개, Monitors 에서 threshold 설정): 5분 rolling 에서 첫 쿼리가 3 이상이면 warn, 10 이상이면 alert.

Risk Assessment#

  • Risk level: low — 단발성, 이미 resolved, 사용자 체감 영향 최소(단일 요청, 이벤트 telemetry 손실 1건).
  • 예상 복잡도: trivial (단기 개선까지 반영 시 standard — worker path 이관 및 rescue clause 조정 포함).