Skip to content

feat: 사용자별 컴퓨트·스토리지 리소스 한도 및 이중 삭제 보호#65

Open
qixiangme wants to merge 6 commits into
mainfrom
feat/chang/per-user-resource-quota
Open

feat: 사용자별 컴퓨트·스토리지 리소스 한도 및 이중 삭제 보호#65
qixiangme wants to merge 6 commits into
mainfrom
feat/chang/per-user-resource-quota

Conversation

@qixiangme

Copy link
Copy Markdown
Contributor

개요

사용자별 컴퓨트(VM)·스토리지 리소스 상한을 도입하고,
이중 삭제(double-delete) 경쟁 조건에서 할당량이 중복 반납되는 문제를 수정한다.

변경

컴퓨트 — 사용자별 사용량 한도

  • UserUsageLimits 구조체: 인스턴스 수·vCPU·RAM·Disk 누적 스펙 합산 기반 상한
  • 인스턴스 생성 시 ensureUserUsageLimits — flavor 스펙 합산 후 한도 초과 시 거부
  • OpenStack DeleteServer 404-tolerant: 이미 삭제된 VM은 성공으로 처리
  • DeleteByOpenstackID(bool, error): 0 rows = 동시 삭제 감지 → ErrInstanceNotFound (할당량 이중 반납 방지)

스토리지 — 사용자별 한도 + 이중 삭제 방지

  • 버킷 개수 한도: CountByOwner (단일 COUNT 쿼리)
  • 용량 한도: 업로드 시 전체 버킷 오브젝트 크기 합산 체크
    • goroutine + sync.WaitGroup 병렬 ListObjects — O(N·RTT) → O(RTT)
  • 디스크 고갈 방지: MaxBytesReaderFormFile 전에 적용, 초과 시 413 반환
  • Swift DeleteContainer / DeleteObject / ListObjects 404-tolerant
  • Delete(bool, error): 0 rows = 동시 요청 감지 → 할당량 이중 반납 없음
  • 삭제 순서: Swift-first → DB-second (DB-first 시 Swift 실패 → 컨테이너 영구 고아 문제 방지)
  • 한도 초과 체크(429)를 이름 중복 체크(409)보다 먼저 수행

환경변수 및 현재 적용 상한값

변수 현재 값 설명
RCP_MAX_INSTANCES_PER_USER 3 VM 개수 상한
RCP_MAX_VCPUS_PER_USER 4 core 총 vCPU 상한
RCP_MAX_RAM_MB_PER_USER 8192 MB (8 GB) 총 RAM 상한
RCP_MAX_DISK_GB_PER_USER 40 GB 총 Disk 상한
RCP_MAX_CONTAINERS_PER_USER 5 스토리지 버킷 개수 상한
RCP_MAX_STORAGE_GB_PER_USER 50 GB 총 오브젝트 스토리지 상한

각 항목을 0으로 설정하면 해당 한도가 비활성화됩니다.

테스트

  • compute/service_test.go: 인스턴스 수·리소스 한도 거부, 이중 삭제 → ErrInstanceNotFound
  • storage/service_test.go: 버킷 개수·GB 한도 거부, 경계값(>=), 이중 삭제 → ErrContainerNotFound, Swift-first 순서 검증, 무제한 시 DB 조회 스킵

후속 작업

  • TOCTOU (동시 생성 경쟁 조건): DB 트랜잭션 없이는 완전 해결 불가 — 별도 이슈로 관리
  • Swift 고아 컨테이너 정기 정합성 점검 배치

qixiangme and others added 3 commits May 31, 2026 19:57
[Storage]
- Add UserStorageLimits.StorageGB: rejects uploads when total GB across
  all user containers + new file exceeds limit (checked at upload time
  via ListObjects sum, not just container count)
- Add CountByOwner to repo: single COUNT query instead of loading all rows
- Add RCP_MAX_CONTAINERS_PER_USER and RCP_MAX_STORAGE_GB_PER_USER env vars
- Flip delete order to DB-first → Swift-last: 0 rows affected = already
  deleted by concurrent request → skip Swift call, return ErrContainerNotFound
- Make Swift DeleteContainer / DeleteObject 404-tolerant (already gone = success)

[Compute]
- Make OpenStack DeleteServer 404-tolerant (VM already terminated = success)
- DeleteByOpenstackID now returns (bool, error): 0 rows = concurrent delete
  already handled → return ErrInstanceNotFound, preventing quota double-release

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
#1 - Disk exhaustion before limit check:
  Apply MaxBytesReader(StorageLimitBytes) before FormFile so Go never
  buffers more than the user quota to disk. Returns 413 if exceeded.

#2 - Swift container orphaned on DB-first delete failure:
  Revert delete order to Swift-first (DB-second). With 404-tolerance
  already in place, concurrent double-deletes are handled: second request
  gets Swift 404 -> success, then DB 0 rows -> ErrContainerNotFound.
  Also make ListObjects 404-tolerant (return empty) so concurrent
  requests don't fail at the ListObjects step.

#3 - Off-by-one: > should be >=:
  totalBytes+additionalBytes >= limitBytes now correctly rejects uploads
  that would bring usage to exactly the limit, not only over it.

#6 - N sequential Swift ListObjects on every upload:
  Parallelize per-container ListObjects calls with goroutines + WaitGroup.
  Reduces latency from O(N*roundtrip) to O(max(roundtrip)).

#10 - Wrong error precedence (409 before 429):
  Move ensureUserStorageLimits before FindByName in CreateContainer so
  a user at their limit gets 429 instead of 409 when the name also exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@qixiangme qixiangme self-assigned this Jun 1, 2026
)

if err := h.Svc.UploadObject(c.Request.Context(), id, containerName, objectName, fileStream, contentType); err != nil {
size := c.Request.ContentLength

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

파일 크기를 ContentLength로 산정하는 부분에서 Content-Length 헤더는 클라이언트가 보내는 값이라 신뢰할 수 없고, 누락되면 -1이 됩니다. 실제 쿼터 산정은 스트림으로 읽은 바이트 수 기준으로 검증하는 게 안전합니다.

// 한도를 초과하는 파일은 디스크를 채우기 전에 차단된다.
if maxBytes := h.Svc.StorageLimitBytes(); maxBytes > 0 {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

남은 쿼터 전체를 한 번의 업로드 상한으로 걸면, 이미 사용 중인 용량이 있어도 한도까지 통과될 수 있습니다. (남은 용량 = 한도 − 현재 사용량)으로 상한을 잡는 편이 의도에 맞습니다.

return nil
}

func (s *Service) ensureUserStorageSizeLimit(ctx context.Context, ownerID uuid.UUID, additionalBytes int64) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

업로드마다 전체 객체를 스캔하면 객체 수가 늘어날수록 비용이 커집니다. 또한 검사 시점과 실제 쓰기 시점 사이에 동시 업로드가 끼어들면 한도를 초과할 수 있는 TOCTOU 여지가 있습니다. 사용량을 별도로 집계/캐싱하거나 원자적 검증을 고려해볼 만합니다.

}

limitBytes := int64(s.userLimits.StorageGB) * 1024 * 1024 * 1024
if totalBytes+additionalBytes >= limitBytes {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정확히 한도와 같은 크기에서 허용/거부 의도가 무엇인지 확인 부탁드립니다. >=이면 한도 정확히 채우는 것도 거부됩니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants