백엔드

Redis 키스페이스 알림과 임대를 이용한 GPU 워커 풀 로드 밸런싱

GPU는 비싸고 한 번에 하나의 무거운 작업만 실행하므로 라운드 로빈 라우팅은 바쁜 워커 뒤에서 지연됩니다. 여기에 Redis 리스와 키스페이스 알림으로 구축된 바쁨 인지 스케줄러가 있습니다.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

GPU 풀은 상태 비저장(stateless) 웹 서버 풀과 같지 않으며, 이를 웹 서버처럼 로드 밸런싱하는 것은 여러분이 소유한 가장 비싼 하드웨어를 낭비하는 일입니다. 무거운 추론 작업을 실행하는 GPU는 완전히 점유됩니다. 여기에 두 번째 작업을 보내면 공유하지 않고 큐에 넣으며, 이제 다른 GPU는 유휴 상태로 있는 동안 한 요청은 다른 사람의 작업 뒤에서 몇 분간 기다리게 됩니다. 웹 로드 밸런싱에서 반사적으로 사용하는 라운드 로빈 및 최소 연결 방식은 GPU에게 '바쁨'이 무엇을 의미하는지 모르기 때문에 여기서는 둘 다 실패합니다. 이 게시물은 두 가지 Redis 기본 요소, 즉 GPU를 예약하는 임대(lease)와 GPU가 해제되는 즉시 알려주는 키스페이스 알림을 사용하여 바쁨을 인지하는 스케줄러를 구축하고, 이러한 설계를 강제하는 실패 모드를 다룹니다.

일반적인 로드 밸런싱이 GPU에서 실패하는 이유

일반적인 로드 밸런서의 가정은 GPU 작업에는 적용되지 않습니다. 웹 서버는 저렴하고 짧은 다수의 동시 요청을 처리하므로, 요청을 서버에 고르게 분산하는 것이 최적에 가까우며 오래된 부하 정보는 거의 문제가 되지 않습니다. GPU는 한 번에 하나씩, 비싸고 긴 무거운 작업을 처리하므로, 중요한 것은 특정 GPU가 지금 유휴 상태인지 여부뿐이며, 이에 대한 오래된 정보는 즉시 시작과 수 분간의 지연이라는 차이를 만듭니다.

라운드 로빈 방식은 바쁜 상태를 완전히 무시하고 작업 N을 아직 처리 중인 GPU에 아무렇지 않게 작업 N+1을 할당할 것입니다. 최소 연결 방식은 더 가깝지만 여전히 틀렸습니다. GPU 점유율이 아닌 연결 수를 세기 때문이며, 추론 중인 GPU는 연결이 전혀 없어도 완전히 사용 불가능한 상태일 수 있습니다. 필요한 것은 "이 특정 GPU가 유휴 상태인가"를 진실의 단위로 삼고, 상태가 변경되는 즉시 업데이트되며, 두 요청이 동일한 유휴 GPU를 동시에 점유할 수 없도록 강력한 예약 기능을 갖춘 스케줄러입니다. Redis는 이 두 가지를 모두 저렴하게 제공합니다.

리스는 예약입니다

핵심 상태는 GPU별 리스입니다. 즉, GPU가 사용 중일 때 존재하고 유휴 상태가 되면 사라지는 Redis 키입니다. GPU를 확보하는 것은 해당 리스를 가져가는 것이며, 이 확보는 원자적이어야 하므로 동일한 유휴 GPU를 두고 경쟁하는 두 요청이 모두 이길 수 없습니다.

// Claim GPU n only if its lease is absent. SET NX is atomic, so exactly one
// caller wins the race. The TTL is a safety net, covered below.
ok, err := rdb.SetNX(ctx, "gpu:lease:"+gpuID, workerID, leaseTTL).Result()
if err != nil {
	return "", err
}
if !ok {
	return "", errBusy // someone else holds this GPU
}

