AWS

Sharp ‘Input Buffer is empty’ 1,554건의 원인

이미지 프록시에서 하루 1,554건 발생한 Sharp 오류를 빈 S3 원본과 접근 거부로 분리했다. 입력 길이 검사, 최소 권한과 배포 버전 확인으로 수정했다.

이 글의 목차이미지 프록시는 원본과 화면 사이의 변환소다9
  1. 이미지 프록시는 원본과 화면 사이의 변환소다
  2. 이미지 변환보다 한 단계 앞에서 실패하고 있었다
  3. 오류 문구 하나를 두 갈래로 나눴다
  4. 실제로 바꾼 것은 Sharp 앞의 경계였다
  5. 배포한 버전과 보고 있던 코드를 먼저 맞췄다
  6. 조치 후 확인한 결과
  7. 이미지 프록시 오류를 볼 때 다시 쓰는 점검 순서
  8. 결론: 이미지 라이브러리를 고치기 전에 입력 경계를 고쳤다
  9. 참고 자료

운영 로그를 살펴보다가 이미지 프록시에서 Input Buffer is empty라는 오류가 하루 1,554건 발생하는 것을 발견했다.

이미지 프록시는 원본 이미지를 가져와 화면에 맞는 크기와 형식으로 바꿔 주는 중간 서버다.

이 서비스는 CloudFront 요청에 맞춰 세계 여러 AWS 거점에서 코드를 실행하는 Lambda@Edge와 Node.js 이미지 처리 라이브러리인 Sharp를 사용하고 있었다.

처음에는 Sharp가 일부 이미지 형식을 읽지 못한다고 생각했다.

로그를 더 좁혀 보니 변환할 이미지가 잘못된 것이 아니라, 변환기에 전달할 바이트가 아예 없는 요청과 S3 접근이 거부된 요청이 함께 섞여 있었다.

둘은 화면에서 모두 깨진 이미지로 보일 수 있지만 해결해야 할 위치가 달랐다.

이미지 프록시는 원본과 화면 사이의 변환소다

상품 사진 한 장도 목록에서는 작은 썸네일로, 상세 화면에서는 큰 이미지로, 최신 브라우저에서는 효율적인 형식으로 보여 줄 수 있다.
크기별 파일을 사람이 미리 모두 만들지 않고 요청에 맞춰 변환하는 중간 계층이 이미지 프록시다.

이 서비스에서는 사용자가 CloudFront 주소로 이미지를 요청하면 Lambda@Edge가 S3 원본을 읽고 Sharp로 크기와 형식을 바꿨다.
CloudFront는 여러 지역의 엣지 위치에서 콘텐츠를 전달하는 CDN(Content Delivery Network)이고, Lambda@Edge는 그 요청 흐름에 함수를 연결하는 AWS 기능이다.
변환 결과를 캐시에 남기면 같은 크기의 다음 요청은 원본과 함수를 다시 거치지 않을 수 있다.

이 구조가 언제나 필요한 것은 아니다.
이미지 종류와 크기가 적다면 배포·업로드 시점에 파생 이미지를 미리 만드는 편이 단순하다.
크기 조합이 많고 요청 지역이 넓다면 동적 프록시가 유용하지만, S3 권한, 함수 버전, 캐시 키와 변환 비용을 함께 운영해야 한다.

상황더 단순한 선택
정해진 썸네일 크기 두세 개업로드 때 파생 이미지 생성
사용자 요청 크기가 다양함허용 크기를 제한한 이미지 프록시
원본이 비공개 S3에 있음프록시 실행 역할에 최소 읽기 권한
변환 실패 때 원본도 보여 주면 안 됨404 또는 명시적인 기본 이미지 응답

이미지 변환보다 한 단계 앞에서 실패하고 있었다

정상 흐름은 CloudFront가 요청을 받고, Lambda@Edge가 S3에서 원본 객체를 읽은 뒤, Sharp가 그 바이트를 변환하는 순서였다.

S3는 AWS의 객체 저장소이고, 객체는 버킷 안에 저장한 이미지 파일 하나를 뜻한다.

오류가 난 요청에서는 이 세 단계 중 가운데가 비어 있었다.

원본 키가 오래된 주소를 가리키거나 삭제된 객체를 요청하면 읽을 이미지가 없었다.

객체가 있어도 Lambda@Edge 실행 역할에 해당 경로를 읽을 권한이 없으면 AccessDenied가 발생했다.

AccessDenied는 객체가 비어 있다는 뜻이 아니라, 실행 주체가 그 객체를 읽도록 허용되지 않았다는 뜻이다.
S3 GetObject도 권한 거부를 정상적인 빈 본문으로 반환하지 않고 오류로 끝낸다.
따라서 권한 오류는 catch 경계에서 따로 기록해야 하고, 상위 래퍼가 오류를 삼켜 빈 값으로 바꾸는지도 확인해야 한다.

실제 로그에는 권한 거부와 빈 입력이 함께 있었지만 둘을 같은 원인으로 합치지 않았다.
빈 값이나 길이가 0인 버퍼가 Sharp까지 도달한 요청에서 Input Buffer is empty가 마지막 증상으로 남았다.

버퍼는 파일의 바이트를 메모리에 담아 둔 덩어리다.

봉투가 훼손된 것과 봉투 안에 종이가 없는 것은 다른 문제인데, 당시 코드는 둘 다 인쇄기에 넣어 본 다음에야 실패를 알리는 셈이었다.

CloudFront 요청이 Lambda@Edge를 거쳐 S3 원본을 읽고 Sharp로 변환되는 흐름에서 원본 없음과 권한 거부를 Sharp 호출 전에 나누는 과정
Sharp는 마지막 변환 단계였으므로, 원본 없음과 권한 거부를 앞에서 구분해야 같은 오류 문구 뒤에 숨은 원인을 각각 고칠 수 있었다.

오류 문구 하나를 두 갈래로 나눴다

먼저 실패 요청 한 건이 지나가는 경로를 요청 주소, S3 객체 키, 원본 조회 결과, Sharp 호출 순서로 연결했다.

그 결과 빈 입력과 권한 거부를 같은 예외 처리로 묶으면 안 된다는 판단이 섰다.

원본이 실제로 없거나 길이가 0이면 이미지 변환을 시도하지 않고 404 Not Found 또는 서비스에서 정한 기본 이미지로 끝내야 했다.

404는 요청한 자원을 찾지 못했다는 HTTP 상태 코드다.

반면 권한 거부는 없는 이미지처럼 숨겨 버리기보다, 어떤 실행 역할이 어느 객체 경로에서 거부됐는지를 운영 로그에 남겨야 했다.

그렇지 않으면 주소가 잘못된 사용자 요청과 잘못 배포된 권한 정책이 하나의 404 통계로 합쳐진다.

HTTP 상태도 원인과 맞춰야 한다.
요청한 원본이 실제로 없다면 404 Not Found, 프록시 내부 권한이나 설정 때문에 원본을 읽지 못했다면 서비스 내부 오류로 취급하는 편이 운영상 구분하기 쉽다.
다만 비공개 객체의 존재 여부를 외부에 노출하지 않도록 클라이언트 응답은 일반화할 수 있다.
외부 응답을 같게 하더라도 내부 로그와 지표에서는 원본 미존재와 권한 거부를 분리해야 한다.

선택지는 세 가지였다.

  1. Sharp의 오류 허용 옵션을 느슨하게 바꿔 계속 변환을 시도한다.
  2. 모든 S3 객체를 넓게 읽을 수 있도록 권한을 열어 둔다.
  3. 입력을 변환 전에 검사하고, 필요한 객체 경로에만 읽기 권한을 준다.

첫 번째 방법은 손상된 픽셀 데이터에 대한 처리 강도를 바꿀 수는 있어도 존재하지 않는 입력을 이미지로 만들어 주지 않는다.

