[MLOps] 머신러닝 플랫폼 엔지니어링: Kubeflow Pipelines와 학습 트러블슈팅 - 9.1.3. 트러블슈팅에서 본 플랫폼의 몫

· 11 분 소요

MLOps › 학습·MLOps › 파이프라인·스터디

시리즈 · 머신러닝 플랫폼 엔지니어링: Kubeflow Pipelines와 학습 트러블슈팅 (5 / 5)·← 이전 편

서종호(가시다)님의 Machine Learning Platform Engineering Study 4주차 학습 내용을 기반으로 합니다.


TL;DR

  • 4주차 네 편의 사례와 실험을 플랫폼 엔지니어의 관점에서 다시 보면 두 질문으로 모인다. 실패를 언제 드러내는가, 그리고 실패가 났을 때 플랫폼이 어디까지 흡수하는가다(들어가며)
  • 잃는 시간은 원인이 생긴 때와 실패가 드러난 때 사이의 거리만큼 커졌다. 빌드 안의 import와 모든 모듈을 불러 보는 점검은 그 거리를 빌드와 점검 단계로 당겼지만, 학습 중에 옮겨진 검증 데이터처럼 다음 검증까지 미뤄지는 실패는 남아 있다(원인과 실패 사이의 거리)
  • 늦게 드러나는 실패보다 오류 없이 지나가는 조용한 실패가 더 나빴다. KFP 캐시의 재사용, 묶음째 거부된 MLflow 검증 지표, 오류 없이 건너뛴 best 업로드는 결과물이나 그 기록이 의도와 달라진 채 남아, 알아채기 전까지 비교와 배포 판단에 쓰인다. 그래서 실패를 앞당기기 전에 먼저 드러나게 만드는 일이 앞선다(오류 없이 지나간 실패)
  • 플랫폼이 맡을지는 학습 코드를 몰라도 할 수 있는지로 갈랐다. 빌드 재현성, 기록, 산출물 보존, 실패 run의 traceback 보존은 플랫폼이 맡고, 감시 지표와 기준 지표 이름은 학습 설정에, 데이터 불변은 데이터를 관리하는 쪽에 두었다. 다만 여러 기록을 하나로 묶어 처리한 플랫폼 코드가 스스로 조용한 실패를 만들기도 했고, 경계를 지키려다 임시 대처에 머물거나 오히려 사내 코드의 관행을 깊이 알아야 했다(어디까지 흡수하는가, 과한 추상화, 경계를 지키는 비용)
  • 두 질문은 한 지점에서 충돌한다. 검증 경로의 문제를 가장 일찍 드러낼 학습 시작 직후의 검증 몇 배치는 학습 프레임워크를 알아야 걸 수 있어서, 플랫폼 몫인지 아직 정하지 못했다(남은 고민)


들어가며

4주차 시리즈의 8.1.1편은 학습 이미지와 의존성, 8.1.2편은 분산 학습의 epoch별 검증, 9.1.2편은 학습 데이터 참조와 산출물 저장에서 겪은 장애를 다뤘다. 9.1.1편에서는 Kubeflow Pipelines(KFP)의 캐시와 importer가 데이터의 무엇을 보고 같다고 판단하는지 로컬 클러스터에서 실험했다. 네 편은 이름으로 가리킨 것과 실제 내용이 어긋나는 일, 곧 이 시리즈가 이름과 내용의 어긋남이라고 부른 줄기로 묶였다.

그런데 사례마다 대처를 고르면서 따진 물음은 그 줄기와 따로 있었다. 실패를 겪을 때마다 이 실패를 더 일찍 볼 수는 없었는지 따졌고, 대처를 정할 때마다 그 일을 플랫폼이 막을지 학습 코드가 막을지를 정해야 했다. 두 물음은 학습 플랫폼을 운영하는 쪽이 어떤 사례에서든 다시 만나는 것이라, 이 편에서는 네 편의 사례를 이 두 축으로 다시 묶어 본다. 실패를 언제 드러낼 수 있을까. 실패가 났을 때 플랫폼은 어디까지 흡수해야 할까.


실패를 언제 드러내는가

원인과 실패 사이의 거리