SetNX는 단일 명령으로 compare-and-swap을 수행합니다. 즉, 키가 아직 존재하지 않는 경우에만 키를 설정합니다. 경쟁에서 이긴 GPU는 리스를 얻고 작업을 시작합니다. 다른 모든 GPU는 ok가 false를 반환하는 것을 보고 다른 GPU를 시도하기 위해 넘어갑니다. 작업이 끝나면 워커는 리스를 삭제하고 GPU는 다시 유휴 상태가 됩니다. 만약 이것뿐이라면, 유휴 GPU를 찾기 위해 여전히 폴링해야 할 것이고, 폴링은 우리가 피하고자 하는 바로 그 지연 시간과 낭비의 원인이 됩니다. 두 번째 프리미티브는 폴링을 제거합니다.

키스페이스 알림은 "유휴"를 이벤트로 전환합니다

Redis는 키가 만료되거나 삭제될 때를 포함하여 키가 변경될 때마다 이벤트를 게시할 수 있습니다. 그 기능이 바로 키스페이스 알림이며, 이를 통해 대기 중인 요청은 타이머를 사용하여 주기적으로 확인하는 대신 GPU가 유휴 상태가 되는 즉시 알 수 있습니다. 관심 있는 이벤트 클래스인 일반 키 이벤트와 만료 이벤트에 대해 서버에서 한 번 활성화한 다음, 임대 키에 대한 패턴을 구독합니다.

// Subscribe to deletions and expirations of GPU lease keys. Each message
// means a specific GPU just became free.
pubsub := rdb.PSubscribe(ctx,
	"__keyevent@0__:del",
	"__keyevent@0__:expired",
)
for msg := range pubsub.Channel() {
	freedGPU := parseGPUID(msg.Payload) // the lease key that just vanished
	scheduler.notifyFree(freedGPU)
}

이제 흐름은 엔드투엔드 이벤트 기반이 됩니다. 요청은 SetNX를 사용하여 사용 가능한 GPU를 확보하려고 시도합니다. 모든 임대가 점유 중이면, 스피닝하는 대신 대기합니다. 어느 곳에서든 작업이 완료되어 임대를 삭제하거나 임대가 만료되는 순간, Redis는 이벤트를 게시하고, 대기 중인 요청이 깨어나 새로 사용 가능해진 GPU를 확보하려고 시도합니다. 고가의 하드웨어는 안정 상태에서 폴링 없이 Redis 메시지 하나가 이동하는 시간 안에 사용 중 상태에서 재할당 상태로 전환됩니다.

TTL이 존재하는 이유인 실패 모드

정상 경로에는 구멍이 있으며, 이는 가장 비싼 리소스를 발이 묶이게 만들기 때문에 가장 중요한 문제입니다. 워커가 GPU를 점유하고 작업을 시작한 다음 충돌하거나, 네트워크가 분할되거나, 프로세스가 실행 중에 종료된다고 가정해 봅시다. 임대를 삭제하는 코드 라인에 절대 도달하지 못합니다. 임대 키는 Redis에 영원히 남아 있고, 해당 GPU는 영원히 사용 중으로 표시되며, 더 이상 존재하지 않는 프로세스에서 키를 삭제할 것이 없으므로 키스페이스 이벤트가 절대 발생하지 않습니다. 한 번의 충돌로 풀에서 GPU가 영구적으로 제거됩니다.

이것이 임대가 TTL, 즉 위 SetNX 호출의 leaseTTL를 가지는 이유입니다. TTL은 워커가 만료되기 훨씬 전에 완료 시 임대를 삭제하는 정상적인 경우를 위한 것이 아닙니다. 이것은 비정상적인 경우를 위한 복구 메커니즘입니다. 보유자가 중단되면 임대는 저절로 만료되고, Redis는 만료 키스페이스 이벤트를 발생시키며, GPU는 사람의 개입 없이 자동으로 풀에 다시 합류합니다. 이 임대는 이전의 단일 실행기 작업 논의와 같은 의미의 진정한 임대입니다. 즉, 살아있는 보유자는 갱신하고 중단된 보유자는 상실하는 예약입니다.