Sharp 생성자 문서failOn은 잘못된 픽셀 데이터에서 어느 수준의 경고까지 처리를 중단할지 정하는 옵션이다.

빈 입력을 정상 이미지로 바꾸는 옵션은 아니므로 이번 문제의 주된 해결책으로 삼지 않았다.

두 번째 방법은 당장 AccessDenied를 줄일 수 있지만 이미지 프록시가 필요하지 않은 객체까지 읽을 수 있게 만든다.

AWS는 Lambda@Edge 코드가 S3 같은 다른 AWS 리소스를 읽는다면 실행 역할에 그 작업을 수행할 권한이 필요하다고 설명한다.

Lambda@Edge 권한 문서를 기준으로 대상 객체 경로에 필요한 읽기 권한만 보완했다.

실제로 바꾼 것은 Sharp 앞의 경계였다

공개할 수 없는 버킷 이름과 객체 경로는 빼고 핵심 흐름만 단순화하면 다음과 같다.

image-proxy.tsTypeScript단순화 예시
// 내부 래퍼가 AWS SDK의 Body 스트림을 Buffer로 읽어 반환한다.
const object = await storage.getObjectAsBuffer({ bucket, key });
const body: Buffer | undefined = object.body;

if (!body || body.byteLength === 0) {
  return notFoundImageResponse();
}

return sharp(body, { failOn: 'warning' })
  .resize(resizeOptions)
  .jpeg({ quality: 80 })
  .toBuffer();

여기서 object.body는 AWS SDK가 돌려주는 원형 스트림이 아니다.

storage.getObjectAsBuffer()라는 내부 래퍼가 응답 본문을 모두 읽어 Node.js Buffer로 정규화했다는 계약을 단순화해 표현했다.

AWS SDK 응답을 직접 사용한다면 Body를 먼저 바이트 배열이나 Buffer로 변환한 뒤 같은 검사를 해야 한다.

byteLength === 0은 메모리에 담긴 입력 바이트가 하나도 없는지 검사한다.

이 조건을 통과하지 못하면 반드시 return해 아래의 sharp()가 실행되지 않게 했다.

quality: 80은 JPEG 인코더에 전달하는 품질 값이며, 화면 요구사항과 원본 특성에 따라 달라질 수 있는 작업 당시의 선택이다.

Sharp의 toBuffer() 문서는 변환 결과를 Node.js 버퍼로 돌려주는 동작을 설명한다.

입력 검증과 별개로 S3 읽기 권한도 대상 리소스 범위에 맞게 보완했다.

일반 객체를 읽는 작업에는 s3:GetObject가 필요하고, 특정 버전을 직접 읽는다면 s3:GetObjectVersion이 필요할 수 있다.

암호화에 고객 관리형 KMS 키를 사용한 객체라면 KMS 복호화 권한도 별도로 확인해야 한다는 내용은 S3 GetObject 공식 문서에서 확인할 수 있다.

권한을 추가하는 이유는 “이미지 프록시니까 S3 전체를 읽어야 한다”가 아니라, 실행 역할이 정해진 원본 경로에서 변환에 필요한 객체만 읽어야 하기 때문이다.

클라이언트가 이미 삭제된 이미지 주소를 계속 보내는지도 함께 확인했다.

서버에서 매번 기본 이미지를 돌려주는 것만으로 끝내면 잘못된 URL을 만드는 원인은 남기 때문이다.

배포한 버전과 보고 있던 코드를 먼저 맞췄다

Lambda@Edge는 수정한 소스 파일을 저장했다고 곧바로 전 세계 요청이 새 코드로 처리되는 구조가 아니다.

CloudFront에 연결할 함수 버전을 발행하고 그 버전을 엣지 위치로 복제하는 과정이 있다.

AWS의 Lambda@Edge 함수 편집 문서도 새 코드를 적용할 때 버전을 발행하고 트리거를 연결하도록 안내한다.

그래서 로컬에서 고친 코드, 발행한 함수 버전, CloudFront에 실제 연결된 버전을 나란히 확인했다.