네 편의 사례에서 잃는 시간을 정한 것은 원인이 생긴 때와 실패가 드러난 때 사이의 거리였다. 빌드에서 생긴 원인이 학습 중에 드러나면 그 사이에 쓴 GPU 시간을 잃고, 학습 도중 생긴 원인이 다음 검증에서 드러나면 마지막 체크포인트 이후의 학습을 다시 돌려야 한다.

사례 원인이 생긴 때 처음 드러난 때 지금 드러나는 때
빈 패키지 이미지 빌드 학습 시작 직후 빌드 안의 import
protobuf 하향 이미지 빌드 학습 잡 기동(재시도 끝 실패) 빌드 안의 대시보드 기동 확인, 베이스 계약 대조
numba 버전 변화 이미지 빌드 첫 epoch 뒤 지표 계산 모든 모듈을 불러 보는 점검. 호출해야 컴파일되는 커널은 그대로 첫 검증
기준 지표 이름 학습 설정 첫 검증 첫 검증
옮겨진 검증 데이터 학습 도중 다음 검증 다음 검증

빌드에서 생긴 원인은 빌드 안에서 드러나게 할 수 있었다. 이미지 안의 내용은 빌드가 끝나면 정해지므로, import 한 줄과 베이스 계약과의 대조, 모든 모듈을 불러 보는 점검처럼 이미지만 보고 하는 확인으로 학습을 걸기 전에 멈출 수 있다. 학습 설정과 검증 경로에서 생긴 원인은 검증이 돌아야 드러나서, 지금은 첫 검증보다 앞당기지 못했다. 학습 도중 바뀐 데이터는 거리가 가장 멀다. 검증과 검증 사이에 학습 루프는 검증 데이터를 읽지 않으니, 데이터가 옮겨진 때와 실패한 때 사이는 검증 주기만큼 벌어지고 그동안 학습은 정상으로 보인다.

오류 없이 지나간 실패

늦게라도 오류로 멈추는 실패는 그나마 나았다. 더 나빴던 것은 오류 없이 결과만 틀리거나 빠진 채 지나간 실패이고, 흔히들 이것을 조용한 실패라고 부른다. KFP 캐시는 데이터 내용이 바뀌어도 가리키는 이름이 같으면 이전 run의 결과를 아무 경고 없이 재사용했다(9.1.1편의 실험 2). MLflow는 지표 이름 하나가 규칙을 어기자 검증 지표 묶음을 통째로 거부했는데, 학습은 검증 지표 없이 그대로 돌았다(8.1.2편의 지표 이름 사례). 비동기로 올린 이미지는 업로드에 실패했는데도 run에는 이미지를 기록했다는 태그만 남았다(9.1.2편의 기록 경로 사례). PV에 건 읽기 전용은 일부 노드의 컨테이너 안에서 rw로 보였고, 따로 확인하기 전에는 보호가 빠진 줄 알 수 없었다(PV의 ro 사례).

조용한 실패는 비용도 더 크다. 오류로 멈추는 실패는 시간을 잃을 뿐 잘못된 결과를 남기지 않는다. 조용한 실패는 학습이 끝까지 돌아 겉으로는 완료된 모델을 남기지만, 그 모델은 의도한 모델이 아닐 수 있다. 종료 단계의 best 업로드는 로컬 파일 확인 조건에 걸려 오류 없이 건너뛰었고, 로직상으로도 마지막 epoch의 체크포인트를 best 이름으로 바꿔 올리게 돼 있어 best라는 이름의 모델이 가장 좋은 모델이라는 보장이 없었다(9.1.2편의 체크포인트 사례). KFP 캐시가 이전 run의 결과를 재사용하면 바뀐 데이터로 학습했다고 믿은 모델이 실제로는 옛 데이터의 결과다. 검증 지표가 MLflow에서 빠지면 그 모델을 다른 학습과 비교할 근거가 사라지고, 조기 종료 기준 이름이 틀리면 의도한 시점에 멈추지 않은 모델이 남는다(겪지는 않았고 MMEngine 소스로 확인했다). 그래서 알아챈 뒤에는 학습 전체를 다시 돌려야 하고, 알아채지 못하면 그 모델이 비교와 배포 판단에 그대로 쓰인다. 검증 지표가 빠진 것도 MLflow 화면을 들여다본 뒤에야 알았다.