이는 긴 작업이 하트비트처럼 TTL이 경과하기 전에 임대를 갱신해야 한다는 것을 의미합니다. 그렇지 않으면 복구 메커니즘에 의해 실행 중에 GPU를 잃게 됩니다. 가장 길 것으로 예상되는 작업보다 TTL을 여유 있게 길게 설정하거나, 길이를 한정할 수 없는 작업의 경우 임대를 주기적으로 하트비트하십시오. 트레이드오프는 익숙한 것입니다. 짧은 TTL은 충돌한 GPU를 빠르게 복구하지만 느린 작업을 빼앗을 위험이 있고, 긴 TTL은 느린 작업에는 안전하지만 충돌한 GPU를 더 오래 발이 묶인 상태로 둡니다. 기본값에서가 아니라 작업 길이 분포를 보고 선택하십시오.

점유와 대기 사이의 경쟁

사용 중 인지 스케줄러에는 작성하기는 쉽지만 재현하기는 어려운 미묘한 순서 버그가 있어 언급할 가치가 있습니다. 한 요청이 모든 GPU를 확인하고, 모두 사용 중임을 발견한 다음, 유휴 이벤트 피드를 구독한다고 상상해 보세요. 만약 확인과 구독 사이의 아주 짧은 시간 동안 GPU가 유휴 상태가 되면, 구독이 존재하기 전에 이벤트가 발생하고, 요청은 이를 수신하지 못하며, 몇 분 후에나 발생할 수 있는 다음 유휴 이벤트를 기다리게 됩니다. GPU는 유휴 상태이고 요청은 대기 중이지만, 서로의 상태를 알지 못합니다.

해결책은 순서를 뒤집어 구독한 후 다시 확인하는 것입니다. 먼저 유휴 피드를 구독한 다음, GPU 점유를 시도합니다. 점유에 성공하면 구독을 취소하고 실행합니다. 모든 리소스가 사용 중이라 실패하면 이미 구독한 피드에서 대기하고, 중요한 점은 이벤트가 도착했을 때 유휴 상태가 된 GPU가 자신의 것이라고 가정하는 대신 다시 점유를 시도해야 한다는 것입니다. 다른 대기자가 먼저 점유했을 수도 있기 때문입니다. 구독하고, 확인한 다음, 대기 후 재시도하는 것입니다. 이 순서는 그 간극을 없애고, 깨어날 때 재시도하는 방식은 유휴 상태가 된 GPU를 자신이 접근하기 전에 다른 누군가가 점유하는 여러 대기자 문제를 처리합니다.

부실 상태와 조정 패스

TTL이 있더라도 분산 상태에는 드리프트가 발생합니다. 작업자가 잠시 파티션된 상태에서 여전히 GPU를 점유하고 있다고 생각하는 동안 해당 임대가 삭제될 수 있습니다. 구독자가 이벤트 발생 순간에 재연결 중이었다면 만료 이벤트가 누락될 수 있습니다. 이벤트에만 의존하여 구축된 이벤트 기반 시스템은 시간이 지남에 따라 이러한 작은 불일치들이 누적됩니다. 해결책은 주기적인 조정입니다. 즉, 느린 간격으로 각 GPU 작업자에게 실제로 무엇을 하고 있는지 독립적으로 묻고, 현실에 맞게 Redis를 수정하는 것입니다. 이벤트는 일반적인 경우에 시스템을 빠르고 최신 상태로 유지합니다. 조정 패스는 이벤트가 놓친 드리프트를 포착하여 장기적으로 시스템의 정확성을 유지합니다. 이벤트로부터의 빠른 경로, 조정을 통한 정확성 안전장치는 모든 이벤트 기반 스케줄러에 좋은 기본 자세입니다.

더 간단한 설계로 충분할 때

