DevOps ClearML MLOps LLM Fine-tuning Pipeline vLLM NVIDIA NGC GPU Quantization

단일 DGX에 추론과 학습을 같이 올렸더니 파이프라인이 복구 절차가 됐다

단일 DGX에서 vLLM 추론과 LLM 학습 작업을 같이 굴리며 ClearML 파이프라인을 만든 기록. 좋은 구조는 아니었고, 그 제약을 받아들이자 GPU 소유권 전환과 복구 절차가 파이프라인 단계로 흘러들어왔습니다.

앞선 글에서는 단일 GPU 노드에서 모델을 어떻게 서빙했는지를 적었습니다. 이 글은 그 앞단, 서빙할 모델을 만들어내는 파이프라인을 어떻게 묶었는지에 대한 기록입니다.

먼저 인정하고 가야 합니다. 이건 좋은 운영 구조가 아니었습니다.

추론 서비스와 학습 작업은 리소스 성격이 다릅니다. vLLM은 계속 떠 있어야 하는 상주 서비스이고, 학습은 GPU를 크게 잡아먹는 배치 작업입니다. 둘을 같은 DGX, 같은 GPU 위에 올린 순간, 파이프라인 문제는 “어떤 순서로 학습할까”가 아니라 “누가 GPU를 점유하고 있고, 실패하면 누가 원래 상태로 되돌릴까”가 됐습니다.

제가 다룬 환경은 꽤 특수했습니다.

  • DGX 한 대에서 학습 작업을 돌렸습니다.
  • 같은 GPU에서 vLLM 추론 컨테이너도 떠 있었습니다.
  • 학습을 시작하려면 vLLM을 내리고, 끝나면 다시 올려야 했습니다.
  • 파이프라인은 밤에 채팅 한 줄로 트리거하고, 아침에 결과를 확인하는 형태를 목표로 했습니다.

보통이라면 이렇게 나누는 편이 낫습니다. Hermes 같은 LLM agent나 컨트롤 플레인은 별도 서버에 두고, DGX는 학습 전용으로 쓰고, 추론도 다른 서버나 클러스터로 분리합니다. 그런 구성이라면 이 글에 나오는 stop_vllm, recover_vllm 같은 단계는 필요 없거나 전혀 다른 의미가 됩니다.

그래서 이 글은 “이렇게 하면 좋은 MLOps 파이프라인이 된다”는 글이 아닙니다. 오히려 반대에 가깝습니다. 나쁜 리소스 배치를 받아들이면, 그 비용이 어떤 운영 단계로 파이프라인 안에 흘러들어오는지에 대한 기록입니다.

문제는 학습 순서가 아니라 GPU 소유권이었다

처음에는 이걸 ML 파이프라인 문제로 봤습니다. 데이터를 가져오고, 학습하고, 양자화하고, 배포하면 된다고 생각했습니다.

하지만 실제 병목은 학습 코드가 아니었습니다. 지금 이 GPU를 누가 쓰고 있는가, 다음 단계가 GPU를 잡기 전에 무엇을 내려야 하는가, 실패해도 추론을 다시 올릴 수 있는가가 더 중요했습니다.

vLLM은 서빙 성능을 위해 GPU 메모리를 적극적으로 씁니다. vLLM 문서에서도 gpu_memory_utilization 비율로 KV cache용 GPU 메모리를 미리 할당한다고 설명합니다. 이 말은, 같은 GPU에서 학습을 시작하려면 “큐에 작업을 넣는다”만으로는 부족하다는 뜻입니다. 큐 밖에서 GPU를 잡고 있는 추론 컨테이너를 내려야 했습니다.

이 선택은 구조적으로 별로였습니다. 추론 가용성이 학습 스케줄에 종속됐고, 실패하면 학습 실패와 서비스 중단이 같이 남을 수 있었습니다. 그 비용이 뒤에서 나오는 stop_vllm, recover_vllm, finalize_success 같은 단계로 나타났습니다.

처음 생각한 파이프라인

처음 화이트보드에 그린 파이프라인은 이랬습니다.

fetch → validate → train → convert → quantize → deploy → evaluate → report

