작은 파일 업로드가 528 MiB를 쓰던 이유

읽는 데 14분
목차

9월 28일, 개발 환경의 파일 업로드 요청이 502로 끝났다. Ingress가 기록한 요청 길이는 약 16 KiB였다. 그런데 같은 시각 요청을 처리하던 Pod는 메모리 부족으로 종료됐다. 컨테이너의 메모리 한도는 1 GiB였다.

파일은 io.Reader로 스트리밍하고 있었다. 파일 전체를 메모리에 올리지 않으니 사용량도 작을 것이라 생각하기 쉽다. 하지만 저장소 SDK 안쪽에서는 업로드 한 번에 528 MiB짜리 버퍼를 먼저 만들고 있었다.

작은 요청에서 시작한 OOM 조사

먼저 502와 Pod 종료가 같은 사건인지 확인했다. Ingress의 upstream 주소가 종료된 Pod와 일치했고, 응답 헤더를 읽던 중 연결이 끊긴 시각도 Kubernetes의 OOMKilled 시각과 맞았다.

확인한 근거관측값여기서 알 수 있는 것
Ingress 요청 길이약 16 KiB오류가 난 요청 자체는 작았다
Pod 종료 상태OOMKilled, exit code 137메모리 부족으로 프로세스가 종료됐다
종료 전 RSS 관측 구간약 48 → 585 MiB약 537 MiB의 증가가 있었다
컨테이너 메모리 한도1 GiB큰 할당이 겹치면 한도를 압박한다

메모리 지표는 15초 간격이었다. 종료 순간의 최고 사용량이나 당시 동시 요청 수까지 보여 주지는 않는다. 작은 요청이 실패했다는 사실만으로 그 요청 하나가 OOM을 일으켰다고 단정할 수도 없다. 다음으로 실제 할당 경로를 확인했다.

PartSize를 생략하면 SDK가 정한다

문제가 된 경로는 파일 크기를 모르는 스트림을 size=-1로 전달했다. PutObjectOptions에는 PartSize를 지정하지 않아 기본값 0이 들어갔다.

파일 크기 미상: size=-1
  → PartSize 생략: 0
  → OptimalPartInfo(-1, 0)
  → part 크기 553,648,128 bytes 계산
  → 파일을 읽기 전에 528 MiB 버퍼 할당

사용하던 minio-go v7.0.97의 크기 미상 multipart 경로는 이렇게 계산한 크기로 버퍼를 만든다. 0은 버퍼를 쓰지 않는다는 뜻이 아니다. SDK가 part 크기를 고르도록 맡기는 값이다.

part 크기 계산과 크기 미상 업로드 구현을 함께 확인했다.

해당 버전의 OptimalPartInfo를 로컬에서 호출해 528 MiB가 나오는 것을 확인했다. 버퍼 두 개가 겹치면 528 × 2 = 1,056 MiB다. 애플리케이션의 다른 메모리를 더하기 전부터 1 GiB를 넘는다.

이 할당 크기는 관측한 RSS 증가와도 부합한다. 다만 종료 순간의 heap profile은 없으므로, 정확히 두 요청이 겹쳐 종료됐다고 확정한 것은 아니다. 큰 버퍼를 만드는 코드 경로는 확인했고, OOM의 유력한 원인으로 좁혔다.

그림 크게 보기: SDK의 업로드 버퍼 할당 위치

수정은 버퍼 크기와 동시 처리 수를 함께 제한했다

MinIO 클라이언트의 업로드 옵션에 기본 part 크기 5 MiB를 명시했다. 서버의 디스크 캐시 설정을 바꾼 것이 아니라, 애플리케이션 안에서 SDK가 할당할 전송 버퍼를 조정한 것이다.

일반 파일과 크기 미상 스트림에는 작은 part를 사용하고, 크기가 알려진 대용량 파일은 필요한 part 수를 계산해 별도로 다뤘다. 지원 파일 크기를 유지하는 조건은 뒤에서 설명한다.

버퍼를 줄여도 동시 요청은 남는다

애플리케이션이 파일 전체를 보관하는 경로를 단순화하면 메모리 사용량은 동시 요청 수 × 파일 크기에 비례한다. 순차 스트리밍으로 바꾸면 그중 전송 버퍼 항은 동시 요청 수 × 버퍼 크기로 바뀐다.

하지만 이것을 프로세스 전체 메모리 공식으로 쓰면 틀린다. 요청 본문 파싱, Base64 디코딩, SDK의 병렬 전송, TLS, 임시 객체와 GC가 별도로 메모리를 쓴다. 한 요청 안에 살아 있는 전송 버퍼 수를 K, 처리 요청 수를 C, 버퍼 크기를 B라고 하면 해당 항은 대략 C × K × B다. 다른 할당은 따로 더해야 한다.

본문을 읽기 전에 처리 자리를 확보한다

그래서 전송 조각만 줄이는 것으로 끝내지 않았다. 일반 업로드와 JSON/Base64 업로드가 같은 프로세스 내 대기열을 거치도록 연결했다. 파일 형식은 달라도 메모리 예산은 함께 쓰기 때문이다.

인증·권한 검사
  → 대기 자리 확보
  → 처리 자리 확보
  → 본문 읽기·디코딩·저장
  → 처리 자리와 대기 자리 반환

본문을 읽고 나서 세마포어를 기다리면, 기다리는 요청도 이미 큰 객체를 들고 있다. 이번 수정은 읽기보다 앞에서 처리 자리를 확보하도록 순서를 옮겼다. 거절 응답 뒤에 실행되는 활동 기록 미들웨어도 확인했다. 그 코드가 JSON 본문을 뒤늦게 읽으면 앞단의 제한을 우회하기 때문이다.

대기열은 처리량을 만들어 내지 않는다

처리 슬롯과 대기 슬롯을 나눴다. 처리 슬롯은 비싼 작업의 동시 실행 수를 제한하고, 대기 슬롯은 연결과 요청 상태가 무한히 쌓이지 않도록 제한한다. 대기 시간이 끝나거나 큐가 차면 거절하고, 취소된 요청은 자리를 돌려준다.

처리 자리를 늘리는 결정에는 비용이 있다. 파일당 메모리와 저장소 부하가 함께 늘 수 있다. 대기 자리를 늘리는 결정에도 비용이 있다. 사용자가 기다리는 시간과 연결 유지 비용이 늘어난다.

예를 들어 처리 슬롯이 10개이고 요청 한 건이 평균 2초를 점유한다고 가정하면, 모든 슬롯이 계속 일하고 다른 병목이 없을 때 처리량은 약 5건/초다. 대기열을 100개에서 1,000개로 늘려도 이 처리량이 10배가 되지는 않는다. 이는 계산 예시이며 실제 부하 측정값은 아니다.

Little의 법칙은 안정된 시스템의 평균값을 L = λW로 연결한다. L은 시스템 안에 머무는 평균 요청 수, λ는 수용된 요청의 평균 유입률, W는 평균 체류 시간이다. 대기열만 계산할 때는 대기 중인 수와 대기 시간을 짝지어야 한다. 최대 큐 길이를 평균 L로 대입하거나 거절된 요청까지 유입률에 섞으면 다른 값을 계산하게 된다. MIT의 대기행렬 강의

프로세스 안에서 본문을 읽지 않는 것과 클라이언트가 전송하지 않는 것도 다르다. 프록시나 TCP에는 별도 버퍼가 있고, 요청 버퍼링이 켜진 프록시는 애플리케이션보다 먼저 파일을 받을 수 있다. 대기 시간은 프록시와 클라이언트의 timeout 안에서도 성립해야 한다.

작은 조각이 파일을 잘라 버릴 수도 있다

조각 크기를 줄이면서 데이터 무결성 문제도 확인했다. 사용한 minio-go v7.0.97의 크기 미상 multipart 경로에서는 허용한 part 수를 다 채운 뒤 추가 입력을 확인하지 않고 완료할 수 있었다. 조각이 5 MiB이고 최대 10,000개라면 검사 경계는 50,000 MiB다. 해당 버전의 SDK 구현

여기서 io.LimitReader를 붙이면 제한 밖의 데이터는 보이지 않는다. 이 함수는 지정한 길이 뒤에 EOF를 반환한다. 읽는 쪽은 원본이 정확히 끝난 것인지, 더 긴 파일을 잘라 읽은 것인지 구별할 수 없다. Go io 문서

다음은 그 차이만 확인하는 실행 예제다. 실제 업로드 구현은 아니다.

package main

import (
    "fmt"
    "io"
    "strings"
)

func main() {
    data, err := io.ReadAll(io.LimitReader(strings.NewReader("abcdef"), 5))
    if string(data) != "abcde" || err != nil {
        panic("unexpected LimitReader behavior")
    }
    fmt.Printf("data=%q err=%v\n", data, err)
}

출력은 data="abcde" err=<nil>이다. 오류 없이 읽었다는 사실이 원본 전체를 읽었다는 뜻은 아니다.

수정한 reader는 마지막 허용 바이트를 SDK에 넘기기 전에 추가 바이트가 있는지 확인한다. 초과하면 오류를 반환해 multipart 완료를 막는다. 일반적인 크기 검사는 N+1바이트를 읽어 초과를 판별하지만, 이 경로에서는 SDK가 마지막 part를 채우자마자 완료할 수 있어 검사 시점도 앞당겨야 했다. 원본을 더 읽는 과정에서 난 오류 역시 숨기지 않는다.

크기를 미리 아는 큰 파일은 별도로 다뤘다. 작은 조각 10,000개를 넘는 파일에는 SDK가 계산한 큰 조각을 사용해 기존 대용량 지원을 유지했다. 이 경로까지 5 MiB 버퍼 상한을 보장한다고 설명하면 안 된다.

테스트가 확인한 것

저장소의 회귀 검사는 실제 SDK와 로컬 HTTP 대역으로 전체 바이트 보존, 조각 분할, 초과 시 multipart 중단, 조건부 완료 헤더를 확인한다. 요청 대기 검사는 처리 슬롯이 찼을 때 다음 요청의 본문을 읽지 않는지, 취소·포화·시간 만료 뒤 자리를 반환하는지 다룬다. 기존 검증 문서는 이 검사와 로컬 저장소 검사가 통과했다고 기록한다.

글을 작성하며 OptimalPartInfo의 버퍼 크기 계산과 위 LimitReader 예제를 확인했다. 과거 서비스 테스트 전체를 다시 실행하거나 운영 파일 부하를 재현한 것은 아니다. 작은 조각으로 바꾼 뒤 RSS가 얼마나 줄었는지, 어느 동시 처리 수에서 처리량이 꺾이는지는 별도 측정 대상이다.

다른 업로드 API에도 적용해 보려면 가장 먼저 파일을 읽는 줄을 찾아보면 된다. 그 줄보다 앞에 처리 제한이 있는지 확인하고, 대기 중인 요청의 본문 reader가 호출되면 실패하는 테스트를 넣는다. 처음에는 제한이 실제 할당보다 먼저 작동하는지 확인한다. 그다음 세마포어 개수를 조정한다.