이 메커니즘은 GPU가 희소하고 비싼 자원이며, 작업이 충분히 길어서 바쁜 GPU로 작업을 라우팅하는 것이 큰 비용을 초래하는 실수가 될 때 그 가치를 발휘합니다. 작업이 짧고 비용이 저렴하다면, 유휴 워커가 작업을 가져가는 단순한 큐 방식이 더 간단하고 그만큼 효과적입니다. 각 작업이 순식간에 끝나므로 약간 불완전하게 할당되더라도 그 비용은 무시할 수 있기 때문입니다. 배치를 대신 처리해주는 관리형 추론 서비스가 있다면, 그 서비스를 사용하고 이 모든 내용은 건너뛰십시오. 바로 이 문제를 해결하기 위해 비용을 지불하고 있는 것이기 때문입니다.

다음 세 가지가 모두 해당하는 경우에 임대 및 알림 스케줄러를 구축하십시오. GPU가 비싸서 유휴 시간이 실제 손실이 될 때, 작업이 길어서 잘못된 할당이 상당한 시간 동안 요청을 지연시킬 때, 그리고 관리형 엔드포인트를 대여하는 대신 직접 풀을 운영하고 있을 때입니다. 이러한 조건에서, 원자적 예약을 위한 Redis 임대와 즉각적인 핸드오프를 위한 키스페이스 알림은 폴링 없이 고가의 하드웨어를 계속 사용 중 상태로 유지하는 스케줄러를 제공합니다. 그리고 TTL과 조정(reconcile) 과정은 워커가 중단되거나 이벤트가 유실될 때 시스템을 정상적으로 유지해 줍니다.

관련 글

데이터베이스

Firestore의 MongoDB 호환성에서 `retryWrites=false`의 함정 ================================================================ Firestore를 MongoDB 호환 모드에서 사용하고 있다면 쓰기 작업이 때때로 실패하고 온라인에서 찾을 수 있는 유일한 "해결책"은 연결 문자열에 `retryWrites=false`를 추가하는 것뿐인 특이한 문제를 겪었을 수 있습니다. 이것이 문제를 해결하는 것처럼 보일 수 있지만, 데이터 손실 및 기타 문제로 이어질 수 있는 함정입니다. 이러한 현상이 발생하는 이유와 올바른 해결책이 무엇인지 알아보겠습니다. ## 쓰기 작업이 실패하는 이유 Firestore는 MongoDB를 에뮬레이션할 때 MongoDB의 재시도 가능 쓰기(retryable writes) 기능을 완벽하게 지원하지 않습니다. 쓰기 작업이 Firestore로 전송될 때 일시적인 네트워크 오류나 서버 측의 일시적인 문제가 발생하면 MongoDB 드라이버는 자동으로 쓰기 재전송을 시도할 수 있습니다. 하지만 Firestore의 에뮬레이션 레이어는 이 두 번째 시도를 올바르게 처리하지 못할 수 있으며, 특히 첫 번째 시도가 이미 처리 중인 경우 더욱 그렇습니다. 이로 인해 드라이버는 쓰기 작업이 실패했다고 생각하는 오류 상태가 발생할 수 있으며, 실제로는 결국 성공할 수도 있습니다. ## `retryWrites=false` "해결책" 연결 문자열에 `retryWrites=false`를 추가하면 MongoDB 드라이버에게 첫 번째 시도 후에 바로 포기하라고 지시합니다. 일시적인 오류가 발생하면 쓰기 작업은 즉시 실패하고 애플리케이션은 오류를 보게 됩니다. 더 이상 혼란스러운 재시도 관련 오류가 발생하지 않기 때문에 이것이 문제를 해결하는 것처럼 *보입니다*. 쓰기 작업은 첫 시도에 성공하거나 깔끔하게 실패합니다. ## 진짜 문제: 데이터 손실 함정은 재시도를 비활성화함으로써 이제 일시적인 실패를 직접 처리해야 한다는 점입니다. 그렇게 하지 않으면, 애플리케이션은 순간적인 네트워크 문제만으로도 중요한 데이터를 저장하지 못할 수 있습니다. 사용자는 데이터가 저장되었다고 생각할 수 있지만, 실제로는 쓰기 작업이 조용히 실패했을 수 있습니다(오류를 처리하지 않는다면 그다지 조용하지 않을 수도 있습니다). 이는 데이터 신뢰성 측면에서 중대한 후퇴입니다. ## 올바른 해결책 중요한 신뢰성 기능을 비활성화하는 대신, 올바른 접근 방식은 Firestore의 MongoDB 호환성을 위해 환경이 올바르게 설정되었는지 확인하는 것입니다. * **1. 공식적으로 지원되는 드라이버 및 버전 사용:** Google Cloud의 문서를 확인하여 Firestore에서 완전히 테스트되고 지원되는 MongoDB 드라이버 및 버전 목록을 확인하십시오. 지원되지 않는 버전을 사용하면 예측할 수 없는 동작이 발생할 수 있습니다. * **2. 사용자 지정 재시도 로직 구현:** 문제를 일으키는 드라이버 구성을 꼭 사용해야 하는 경우, 재시도를 전역적으로 비활성화하지 마십시오. 대신, 쓰기 실패와 관련된 특정 오류 코드를 포착하여 애플리케이션 코드에 자체 재시도 로직을 구현하십시오. 이를 통해 재시도 횟수와 백오프 전략(예: 지수 백오프)을 제어할 수 있습니다. * **3. 문제 보고:** Firestore의 MongoDB 호환성 레이어에서 버그를 발견했다고 생각되면 Google Cloud 지원팀에 보고하십시오. 드라이버 버전, 연결 문자열(자격 증명 제외), 정확한 오류 메시지에 대한 자세한 정보를 제공하십시오. 이는 모든 사용자를 위해 서비스를 개선하는 데 도움이 됩니다. 요약하자면, `retryWrites=false`는 해결책이 아닙니다. 이는 더 깊은 문제를 숨기고 데이터 손실의 위험을 초래하는 임시방편입니다. 항상 지원되는 구성을 사용하고 애플리케이션에 강력한 오류 처리 및 재시도 메커니즘을 구축하는 것을 우선시해야 합니다.