그래서 실패를 앞당기기 전에 먼저 실패가 드러나게 만드는 일이 앞선다. 지표 이름 사례에서 기준 이름을 변형으로 맞춰 주지 않은 것도, best가 엉뚱한 지표로 조용히 골라지는 것보다 첫 검증의 KeyError가 낫다고 봐서다. KFP에서 다이제스트 단계의 캐시를 끄고 데이터의 내용 다이제스트를 단계 입력으로 넘기면 바뀐 데이터는 이전 결과를 재사용하지 않고 다시 실행되는데(9.1.1편의 내용 다이제스트), 이것도 조용한 재사용을 드러나는 재실행으로 바꾸는 일이다.

참고: 조용한 실패가 더 무섭다는 생각은 GPU ECC 글을 쓰며 처음 정리했다. 그 글에서는 crash는 최소한 멈추기라도 하지만 silent corruption은 티 안 나게 결과만 틀어진다고 보고, ECC의 가치를 “오류를 안 낸다”가 아니라 “오류를 알 수 있다”로 정리했다. ECC는 고칠 수 있는 단일 비트 오류는 조용히 고쳐 학습을 잇게 하고, 고칠 수 없는 오류는 Xid로 드러내 오염된 채 수렴하는 최악을 막는다. 실제로 VRAM에 고칠 수 없는 오류가 난 장애 사례에서는 분산 학습이 죽었는데, ECC가 없었다면 죽지 않고 오염된 채 수렴했을 것이라 학습이 죽은 것 자체를 ECC의 성과로 봤다. 이 시리즈의 사례는 하드웨어가 아니라 소프트웨어 층의 이야기지만, 플랫폼에 바라는 동작은 같다. 고칠 수 있는 실패는 흡수하되 남기고, 고칠 수 없는 실패는 조용히 지나가지 않게 멈춘다.


실패가 났을 때 어디까지 흡수하는가

여기서 흡수는 실패를 학습 코드 대신 플랫폼이 받아 처리하는 일을 말하고, 갈래가 둘이다. 하나는 실패의 원인을 플랫폼 쪽에서 막는 대처이고, 다른 하나는 막지 못한 실패를 받아 학습을 잇는 처리다. 무엇을 받아 처리할지가 범위의 문제이고, 받아 처리한 실패를 어떻게 남길지가 숨김의 문제다.

받아 처리하는 방식은 실패의 성격에 따라서도 갈린다. GPU ECC 장애 글에서 정리했듯 하드웨어 결함 같은 인프라성 실패는 건강한 GPU에서 다시 돌리면 성공할 여지가 커서 플랫폼이 재시도로 받아 처리할 수 있다. 그러나 이 시리즈의 실패는 대부분 같은 이미지와 설정, 데이터로 다시 돌리면 똑같이 실패하는 쪽이라, 재시도보다 원인을 막는 대처가 중심이었다.

학습 코드를 몰라도 할 수 있는 일

플랫폼 쪽 역할의 경계는 학습 코드를 건드리지 않는 데 있다. 학습을 띄우는 드라이버와 실행 래퍼, 기록과 업로드를 맡는 훅처럼 학습을 감싸는 코드는 플랫폼 몫이고, 모델과 데이터 처리, 평가 지표 계산은 학습 코드의 몫이다(8.1.1편의 플랫폼 구성). 그래서 플랫폼이 어떤 대처를 맡을지는 결국 학습 코드와 지표 구조를 몰라도 할 수 있는지로 갈렸다. 다시 빌드해도 버전이 바뀌지 않게 constraints 파일을 두고 모든 모듈을 불러 보는 점검은 학습 코드를 읽지 않고 걸 수 있었다(8.1.2편의 numba 사례). 기록 훅의 이름 치환은 지표의 뜻을 몰라도 MLflow의 이름 규칙만 알면 됐다. best 파일을 객체 저장소로 옮겨 남기는 일(9.1.2편의 체크포인트 사례)과 실패한 run의 traceback 전문을 MLflow 아티팩트로 남기는 일(numba 사례)도 학습 내용과 무관했고, 공유 데이터의 읽기 전용도 파드 쪽 볼륨 설정에 걸면 됐다.