검토한 소스와 실행 중인 버전이 다르면 올바른 수정도 운영 오류를 바꾸지 못한다.

배포 전에는 함수 단독 시험과 CloudFront를 통한 통합 시험을 분리했다.

빈 객체, 존재하지 않는 원본, 정상 이미지, 권한이 없는 원본을 각각 요청해 의도한 경로로 끝나는지 확인했다.

정상 이미지는 변환 결과를 돌려줘야 하고, 빈 입력과 원본 미존재는 Sharp를 호출하지 않아야 하며, 권한 오류는 운영자가 구분할 수 있어야 했다.

AWS도 Lambda@Edge 시험 및 디버깅 문서에서 함수 단독 시험과 CloudFront 통합 시험을 모두 권장한다.

Lambda@Edge 로그는 함수가 실행된 사용자 인접 리전에 기록될 수 있어 한 리전의 로그만 보고 오류가 없다고 결론내리지 않았다.

조치 후 확인한 결과

확인된 결과는 빈 입력에서 Sharp 호출을 중단하는 경로와 필요한 S3 읽기 권한을 보완했다는 것이다.

원본이 없는 요청과 권한이 잘못된 요청도 서로 다른 원인으로 추적할 수 있게 됐다.

하루 1,554건은 조사를 시작하게 만든 조치 전 오류 건수다.

조치 이후 장기간의 일별 오류 수는 남아 있지 않아 감소율을 계산하지 않았고, 빈 입력·원본 미존재·권한 거부·정상 이미지에 대한 회귀 시험 통과를 확인 결과로 삼았다.

같은 문제가 다시 생기면 다음 항목을 함께 기록해야 개선 효과까지 설명할 수 있다.

  • 조치 전후 같은 요일과 시간대의 Input Buffer is empty 건수
  • 원본 미존재, 0바이트 객체, 권한 거부를 나눈 건수
  • 정상 이미지 변환의 성공률과 처리 시간
  • 기본 이미지 또는 404 응답 비율
  • 잘못된 이미지 URL을 만든 상위 호출 경로

이미지 프록시 오류를 볼 때 다시 쓰는 점검 순서

  1. 실패 요청의 CloudFront 요청 식별자와 원본 이미지 주소를 확보한다.
  2. 요청 주소가 실제 S3 객체 키로 어떻게 바뀌는지 확인한다.
  3. 객체 미존재, 0바이트 객체, 권한 거부를 별도 상태로 기록한다.
  4. 입력 길이를 검사한 뒤에만 Sharp를 호출한다.
  5. 실행 역할에는 필요한 버킷과 객체 접두사 범위의 읽기 권한만 부여한다.
  6. 로컬 소스, 발행한 Lambda 버전, CloudFront에 연결된 버전을 대조한다.
  7. 함수 단독 시험 뒤 CloudFront 경로의 통합 시험을 실행한다.
  8. 여러 실행 리전의 로그와 조치 전후 오류율을 같은 조건으로 비교한다.

업로드 단계에서 파일을 믿지 않고 검사하는 방법은 S3 업로드를 격리·검사·게시로 나누는 설계에도 정리해 두었다.

결론: 이미지 라이브러리를 고치기 전에 입력 경계를 고쳤다

이번 오류의 마지막 줄에는 Sharp가 보였지만, 원인은 Sharp보다 앞에 있었다.

없는 원본과 권한이 없는 원본을 구분하지 않은 채 변환기를 호출하고 있었다.

입력이 없으면 즉시 끝내고, 권한은 필요한 객체 경로에만 주며, 배포한 엣지 버전까지 확인하는 순서로 문제를 닫았다.

이미지 처리 오류가 반복될 때는 변환 옵션부터 바꾸기보다 변환기에 실제로 어떤 바이트가 도착했는지부터 확인하는 편이 빠르다.

참고 자료

소신을 상징하는 고래 일러스트
작성자소신 · Soshin

직접 부딪힌 문제와 해결 과정을 다음에 다시 꺼내 볼 수 있도록 기록합니다.