ML 작업만 놓고 보면 이 정도면 충분해 보였습니다. 데이터를 가져오고, 검증하고, LoRA 학습을 돌리고, 추론용 포맷으로 바꾸고, AWQ 양자화를 하고, 배포하고, 평가 결과를 남기면 됩니다.

그런데 실제로 밤마다 돌리기 위해 만든 DAG는 이렇게 됐습니다.

fetch_dataset → validate_data → enter_maintenance → stop_vllm → start_ngc
→ train → convert → quantize_awq → deploy → recover_vllm
→ evaluate → report_eval → share_promptfoo → finalize_success

처음 그린 흐름은 8개 단계였고, 실제 DAG는 세어보면 14개 단계입니다. 차이는 여섯 단계였습니다.

추가된 단계어디에 붙었나맡은 일
enter_maintenance학습 전추론 서비스를 내릴 준비를 하고 점검 창에 들어갑니다.
stop_vllm학습 전GPU 메모리를 잡고 있던 vLLM 컨테이너를 내립니다.
start_ngc학습 전학습에 필요한 NGC 기반 학습 컨테이너를 올립니다.
recover_vllm배포 후모델 파일 교체가 끝난 뒤 추론 서비스를 다시 세웁니다. 실패 경로에서도 실행돼야 하는 복구 단계에 가깝습니다.
share_promptfoo평가 후평가 결과를 Promptfoo 대시보드에 올리고 비교 가능하게 남깁니다.
finalize_success마지막이번 실행이 검증까지 끝난 성공 케이스임을 명시적으로 닫습니다.

표로 놓고 보면 공통점이 보입니다. 이 중에서 모델을 더 잘 학습시키는 단계는 없습니다. 대신 나쁜 리소스 배치가 만든 운영 부채를 처리합니다. 학습 전후의 상태를 정리하고, 추론 서비스를 복구하고, 평가 결과를 남기고, 실행을 명시적으로 마감하는 일입니다.

처음에는 이런 것들을 파이프라인 바깥의 운영 절차로 생각했습니다. “학습 전에 내가 vLLM 내려두면 되지”, “끝나고 다시 올리면 되지” 같은 식이었습니다. 그런데 그건 자동화가 아니라 사람이 지키고 있는 수동 절차였습니다. 밤에 채팅 한 줄로 트리거하려면, 이 운영 부채를 DAG 안으로 끌어들일 수밖에 없었습니다.

ClearML Agent 큐가 해결한 것과 못한 것

“ClearML로 파이프라인을 만들었다”고 하면 보통 웹 대시보드부터 떠올립니다. 실험 추적, 메트릭 그래프, DAG 시각화가 보이는 그 화면이요. 하지만 제 구성에서 실제로 GPU 작업을 꺼내 실행하는 주체는 ClearML Server가 아니라 ClearML Agent였습니다.

  • ClearML Server — 웹 UI, 실험·아티팩트·메트릭을 모아두는 추적 백엔드. K8s에 띄웠습니다. 기록하고 보여주는 역할입니다.
  • ClearML Agent — DGX에 systemd 서비스(clearml-agent.service)로 떠서, 큐에 쌓인 Task를 하나씩 꺼내 실제로 실행하는 워커. 일하는 역할입니다.

ClearML 문서에서도 Queue는 실행 대기 중인 Task의 ordered list이고, Agent가 큐에서 Task를 순서대로 pull해서 실행한다고 설명합니다. Agent는 Docker container로 실행 환경을 만들 수도 있습니다. 이 구조 덕분에 dgx-gpu 큐에 워커를 하나만 붙이면, ClearML Task끼리는 자연스럽게 하나씩 실행됐습니다.

여기서 제가 한 선택은 컨트롤러와 컴포넌트의 실행 위치를 나누는 것이었습니다.

  • 컴포넌트(train, quantize_awq 등 실제 GPU 작업)는 dgx-gpu 큐에 enqueue되고, Agent가 꺼내서 GPU에서 실행합니다.
  • 컨트롤러(DAG를 순회하며 다음 단계를 호출하는 오케스트레이터)는 큐에 넣지 않고 호스트에서 직접(run_locally()) 돕니다.

컨트롤러는 GPU가 필요 없습니다. “이 단계가 끝났으니 다음 단계를 큐에 넣어라”를 판단할 뿐입니다. 이걸 GPU 큐에 넣으면 한 장뿐인 GPU 워커가, 정작 GPU를 쓰지 않는 오케스트레이터에 점유됩니다. ClearML도 pipeline controller 같은 가벼운 제어 작업은 services mode 쪽에 두는 사용례를 문서화하고 있습니다. 제 경우에는 컨트롤러를 호스트에서 가볍게 돌리고, GPU가 필요한 컴포넌트만 큐로 보냈습니다.

이렇게 하면 ClearML 큐 안에서는 별도 락을 만들 필요가 없었습니다. 학습 두 개를 동시에 트리거해도 Agent가 한 번에 하나씩만 꺼내기 때문입니다.

하지만 이 직렬화에는 범위가 있었습니다. ClearML 큐를 거치는 Task끼리만 해당됩니다. vLLM은 ClearML Agent가 관리하는 Task가 아니라, GPU 메모리를 잡고 있는 상주 Docker 컨테이너였습니다. 그래서 Agent 큐가 아무리 잘 직렬화돼도, vLLM이 GPU를 붙잡고 있으면 학습 Task는 제대로 시작할 수 없습니다.

이 때문에 stop_vllm이 별도 단계로 들어갔습니다. 이건 좋은 MLOps 패턴이라기보다, 애초에 추론과 학습을 같은 GPU에 올린 선택의 비용이었습니다. 큐 밖의 GPU 점유자를 파이프라인이 직접 정리해야 했습니다.

왜 한 스크립트로 안 묶었나

“그냥 학습 스크립트 하나로 쭉 돌리면 되지 않나?”라는 의문이 먼저 들 겁니다. 됩니다. 한 번에 성공한다면요.

문제는 실패할 때 드러납니다. 14단계 중 quantize_awq에서 깨졌다고 해봅시다.

  • 한 스크립트라면: 처음부터 다시 돌립니다. 데이터셋을 다시 받고(수 분), 다시 학습하고(수십 분), 그제서야 양자화에 도착합니다. 양자화 인자 하나 고치려고 학습 전체를 다시 합니다.
  • 단계로 쪼개면: ClearML이 앞 단계의 산출물(학습된 가중치)을 아티팩트로 들고 있으니, quantize_awq부터 재실행합니다. 고치고 1~2분이면 다시 그 지점에 섭니다.

제가 단계를 나눈 가장 현실적인 이유는 어디서 깨졌는지를 좁히고, 깨진 단계만 다시 돌리기 위해서였습니다. 학습 한 사이클이 40분인데 매번 처음부터 다시 돌려야 한다면, 양자화 인자 몇 개 바꾸는 일도 부담스러워집니다.

여기서 ClearML 아티팩트가 도움이 됐습니다. 단계별 산출물을 아티팩트로 남기면, 다음 단계는 이전 단계의 로컬 파일 경로를 추측하지 않고 ClearML에 기록된 산출물을 받아갈 수 있습니다. 이건 “깔끔한 대시보드”보다 재실행 비용을 줄이는 쪽에서 더 크게 체감됐습니다.

오래 걸린 건 단계 사이의 계약이었다

단계로 쪼개니 새로운 문제가 생겼습니다. 각 단계가 다음 단계에 무엇을, 어떤 형식으로 넘기는가를 정해야 했습니다.

fetch_dataset이 만들어내는 데이터셋의 컬럼 이름 하나가 바뀌면, validate_datatrainevaluate도 한 줄도 못 가고 멈춥니다. 그것도 깨끗하게 에러를 내며 멈추면 다행입니다. 더 나쁜 경우는 형식이 미묘하게 어긋난 채로 학습이 끝까지 돌아가고, 평가 단계에 가서야 점수가 이상하다는 걸 발견하는 겁니다. 그때는 이미 GPU 시간 40분을 버린 뒤입니다.

그래서 가장 많이 손본 건 학습 코드보다 단계 사이의 IO를 명시적인 계약으로 굳히는 일이었습니다.

  • 각 단계의 입력·출력 스키마를 고정하고,
  • validate_data학습 앞이 아니라 fetch 바로 뒤에 둬서, 형식이 어긋나면 GPU를 잡기 전에 멈추게 하고,
  • 단계의 산출물을 ClearML 아티팩트로 박아서, 다음 단계가 “이전 단계가 무엇을 만들었는지”를 추측하지 않고 계약대로 받게 했습니다.