Firestore는 MongoDB 와이어 프로토콜을 지원하지만 재시도 가능 쓰기는 거부합니다. 드라이버의 기본 설정이 on인 경우, 쓰기가 무작위로 실패하는 것처럼 보입니다. 그 이유와 해결 방법은 다음과 같습니다.

5분 읽기 firestoremongodbgoidempotencydatabases
백엔드

백엔드를 Rust에서 Go로 이전한 이유, 그리고 빌드 시간이 결정적이었던 까닭 ================================================================ 저는 Rust를 사랑합니다. 정말입니다. 훌륭한 커뮤니티를 가진 환상적인 언어이며, 대부분의 개인 프로젝트에서 제가 선택하는 언어입니다. 하지만 최근 한 백엔드 프로젝트에서는 Rust에서 Go로 이전하는 어려운 결정을 내렸습니다. 주된 이유는요? 바로 빌드 시간이었습니다. ## Rust 경험: 강력하지만 컴파일이 느림 저희 백엔드 서비스는 Rust로 작성된 작은 모놀리스로 시작했습니다. 초기 개발은 순조로웠습니다. Rust의 타입 시스템과 소유권 모델은 컴파일 시간에 많은 버그를 잡아주어 저희에게 큰 자신감을 주었습니다. `serde`, `tokio`, `actix-web`과 같은 크레이트가 있는 생태계는 함께 작업하기에 즐거웠습니다. 하지만 프로젝트가 커지면서 컴파일 시간도 늘어났습니다. 개발자 머신에서 클린 빌드를 하는 데 몇 분이 걸렸습니다. 증분 빌드는 더 빠르긴 했지만, 점차 느리게 느껴지기 시작했습니다. 이것은 사소한 불편함을 넘어, 개발 속도를 실질적으로 저해하고 있었습니다. ## CI/CD 병목 현상 하지만 진짜 고통스러운 지점은 CI/CD 파이프라인에 있었습니다. 모든 풀 리퀘스트는 전체 릴리스 빌드와 포괄적인 테스트 스위트 실행을 포함한 일련의 검사를 트리거했습니다. 다음은 CI에서의 빌드 시간을 간략하게 살펴본 것입니다: | 단계 | Rust 빌드 시간 | | --- | --- | | 의존성 캐싱 | ~1분 | | 디버그 빌드 (테스트용) | ~5-7분 | | 릴리스 빌드 (배포용) | ~10-15분 | | **총 파이프라인 시간** | **~20-25분** | 간단한 변경에 대한 25분의 피드백 루프는 받아들일 수 없었습니다. 저희는 `sccache`, Docker 레이어 캐싱, 빌드와 테스트 작업 분리 등 다양한 최적화를 시도했습니다. 이러한 방법들이 도움이 되긴 했지만, 몇 분을 단축하는 데 그쳤습니다. 핵심 문제는 그대로였습니다. Rust 컴파일러는 많은 작업을 수행하고(안전성 측면에서는 훌륭하지만!), 시간이 걸린다는 점입니다. ## Go의 등장: 실용적인 선택 저희는 실험을 해보기로 결정했습니다. 서비스의 핵심적이고 독립적인 부분을 가져와 Go로 다시 작성했습니다. 그 경험은... 달랐습니다. * **단순성:** Go는 훨씬 더 단순한 언어입니다. Rust의 강력한 추상화(트레이트나 고급 열거형 등)는 잃었지만, 코드는 놀라울 정도로 작성하고 읽기 쉬웠습니다. * **동시성:** Go의 고루틴과 채널은 동시성 처리를 쉽게 만들어 주어, 저희 백엔드 요구사항에 아주 적합했습니다. * **컴파일러:** 이것이 바로 게임 체인저였습니다. Go 컴파일러는 *빠릅니다*. 눈부시게 빠릅니다. 서비스의 상당 부분을 다시 작성한 후, 동일한 CI 파이프라인을 실행했습니다. 결과는 놀라웠습니다. | 단계 | Go 빌드 시간 | | --- | --- | | 의존성 캐싱 | ~30초 | | 빌드 및 테스트 | ~1-2분 | | **총 파이프라인 시간** | **~2-3분** | 저희는 25분 걸리던 파이프라인을 3분짜리로 단축했습니다. 이것은 단지 점진적인 개선이 아니라, 개발 워크플로우에 대한 근본적인 변화였습니다. 개발자들은 거의 즉시 피드백을 받을 수 있게 되었습니다. 코드 병합이 더 빨라졌습니다. 핫픽스 배포가 사소한 일이 되었습니다. ## 트레이드오프와 결론 이전 과정이 순탄했냐고요? 아닙니다. 저희는 Rust의 컴파일 타임 보장을 포기했고, 이는 테스트와 코드 리뷰에 더 부지런해야 한다는 것을 의미했습니다. 제네릭의 부재(초기 재작성 시점)는 일부 코드를 더 장황하게 만들었습니다. Go의 오류 처리는 단순하지만, Rust의 `Result`와 `?` 연산자보다 덜 견고하게 느껴집니다. 하지만 개발 속도와 빠른 피드백 루프가 중요한 웹 백엔드라는 저희의 특정 사용 사례에서는 그 트레이드오프가 가치가 있었습니다. 빌드 시간의 극적인 감소는 개발자 생산성과 행복도 증가로 바로 이어졌습니다. 저는 여전히 Rust를 사랑하며, 성능과 안전성 보장이 가장 중요하고 컴파일 시간이 덜 중요한 프로젝트에서는 계속해서 사용할 것입니다. 하지만 이 경험은 때로는 작업에 가장 '최고의' 도구는 방해하지 않고 결과물을 내놓게 해주는 도구라는 것을 강력하게 상기시켜 주었습니다. 저희에게 그 도구는 Go였습니다.

Rust는 우리에게 거의 필요하지 않았던 안전성과 매일 싸워야 했던 컴파일 루프를 주었습니다. 한 서비스를 Go로 전환하게 만든, 수치를 포함한 솔직한 트레이드오프가 여기에 있습니다.

7분 읽기 gorustmigrationbuild-timedeveloper-experience