반대로 무엇을 감시해 best와 조기 종료를 정할지는 모델이 무엇을 잘해야 하는지에 대한 판단이라 학습 설정에 두었다(감시 지표 사례). 설정한 기준 이름이 지표 묶음에 있는지 학습 전에 확인하는 일은 학습 코드마다 다른 지표 이름 구조를 알아야 해서 넣지 않았고, ML 엔지니어에게 import 위치나 requirements를 맞춰 달라고 하는 것도 과하다고 봤다. 검증 데이터가 학습 중에 바뀌지 않게 하는 것은 데이터를 관리하는 쪽의 규칙으로 남겼다.

이 경계는 처음부터 지금과 같지 않았다. 학습 레포 쪽에 있던 실행 래퍼와 훅은 플랫폼이 맡을 일이라 보고 플랫폼 라이브러리로 옮겨 왔다(플랫폼 구성). 그 뒤에도 MLflow 기록은 플랫폼이 MMEngine 훅 안에서만 하고 있어서 ML 코드가 자기 지표를 남기려면 플랫폼에 요청해야 했고, 그래서 ML 코드가 직접 부르는 공개 함수로도 열었다(감시 지표 사례). 체크포인트 설정도 플랫폼이 기본값을 주는 쪽으로 적어 두었다가, 감시 지표와 저장 간격 같은 설정을 학습 설정으로 넘겼다. 학습 프레임워크에 맞춘 코드를 늘릴수록 플랫폼이 맡는 범위는 넓어지지만, 그만큼 프레임워크가 바뀔 때마다 따라가야 한다.

흡수하되 숨기지 않기

맡는 범위가 넓어지면 실패를 흡수하고 싶어진다. 막지 못한 실패를 받아 학습을 잇는 가장 쉬운 방법은 예외를 잡아 학습을 계속 돌게 하는 것인데, 잡은 예외를 어디에도 드러내지 않으면 학습은 완료된 것처럼 끝나고 남은 모델은 앞 절에서 본 조용한 실패의 결과물이 된다. 분산 검증이 실패했을 때 검증 루프의 예외를 잡아 경고만 남기고 학습을 이어 가는 방법을 버린 것도 그래서다. 그렇게 했다면 학습은 끝까지 돌았겠지만 검증 지표 없이 끝난 모델이 남았을 것이다(8.1.2편의 결과 수집 사례).

기록은 두 방향으로 실패했다. 업로드 훅의 초기화 누락과 업로드 라이브러리 누락에서는 기록 보조 기능의 예외가 학습 본체까지 올라와 학습을 멈췄고, 비동기 이미지 업로드는 반대로 실패를 삼켰다(9.1.2편의 기록 경로 사례). 그래서 업로드가 실패하면 경고와 run 태그를 남기고 학습은 계속하게 바꿨다. 학습을 멈추지 않는 것과 실패를 숨기지 않는 것은 함께 지켜야 한다.

과한 추상화가 만든 조용한 실패

실패를 흡수하려다 플랫폼이 오히려 실패를 만들기도 했다. 돌아보면 조용한 실패 가운데 몇은 플랫폼 코드가 여러 기록을 한 호출이나 한 곳으로 묶어 처리하다 만든 것이었다. 기록 훅은 검증 지표를 한 번의 호출로 묶어 보냈는데, 이름 하나가 규칙을 어기자 묶음 전체가 거부돼 나머지 지표까지 사라졌다(8.1.2편의 지표 이름 사례). 그 문제를 막으려고 모든 이름을 한곳에서 바꾸는 치환을 넣자 MLflow에만 있는 두 번째 이름이 생겼는데, 두 이름의 대응은 어디에도 남지 않았다. 두 이름이 갈린 사실은 그 이름을 설정에 넣은 학습이 첫 검증에서 멈추고서야 드러났다. 플랫폼 라이브러리는 이미지 기록에 MLflow fluent API의 비동기 기본값을 그대로 써서 업로드를 큐에 넣고 바로 태그를 달았고, 업로드가 실패해도 기록은 성공한 것처럼 보였다(9.1.2편의 기록 경로 사례).