validate_datastop_vllm보다 앞에 둔 것도 이 때문입니다. 데이터가 깨졌으면, 추론 서비스를 멈추기 전에 알아야 합니다. 점검 창을 열고 vLLM을 내린 다음에 “데이터가 이상하네”를 발견하면, 아무것도 학습하지 못한 채 서비스만 내렸다 올린 꼴이 됩니다.

이건 제가 다음에도 유지하고 싶은 기준입니다. 비싼 자원을 잡기 전에 실패할 수 있는 검사를 먼저 끝낸다. 이 구성에서는 그 비싼 자원이 GPU였고, 더 정확히는 vLLM을 내려야만 비워지는 GPU였습니다.

점검 창을 사람 손에 남겨두지 않기

enter_maintenance, stop_vllm, start_ngc, recover_vllm — 이 네 단계는 단일 GPU라는 제약에서 나옵니다. 추론(vLLM)과 학습(NGC 컨테이너)이 같은 GPU 메모리를 통째로 원하기 때문에 공존하지 못하고, 학습을 시작하려면 추론을 내려야 합니다. (이 경합 자체는 앞 글에서 자세히 다뤘습니다.)

여기서 제가 바꾼 건, 이 점검 동작을 파이프라인 바깥의 수동 운영으로 두지 않은 것입니다.

수동이었다면 흐름은 이렇습니다. 사람이 추론을 내리고 → 파이프라인을 돌리고 → 끝나면 사람이 추론을 다시 올린다. 밤에 자동으로 돌릴 수가 없습니다. 누군가 깨어서 앞뒤를 처리해야 하니까요.

stop_vllmrecover_vllm을 파이프라인 단계로 넣으니, 트리거 한 번에 점검 창 진입 → 학습 → 점검 창 복구가 한 흐름으로 묶였습니다. 이게 제가 원했던 실제 효과였습니다. 좋은 아키텍처라서가 아니라, 이미 나쁜 구조를 받아들인 상태에서 사람 손을 빼기 위한 최소한의 장치였습니다.

특히 recover_vllm은 성공 경로의 마지막 장식이 아니라, 실패 경로에서도 실행돼야 하는 복구 단계에 가깝습니다. 학습이 실패해도 추론은 다시 올라와야 합니다. 단일 DGX에서 이걸 놓치면 “학습은 실패했고, 추론도 내려간 채로 아침을 맞는” 상태가 됩니다.

deployrecover_vllm도 일부러 나눴습니다. 여기서 deploy는 학습·양자화된 모델 파일을 serving 경로에 올리고 설정을 갱신하는 단계입니다. 실제 추론 프로세스를 다시 띄우고 상태를 확인하는 일은 recover_vllm이 맡았습니다. 파일을 교체하는 것과 서비스를 복구하는 것은 실패 지점도, 재시도 방법도 다르기 때문입니다.

더 분리된 환경이었다면 이 단계들은 달라졌을 겁니다. 학습 전용 DGX라면 vLLM을 내릴 필요가 없습니다. 추론이 별도 클러스터라면 recover_vllm 대신 model registry 갱신, canary 배포, shadow evaluation 같은 단계가 더 중요했을 수 있습니다. 하지만 제 환경에서는 추론 컨테이너 복구가 가장 먼저 자동화해야 할 운영 동작이었습니다.

deploy 뒤에도 아직 끝난 게 아니었다

마지막 세 단계 evaluate → report_eval → share_promptfoo → finalize_success도 곱씹을 만합니다.

처음에는 deploy까지 성공하면 끝이라고 생각했습니다. 모델이 떠 있으면 성공이라고요. 그런데 AWQ 양자화를 넣고 나니 그 기준이 부족했습니다. AWQ는 calibration dataset으로 scaling factor를 잡아 accuracy loss를 줄이는 방식이라, calibration 데이터가 도메인을 제대로 대표하지 못하면 에러 없이 답변 품질이 떨어질 수 있습니다. 이 silent regression은 앞 글에서도 다뤘습니다.

