ES /docs

Rack::Multipart::MultipartPartLimitError: Too many open files - Maximum file multiparts in content reached

RCA: Rack::Multipart::MultipartPartLimitError - Too many open files

Overview#

What Happened#

cupixworks-api (tesla) 에서 Rack::Multipart::MultipartPartLimitError: Too many open files - Maximum file multiparts in content reached 가 발생했다. 이는 하나의 multipart/form-data 요청에 Rack 의 파일 part 한도(기본 128)를 초과하는 file part 가 담겨 있을 때 Rack 이 body 파싱 단계에서 raise 하는 예외다. tesla 는 실제 파일 업로드를 모두 S3 presigned URL 로 처리하므로 정상 애플리케이션 흐름에서 128개 이상의 file part 를 전송하는 경로가 없다.

Revision 1 재조사에서 APM error span 을 확인한 결과, ET 가 ~11개월 에 걸쳐 31건으로 집계한 이 클러스터는 실제로는 2026-08-05 07:33:46–07:34:12Z (~26초) 사이 단 한 번의 burst였고, 전부 동일 엔드포인트 PUT /api/v1/captures/747735?fields (route /api/v1/captures/:idApi::V1::CapturesController#update) 에서 발생했다. 즉 "산발적 스캐너" 가 아니라 하나의 client 가 하나의 capture 에 대해 짧은 시간 안에 반복 요청한 것이다. 이 예외는 unhandled 로 표면화되는 것이 아니라 Rails framework 미들웨어(ActionDispatch::ShowExceptions)가 잡아 client 에게 HTTP 500 (text/plain) 을 반환한다.

Quick Facts#

Field Value
exception.class Rack::Multipart::MultipartPartLimitError
exception.message Too many open files - Maximum file multiparts in content reached
exception.parent Errno::EMFILE < SystemCallError < StandardError
top_frame rack/multipart/parser.rb (Rack 2.2.13, gem 내부)
runtime Ruby on Rails 7.2.2 (tesla), Rack 2.2.13
env production (APM span 31/31 env:production)
client_response HTTP 500 (text/plain), framework ActionDispatch::ShowExceptions 매핑
endpoint PUT /api/v1/captures/:id (Api::V1::CapturesController#update)

Affected Teams#

에러가 Rack multipart body 파싱 단계에서 발생해 request-info 로그에는 남지 않지만, APM error span 에는 남는다 (Datadog Ruby tracer error-tracking 이 span 을 집계). span 에 usr/team.domain/user-agent/client.ip 는 태깅되지 않았다 — 요청이 인증/유저 resolution 이전 param 파싱 단계에서 실패했기 때문이다. 확인 가능한 범위: env production, capture id 747735, endpoint PUT /api/v1/captures/:id. 정황상(직후 capture 747735 의 reconstruction_state processing→done 전이 로그) 3D reconstruction/processing 계열 클라이언트가 유력하나, span 에 UA 가 없어 정확한 유저/에이전트 특정은 불가하다.

Timeline#

  1. 2025-08-31 14:50 KST — ET first_seen (first_seen)
  2. 2026-08-05 16:33:46–16:34:12 KST — 실제 발생 burst (31/31 span, ~26초), 전부 PUT /api/v1/captures/747735
  3. 2026-08-05 16:34:13 KST — capture 747735 reconstruction_state processing→done 전이 로그
  4. 2026-08-05 — RCA 수행 / Revision 1

Error Log#

Datadog Logs

text
Too many open files - Maximum file multiparts in content reached

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 31
  • 최초 발생: 2025-08-31 14:50 KST
  • 최근 발생: 2026-08-05 16:34 KST

Root Cause Summary#

Rack::Multipart::MultipartPartLimitError 는 tesla 코드가 raise 하는 예외가 아니라 Rack 2.2.13 의 multipart body parser 가 파일 part(파일명이 있는 part) 개수가 Rack::Utils.multipart_file_limit(기본 128) 를 초과할 때 raise 하는 예외다. tesla 는 이 한도를 재정의하지 않으며(config/RACK_MULTIPART_PART_LIMIT/multipart_part_limit 설정 없음, 기본 128 적용), 실제 파일 업로드는 전부 S3 presigned URL 방식으로 처리하므로 애플리케이션이 정상 흐름에서 128개 이상의 file part 를 담은 multipart body 를 기대하는 엔드포인트가 없다. 실제 발생 endpoint 인 PUT /api/v1/captures/:id (Api::V1::CapturesController#update) 역시 파일 업로드 param 을 받지 않는다 (app/concerns/parameter/capture.rbUploadedFile/tempfile/thumbnails 등 파일 수신 없음). 따라서 이 에러는 JSON 엔드포인트에 비정상적으로 많은 file part 를 담은 multipart/form-data body 를 보낸 클라이언트 요청이 원인이며, tesla 코드의 결함이 아니다.

client-facing 결과 (Revision 1 정정): 이 예외는 컨트롤러의 rescue_from(app-level ServerErrorController/ClientErrorController) scope 밖에서 발생하지만, "unhandled" 로 표면화되는 것은 아니다. multipart 파싱은 lazy 하게 컨트롤러 dispatch 안에서(로그 param 필터링 filtered_parameters 시점) 트리거되므로 framework 미들웨어 ActionDispatch::ShowExceptions 가 이를 catch 한다. 그러나 MultipartPartLimitError 는 Rails 의 rescue_responses 매핑 테이블에 없어 default :internal_server_error 로 매핑된다 → client 는 HTTP 500 (text/plain) 을 받는다. 즉 유일한 "결함" 은 클라이언트 입력 오류가 4xx 대신 500 으로 매핑되는 상태-코드 매핑 문제다.

Technical Analysis#

Code Path#

  • Entry point: Rack multipart body parser (gem 내부) — tesla 애플리케이션 코드 진입 이전
  • 예외 정의 (Rack 2.2.13):
sorbet/rbi/gems/rack@2.2.13.rbi:2315ruby
# source://rack//lib/rack/multipart/parser.rb#7
class Rack::Multipart::MultipartPartLimitError < ::Errno::EMFILE; end

Rack::Multipart::Parser 는 file part 를 하나 열 때마다 카운트를 증가시키고, Rack::Utils.multipart_file_limit(기본 128) 를 넘으면 위 예외를 raise 한다. tesla 는 이 한도를 재정의하지 않는다 — config/ 내 관련 설정 grep 결과가 boot.rb 의 무관한 라인 하나뿐이다.

grep RACK_MULTIPART_PART_LIMIT|multipart|EMFILE in tesla/configtext
config/boot.rb:1:ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
  • tesla 는 파일 업로드를 Rack multipart 로 받지 않고 S3 presigned URL 로 처리한다. app/controllers 전역에서 Rails-side multipart 업로드 핸들러(ActionDispatch::Http::UploadedFile, params[:file], .original_filename, .tempfile)가 존재하지 않는다.
grep UploadedFile|tempfile|.original_filename in tesla/apptext
app/services/cupix/user_export_service.rb  # 서버가 생성하는 CSV Tempfile (업로드 수신과 무관)
  • Failure point (매핑 관점): Rack::Multipart::MultipartPartLimitError < Errno::EMFILE < SystemCallError < StandardErrorRuntimeError 계열이 아니고, 어떤 Cupix 예외 클래스도 아니다. 따라서 아래 app-level 컨트롤러 rescue 목록 어디에도 걸리지 않는다.
app/controllers/concerns/server_error_controller.rb:18-20ruby
rescue_from ActiveRecord::LockWaitTimeout,
            Errno::ENOMEM,
            RuntimeError, with: :badgateway_on_system_502_error
app/controllers/concerns/client_error_controller.rb:7-14ruby
rescue_from Cupix::Errors::Unknown,
            Cupix::Errors::Resource,
            Cupix::Errors::Session,
            Cupix::Errors::Parameter,
            Cupix::Errors::Entity,
            Cupix::Errors::Billing,
            Cupix::Errors::InvalidState,
            Cupix::Errors::Siteinsights, with: :client_400_error
  • client-facing 매핑: 실제 스택(APM span)은 이 예외가 컨트롤러 dispatch 안에서 로그 param 필터링(instrumentation.rb:67 filtered_parametersparse_multipartcheck_part_limits) 시 트리거되어 framework 미들웨어까지 전파됨을 보여준다. ActionDispatch::ShowExceptions (framework) 가 이를 잡아 ActionDispatch::ExceptionWrapper 로 status 를 결정한다:
actionpack-7.2.2/lib/action_dispatch/middleware/exception_wrapper.rb:12ruby
cattr_accessor :rescue_responses, default: Hash.new(:internal_server_error).merge!(
  "ActionController::RoutingError"                     => :not_found,
  ...
  "Rack::QueryParser::InvalidParameterError"           => :bad_request
)
actionpack-7.2.2/lib/action_dispatch/middleware/exception_wrapper.rb:175-177ruby
def self.status_code_for_exception(class_name)
  Rack::Utils.status_code(@@rescue_responses[class_name])
end

@@rescue_responsesHash.new(:internal_server_error) 이고 "Rack::Multipart::MultipartPartLimitError" 는 key 로 등록되어 있지 않으며 lookup 은 exact class name 만 사용(ancestor walk 없음, :195 @@rescue_responses.key?(exception.class.name)) → default :internal_server_errorHTTP 500. 이후 ActionDispatch::PublicExceptions 가 응답 body 를 렌더한다(public/500.html 부재 시 content-negotiation 에 따라 text/plain/JSON). 실측 span response content-type = text/plain; charset=utf-8, status_code = 500.

  • 기대 동작 vs 실제 동작: 정상 클라이언트는 captures#update 에 JSON body 를 보낸다. 실제로는 이 endpoint 에 128개를 초과하는 file part 를 담은 multipart/form-data body 가 도달하여 Rack 이 파싱 중단·raise → framework 가 500 으로 매핑 → Error Tracking 에 집계된다.

Log Evidence#

이 예외는 애플리케이션 로거를 거치지 않으므로 Datadog 로그(request-info 포함)에는 남지 않는다. 아래 로그 쿼리는 모두 0건 (retention now-14d):

text
service:cupixworks-api "Maximum file multiparts"     → 0 logs
service:cupixworks-api "MultipartPartLimitError"     → 0 logs
service:cupixworks-api "multiparts"                  → 0 logs
"Maximum file multiparts in content reached"         → 0 logs (전 서비스)

정정 (Revision 1): APM error span 에는 남는다. 이전 revision 은 span 도 0건이라 기재했으나, keyword span 검색(status:error "Maximum file multiparts")만 0이었고, operation_name:rack.request status:error 로 error span 을 가져와 attributes.error.type 로 in-memory 필터하면 정확히 31 span 이 나온다. (@error.type:Rack::... 직접 필터는 콜론 때문에 span query 파서가 400 을 반환하여 사용 불가 — 그래서 broad rack.request error span 을 받아 필터해야 한다.)

text
service:cupixworks-api operation_name:rack.request status:error   → 2493 spans
  → attributes.error.type == "Rack::Multipart::MultipartPartLimitError" 필터  → 31 spans

31개 span 의 공통 필드:

text
error.type:      Rack::Multipart::MultipartPartLimitError
error.file:      .../gems/rack-2.2.13/lib/rack/multipart/parser.rb
error.message:   Too many open files - Maximum file multiparts in content reached
http.method:     PUT
http.route:      /api/v1/captures/:id       (resource: Api::V1::CapturesController#update)
http.url:        /api/v1/captures/747735?fields   (31/31 동일 capture)
http.status_code: 500                        (31/31)
response ct:     text/plain; charset=utf-8
env:             production                   (31/31)
시간 분포:        2026-08-05T07:33:46Z ~ 07:34:12Z (~26초, 단일 burst)
hosts:           3개 EC2 (i-0ce58f398e65c7a10, i-0a026d499fb305739, i-09eb846280d67ac02)
usr / team.domain / user-agent / client.ip:  span 에 미태깅 (param 파싱 실패가 인증 이전)

정황 로그 — burst 직후 동일 capture 상태 전이:

text
service:cupixworks-api "747735"  (2026-08-05T07:33:30Z~07:35:00Z)
→ "reconstruction_state has transitioned from processing to done on Capture 747735"
   (2026-08-05T07:34:13.439Z, class_name StateMachines::Machine)

즉 capture 747735 는 burst 시점에 3D reconstruction 처리 중이었고, 요청 client 는 processing/reconstruction 계열이 유력하다(단정 불가 — span 에 UA 없음). ET 가 first_seen 2025-08-31 로 표기하나 실제 span 발생은 2026-08-05 단일 burst 로, ET occurrence 집계(31)와 span 수(31)가 정확히 일치한다. Representative message 는 Rack 의 고정 상수 문자열 로 변형이 없어 신뢰 가능하며 stale 이 아니다.

status-board 결과: scope svc:cupixworks-api::unknown, active: null. 최근 resolved 인시던트(2026-07-29, 2026-07-30)는 last_seen 이전이고 이 클러스터의 cluster_ids 에 포함되지 않아 무관하다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 128개 초과 file part 를 담은 비정상/malformed multipart 요청이 Rack parser 한도를 초과, 컨트롤러 진입 전 middleware 에서 raise → tesla rescue 밖 → unhandled. 코드 결함 아님(noise) 예외가 Rack::Multipart::MultipartPartLimitError < Errno::EMFILE(gem 내부, rack@2.2.13.rbi:2315); tesla 는 한도 미재정의(config grep 무관 1건); tesla 는 업로드를 전부 S3 presigned URL 로 처리(create_upload_credentials/*_upload_url), Rails-side multipart 핸들러 부재; 저빈도 31건/11개월 Confirmed
H2 tesla 의 정상 업로드 엔드포인트가 다수의 file part 를 받다가 한도 초과 app/controllers 전역에 multipart 파일 수신 핸들러(ActionDispatch::Http::UploadedFile/params[:file]/.tempfile) 없음; 업로드는 presigned URL 로 S3 직접 전송 Rejected
H3 tesla 코드가 이 예외를 직접 raise grep 결과 raise site 없음; 클래스는 Rack gem 및 sorbet RBI 에만 존재 Rejected
H4 서버 파일 디스크립터 고갈(진짜 EMFILE, 시스템 리소스 문제) 부모 클래스가 Errno::EMFILE 메시지가 EMFILE generic("Too many open files") 이 아니라 Rack 의 multipart-specific suffix("- Maximum file multiparts in content reached") 를 포함 → Rack 한도 초과가 원인이지 실제 OS FD 고갈 아님 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

코드 결함이 아니므로 긴급 코드 수정은 불필요. 이 클러스터는 Error Tracking 에서 IGNORE 처리를 권장한다.

단기 개선 (1주 이내)#

  • 상태-코드 매핑 개선(선택): 이 예외가 unhandled 로 표면화되지 않도록, Rack middleware 계층에서 잡아 4xx(예: 400 Bad Request)로 매핑하는 것을 검토한다. 컨트롤러 rescue_from 은 middleware 예외를 잡을 수 없으므로 컨트롤러가 아니라 Rack middleware(예: config.ru/미들웨어 스택에 Rack::Multipart::MultipartPartLimitError/Errno::EMFILE 를 rescue 하는 얇은 middleware 삽입) 계층에서 처리해야 한다. 프런트/외부 API 계약(응답 코드)에 영향을 주므로 조율 필요.
  • 관련 선례(malformed client input → 500 mis-map)와 동일 계열: a21860c9(NoMethodError to_i for ActionController::Parameters), fbb4cb37(RedTail scanner invalid byte sequence). 동일한 "요청이 4xx 로 매핑되지 못함" 매핑 개선 방향.

장기 개선 (재발 방지)#

  • 엣지/WAF 계층 방어: multipart file part 수가 비정상적으로 많은 요청을 애플리케이션 도달 전에 거부(WAF rule 또는 reverse proxy body/part 제한). tesla 는 파일 업로드를 S3 presigned URL 로 처리하므로 API 로 오는 대량 multipart 요청은 정상 트래픽이 아니다.
  • 필요 시 Rack::Utils.multipart_file_limit 를 명시적으로 설정(현재 기본 128)하여 의도를 코드로 문서화.

Monitoring#

Error Tracking issue 를 추적 대상으로 유지하고, 발생 급증 시 알림. 이 예외는 로그/span 에 남지 않으므로 timeseries 는 요청 계층(4xx/5xx 카운트) 근사치로만 볼 수 있다.

text
sum:trace.rack.request.errors{service:cupixworks-api}.as_count()
text
sum:trace.rack.request.hits{service:cupixworks-api,http.status_code:400}.as_count()

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial (코드 변경 불필요; 선택적 매핑 개선은 standard, 프런트 계약 조율 필요)

Noise Verdict#

noise — Rack parser 가 128개 초과 file part 를 담은 비정상 multipart 요청에 대해 raise 하는 예외로, tesla 는 업로드를 전부 S3 presigned URL 로 처리하여 정상 흐름에 이 경로가 없고 코드 결함이 아니므로 noise 다.