공통점은 여러 기록을 하나로 묶어 처리한 곳에서 실패가 커지거나 가려졌다는 것이다. 지표를 한 번에 보내는 편이 간단하고, 이름을 한곳에서 고치는 편이 편하고, 기록을 비동기로 넘기는 편이 학습을 덜 기다리게 한다. 플랫폼은 여러 학습 코드를 같은 방식으로 다뤄야 하니 크게 묶어 추상화해야 하지만, 묶는 단위가 커질수록 그 안의 실패 하나가 묶음 전체를 실패시키거나 묶음 안에 가려진다. 묶음이 실패를 막지 못하고 가리게 되는 과한 추상화를 경계해야 한다. 그렇다고 묶지 않을 수도 없으니, 크게 묶는 것과 작게 나누는 것이 함께 필요하다는 얼핏 모순된 결론이 된다. 둘을 함께 지키려면 학습 코드가 부르는 인터페이스는 크게 묶되, 실패가 일어나고 드러나는 단위는 작게 나눠야 한다고 본다. 지표 하나가 거부되면 그 지표만 빠지고, 업로드 하나가 실패하면 그 업로드가 run에 표시되는 식이다.

경계를 지키는 비용

경계를 지키는 데에도 비용이 들었다. 학습 코드를 고치지 않으려다 바람직하지 않은 대처에 머문 경우가 있었다. 읽기 전용 마운트에 막힌 색인 캐시는 데이터를 내보내는 쪽이 색인을 함께 내보내거나 학습 코드가 캐시를 쓰지 않게 하는 것이 맞는데, 플랫폼은 별도 작업으로 다른 쪽이 관리하는 공유 데이터에 캐시를 미리 써 넣었다(9.1.2편의 색인 캐시 사례). 분산 검증이 실패하던 동안 검증 시작 epoch를 최대 epoch보다 크게 잡아 둔 것도 실패를 피하는 대신 검증 지표로 best를 고르는 기능까지 끈 임시 대처였다(8.1.2편의 결과 수집 사례).

반대로 학습 코드를 건드리지 않으려면 오히려 학습 코드와 그 주변을 깊이 알아야 했다. 캐시를 대신 만들려면 사내 아카이브 유틸리티가 캐시를 어디에 어떤 포맷으로 쓰고 언제 다시 만드는지, 기존 캐시 파일의 소유자와 권한이 어떤지까지 맞춰야 했다. 기록 훅의 이름 치환도 평가기가 운영점 표기에 @를 붙이는 관행을 알고 나서야 원인을 찾았고, best 파일을 객체 저장소로 옮기는 일도 MMEngine의 best 저장 훅이 새 best가 나오면 이전 파일을 지우는 방식(소스)을 그대로 따라야 했다. 학습 코드를 몰라도 되는 일이라는 기준은 플랫폼이 계속 맡을 일을 고르는 기준이지, 장애를 풀 때 학습 코드를 몰라도 된다는 뜻은 아니었다. 사내 코드와 그것이 기대는 프레임워크의 관행을 이해하고 그에 맞춘 해법을 내는 일이 플랫폼 쪽 일의 적지 않은 부분을 차지했다.


남은 고민

남은 고민은 두 질문이 충돌하는 곳에 있다. 학습을 시작하자마자 검증을 몇 배치 돌리면 결과 수집과 평가 모듈의 컴파일이 첫 epoch 전에 지나가고, 호출해야 컴파일되는 numba 커널도 함께 지나간다(8.1.2편의 앞당기는 방법). 검증 경로의 실패를 가장 일찍 드러낼 방법이지만, MMEngine 0.10.5에는 이 옵션이 없어 플랫폼이 학습 루프를 돌리는 Runner와 평가기 구성까지 다뤄 검증 루프를 직접 불러야 한다. 맡는 범위의 기준은 학습 코드를 몰라도 되는 일인데, 일찍 드러내려면 프레임워크 안으로 한 걸음 더 들어가야 하는 셈이다. 학습 코드가 점검용 진입점을 하나 내놓고 플랫폼은 학습 전에 그것을 부르기만 하는 약속으로 나누는 방법이 있지만, 시험하지 않았다.