그래서 deploy 뒤에 평가와 공유 단계를 붙였습니다. base / base+프롬프트 / 파인튜닝 세 버전을 같은 평가셋으로 비교하고, 그 결과를 Promptfoo 대시보드에 남긴 뒤에야 이번 실행을 성공으로 봤습니다. Promptfoo의 assertions와 metrics는 “대충 눈으로 몇 개 찍어보기”가 아니라 같은 테스트셋으로 모델과 프롬프트를 반복 비교하기 위한 장치였습니다.

finalize_success를 둔 이유도 비슷합니다. “모델이 떴다”와 “평가 결과까지 남기고 끝났다”는 다른 상태였습니다. 마지막 단계가 성공을 명시적으로 닫아주지 않으면, 다음날 아침에 이 실행이 배포까지만 된 것인지, 평가까지 통과한 것인지 다시 확인해야 했습니다.

이 구성이 아니면 무엇이 달라질까

이 글의 단계는 제가 다룬 단일 DGX 구성에 붙어 있습니다. 더 정확히는, 좋지 않은 구성의 비용이 단계로 드러난 결과입니다. 구성이 달라지면 파이프라인에 들어가야 하는 운영 단계도 달라집니다.

구성사라지거나 약해지는 단계대신 중요해지는 단계
단일 DGX에서 추론과 학습 공유stop_vllm, recover_vllm, GPU 점유 상태 확인
학습 전용 DGXstop_vllm, recover_vllm데이터 검증, 학습 재시작, 아티팩트 관리
추론 서버 분리로컬 vLLM 복구model registry 갱신, canary 배포, rollback gate
Kubernetes GPU 클러스터수동 컨테이너 stop/startGPU resource request, node scheduling, rollout 전략

그래서 이 글을 다른 환경에 그대로 복사하면 안 됩니다. 가져갈 수 있는 건 단계 이름이 아니라 질문입니다. 특히 첫 질문은 이것이어야 합니다.

애초에 이 리소스들을 같은 머신에 올려야 하는가?
이 파이프라인에서 사람이 수동으로 정리하던 운영 상태는 무엇인가?
실패해도 반드시 원래대로 돌려놓아야 하는 것은 무엇인가?
배포가 아니라 검증 완료를 어떻게 구분할 것인가?

정리 — 이 구성에서 배운 것

  • 이 글의 14단계는 좋은 설계의 증거가 아니라, 단일 DGX에서 추론과 학습을 같이 굴린 비용에 가깝습니다.
  • ClearML 웹이 멀쩡해도 Agent가 죽으면 GPU 작업은 실행되지 않습니다. 제 구성에서는 장애 확인의 첫 지점이 Server가 아니라 Agent였습니다.
  • ClearML 큐 안의 작업은 워커 하나로 직렬화됐지만, 큐 밖의 vLLM 컨테이너는 별도로 내려야 했습니다. 그래서 stop_vllm이 필요했습니다.
  • 단계는 “아키텍처가 멋져 보여서” 나눈 게 아니라, 실패한 지점부터 다시 돌리고 서비스 복구를 빠뜨리지 않기 위해 나눴습니다.
  • validate_data는 vLLM을 내리기 전에 끝나야 했습니다. 데이터가 깨졌다면 GPU와 추론 서비스를 건드리기 전에 멈추는 게 낫습니다.
  • recover_vllm은 성공 경로의 장식이 아니라 실패해도 돌아야 하는 복구 단계에 가깝습니다.
  • deploy만으로는 성공이라고 보기 어려웠습니다. 양자화 모델은 조용히 품질이 떨어질 수 있어서, 평가 결과를 남기고 finalize_success로 닫는 편이 아침에 확인하기 쉬웠습니다.

다시 한다면 장비를 나누겠습니다. Hermes 같은 agent/control plane은 별도 서버에 두고, DGX는 학습 전용으로 두고, 추론은 별도 inference 노드로 분리하는 식입니다. 그러면 이 글의 운영 단계 중 일부는 사라질 겁니다.

하지만 당시의 제약 안에서는, 이미 생긴 운영 부채를 사람 손에 남겨두는 것보다 파이프라인 안으로 끌어들이는 편이 나았습니다. 이 글은 좋은 구조의 소개가 아니라, 나쁜 제약을 받아들였을 때 무엇을 자동화해야 하는지에 대한 기록입니다.

관련 콘텐츠

댓글