학습 중에 옮겨진 검증 데이터도 같은 문제를 안고 있다. 검증 데이터가 그대로 있는지 epoch마다 확인하려면 설정에 적힌 파일 목록을 알아야 해서 기준을 넘는다. 학습 코드를 몰라도 되는 쪽에서 할 수 있는 것은 검증 데이터만 run 전용 사본으로 떠 두거나, 덮어쓰지 않는 버전 이름으로 데이터를 가리키게 하는 것인데(9.1.1편의 바뀌지 않는 이름), 둘 다 적용하지 않았다.

색인 캐시도 근본 대처가 남았다. 데이터를 내보내는 쪽이 색인을 함께 내보내거나 학습 코드가 읽기 전용 데이터에서는 캐시를 쓰지 않게 하는 일은 플랫폼 밖에서 정해야 하고, 그전까지는 새 데이터가 들어올 때마다 별도 작업을 다시 돌려야 한다.

MLflow 기록에도 남은 과제가 있다. 비동기 큐로 올린 이미지의 실패는 아직 run에 드러나지 않고, 기록 훅이 이름을 바꾼 지표의 대응표도 남기지 않는다. 사람들이 실제로 보는 것은 MLflow 화면이라, 이름이 바뀐 사실과 빠진 기록이 그 화면에 보여야 한다. 실패한 run의 traceback을 남기는 장치가 있어도 잡의 상태 메시지에는 원래 예외가 잘려 남아, 실패를 사후에 찾는 관측도 과제로 남았다(9.1.2편의 옮겨진 검증 데이터 사례).


정리

네 편의 사례를 두 질문으로 다시 묶어 보니, 플랫폼이 실패를 일찍 드러낸 곳은 모두 빌드와 점검, 기록, 저장소처럼 학습 코드를 몰라도 되는 층이었다. 검증 경로와 학습 도중의 데이터처럼 학습 안쪽에서 생기는 실패는 그 층에서 당기지 못했고, 당기려 하면 맡는 범위가 학습 프레임워크 쪽으로 넓어졌다.

질문 맡은 일 맡지 않은 일 남은 일
언제 드러내는가 빌드 안 import, 베이스 계약 대조, 모든 모듈 점검 기준 지표 이름의 사전 확인 학습 시작 직후 검증 몇 배치, 옮겨진 검증 데이터의 감지
어디까지 흡수하는가 빌드 재현성, 기록 이름 치환, best 보존, 실패 run의 traceback 보존, 파드 쪽 읽기 전용 감시 지표 선택, import 위치와 requirements, 데이터 내용의 불변 비동기 기록 실패의 표시, 지표 이름 대응표, 잘리는 원래 예외의 관측

다만 그 층에 머물기 위해서도 사내 코드와 프레임워크의 관행을 깊이 알아야 했고, 경계를 지키느라 임시 대처에 그친 일도 있었다. 여러 기록을 하나로 묶어 처리한 곳에서는 플랫폼이 스스로 조용한 실패를 만들기도 해서, 인터페이스는 크게 묶되 실패의 단위는 작게 나누는 것이 함께 필요했다.

그래서 먼저 조용한 실패를 드러나게 하고, 다음으로 학습 코드를 몰라도 되는 층에서 감지를 당기며, 그 너머는 학습 코드와 약속을 정해 나눌 부분으로 남겨 둔다. 플랫폼이 맡는 범위는 좁게 두되 맡은 범위 안의 실패는 하나도 숨기지 않는 쪽이 운영하기에 낫다고 본다.

네 편을 이어 읽으면 학습이 이렇게나 자주 실패하나 싶을 수도 있다. 학습도 코드를 개발할 때처럼 별것 아닌 이유로 자주 멈춘다. 초기화 코드 한 줄이 빠져 epoch마다 학습이 죽거나, 지표 이름의 @ 하나로 검증 지표가 통째로 빠지거나, 데이터셋 디렉터리를 정리한 것만으로 며칠째 돌던 학습이 멈췄다. 그렇다고 ML 엔지니어들의 학습이 모두 이렇게 실패한 것은 아니다. 사례 가운데 적지 않은 수는 플랫폼을 만들며 직접 돌려 본 학습에서 났고, 그 실패들이 플랫폼이 어디까지 맡을지를 정하는 재료가 됐다.




hit count

댓글남기기