[Kubernetes] 머신러닝 플랫폼 엔지니어링: Kubeflow Pipelines와 학습 트러블슈팅 - 9.1.2. 학습 데이터 참조와 산출물 저장의 장애 사례

· 19 분 소요

MLOps › 데이터·MLOps › 학습·스터디

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

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


TL;DR

  • 운영하는 학습 플랫폼은 NFS 공유 스토리지의 학습 데이터와 S3 호환 객체 저장소·MLflow의 산출물을 모두 경로로 가리킨다. 그래서 경로 아래 데이터가 학습 도중 바뀌거나, 데이터 옆 파생 파일이 읽기 전용 마운트에 막히거나, 산출물을 올린 곳과 찾는 곳이 어긋나면 학습이 실패하거나 결과가 남지 않았다
  • 학습 도중 데이터셋 정리로 검증 데이터가 옮겨졌지만 학습 데이터만 읽는 학습 epoch는 그대로 이어졌고, 실패는 다음 검증에서야 드러났다(사례 1). 경로를 고쳐 재개한 학습은 아카이브 목차를 담은 색인 캐시가 옛 위치에만 남아 있어, 새 위치에 캐시를 쓰려다 읽기 전용 마운트에 막혔다(사례 3)
  • 읽기 전용은 파드 쪽 볼륨 설정(readOnly)에 걸어야 컨테이너 런타임 버전과 관계없이 컨테이너까지 전해진다. PersistentVolume(PV)의 ro 마운트 옵션에만 기댔을 때는 일부 노드에서 컨테이너 안 마운트가 rw로 보였는데, 노드별 runc 버전과 호스트 마운트의 ro가 걸린 수준이 함께 갈린 결과로 설명되지만 둘 다 확인하지 못했다(사례 5)
  • 산출물을 올리는 경로와 시점이 학습 프로세스 안 여러 곳(업로드 훅, 종료 훅, best 저장 훅)에 흩어져 있으면 실패 경로에서 산출물이 사라진다. 업로드 훅과 MLflow가 다른 경로를 써서 체크포인트가 MLflow 화면에 보이지 않았고, 가장 좋은 체크포인트(best)는 종료 단계에서 오류 없이 건너뛰거나 로컬 디스크에만 저장됐다(사례 6). 기록 보조 기능의 예외가 학습을 멈춘 일도 있었다(사례 7)
  • Kubeflow Pipelines(KFP)는 단계의 출력을 run 전용 경로에 올리고 성공한 단계의 출력만 등록해 산출물 경로가 갈리는 일을 줄인다. 그러나 파이프라인 밖에서 바뀌는 공유 볼륨의 내용은 막지 못하고, mount_pvc로는 파드 쪽 읽기 전용을 걸 수 없으며, 학습 도중의 체크포인트 업로드는 여전히 학습 루프 안의 업로드 훅이 맡는다(정리)


들어가며

9.1.1편에서는 KFP의 실행 캐시와 importer(이미 객체 저장소에 있는 경로를 아티팩트로 등록하는 기능)가 데이터가 같은지를 경로, PVC 이름, 아티팩트 ID로만 판단한다는 것을 로컬 클러스터에서 확인했다.

학습 플랫폼을 운영하면서 겪은 장애에도 같은 구조가 많았다. 8.1.1편의 학습 플랫폼 구성에서 학습 데이터는 NFS 공유 스토리지를 읽기 전용으로 마운트해 읽고 산출물은 S3 호환 객체 저장소와 MLflow에 남기는데, 둘 다 경로로 가리킨다. 데이터와 산출물을 경로로 가리킬 때 실제로 무엇이 어긋났을까. KFP의 구조였다면 어디에서 같은 일이 생기고, 무엇이 막아 줄까.


데이터 참조

사례 1: 학습 중에 옮겨진 검증 데이터

경로로 가리킨 검증 데이터가 학습 도중 다른 곳으로 옮겨지면서, 학습이 도는 동안 경로 아래 데이터가 그대로 있다는 전제가 깨졌다. 여러 날 걸리는 분산 학습이 수십 epoch째 돌다 실패했는데, 처음 눈에 띈 로그는 여러 프로세스에서 반복된 AttributeError였다. 아카이브 파일을 읽는 객체가 만들어지다 만 채 정리되면서 난 오류라 원래 예외는 잘려 있었다. 커널의 메모리 부족 종료(OOM), GPU 오류, NFS 오류는 노드 어디에도 없었다.

원인을 좁혀 준 것은 재개였다. 마지막 체크포인트에서 재개하자 이번에는 데이터셋을 만들 때의 존재 검사에서 몇 분 만에 검증 데이터 파일이 없다는 오류로 실패했다. 일시적인 읽기 오류가 아니라 그 경로에 파일이 더는 없다는 뜻이었다. 이후 로그 저장소에서 처음 실패한 학습의 원래 예외를 다시 찾았고, 같은 파일이었다.

# 로그 저장소에서 찾은 원래 예외 두 줄
FileNotFoundError: [Errno 2] No such file or directory: '/mnt/<공유 스토리지>/<데이터셋>/<검증 데이터>'
FileNotFoundError: Caught FileNotFoundError in DataLoader worker process 0.

첫 줄은 파일을 열다 난 예외이고, 둘째 줄은 데이터로더의 작업 프로세스에서 난 그 예외를 학습 프로세스가 다시 던진 것이다.

다음은 파일이 왜 없어졌는지였다. 데이터셋 디렉터리의 수정 시각이 직전 검증과 실패 시점 사이였다. 설정에 적힌 파일 목록을 스토리지와 대조하자 학습 파일은 모두 있고 검증 파일만 모두 없었다. 이후 전체 검색을 통해 같은 이름의 파일이 새 디렉터리에 모두 있는 것을 확인했다. 학습 설정은 검증 데이터를 경로로 가리켰는데, 직전 검증이 끝난 뒤 데이터셋 정리로 검증 데이터가 새 디렉터리 구조로 옮겨지고 옛 위치에서는 지워진 것이다. 검증과 검증 사이에 학습 루프가 읽는 것은 학습 데이터뿐이라, 검증 데이터가 옮겨진 뒤에도 학습 epoch는 아무 이상 없이 이어졌고 실패는 다음 검증이 검증 데이터를 여는 시점에야 났다. 검증 데이터가 사라진 사실은 사라진 순간이 아니라 그다음 검증에서 드러나고, 그 사이의 학습은 정상으로 보인다.

남은 것은 학습을 어떻게 이어 갈지였다. 데이터를 옛 위치로 되돌리거나, 설정을 새 위치로 바꾸거나, 검증 데이터를 남아 있는 다른 해상도의 변형으로 바꾸는 길이 있었다. 마지막 길은 데이터 조건이 달라져 앞선 epoch와 지표를 비교할 수 없게 된다. 없어진 검증 데이터만 빼고 학습을 계속하는 방법은 검증 데이터 전체가 빠진 데다 best 선택과 조기 종료가 모두 검증 지표에 걸려 있어 성립하지 않았다.

그래서 설정의 검증 데이터 경로만 새 위치로 바꾼 설정 파일을 덮어 다시 재개했다. 파일 이름이 같으니 내용도 같다고 보고 앞선 epoch와 지표를 비교할 수 있다고 판단했는데, 이 판단은 데이터 소유자의 확인이 필요한 전제였다.

안타깝게도 이 재개는 아래 옛 위치에 남은 색인 캐시 문제로 한 번 더 실패했다.

KFP에서도 같은 틈이 생긴다. 9.1.1편의 실행 중 데이터 변경 실험에서 공유 PVC의 val 디렉터리를 학습 중에 옮긴 파이프라인은 학습 뒤 검증 단계에서 FileNotFoundError로 실패했다. 시작할 때 run 전용 사본을 뜨는 파이프라인만 영향을 받지 않았는데, 수 TB 데이터를 run마다 복사하기는 어렵다. 다만 이 사례에서 옮겨진 검증 데이터는 아카이브 수로 학습 데이터의 수십분의 일이라, 검증 데이터만 run 전용 사본으로 뜨는 방법은 복사 부담이 작다고 본다. 이 방법은 시험하지 않았다.

내용 다이제스트(파일 내용으로 계산한 해시)를 학습 전에 계산해 두고 검증 단계에서 대조하는 방식은 실패 이유를 밝혀 주지만 실패 시점은 같다. 달라지는 것은 실패 뒤 다시 돌릴 범위다. 학습 루프 안에서 검증하면 마지막 체크포인트 이후의 학습을 다시 돌려야 한다. 반면 그 실험의 파이프라인처럼 검증이 학습 뒤의 별도 단계라면 학습 단계는 이미 성공해 출력이 등록돼 있다.

결국 학습이 쓰는 동안 데이터가 바뀌지 않게 하는 규칙이 필요하고, 바뀌지 않는 이름이 그 방향이다. 실패를 사후에 찾는 관측도 과제로 남았다. 잡의 상태 메시지는 학습을 띄운 드라이버 프로세스 로그의 끝부분만 담아 원래 예외가 잘려 있었다. 로그 저장소도 로그를 수집한 시각과 로그가 찍힌 시각이 달라 시간 범위로 찾기가 어려웠다.

사례 2: 데이터 준비의 전제

경로가 있어도 그 아래 데이터의 모양이 학습 코드의 전제와 다르면 실패했고, 그 실패 메시지가 원인을 말해 주지 않는 경우가 많았다.

  • 디렉터리 레이아웃: 학습 진입 스크립트는 <데이터 루트>/<장면>/을 전제했는데 실제 데이터는 그 사이에 버전 디렉터리가 한 단계 더 있었다. 내용은 건드리지 않고 디렉터리만 평탄화해 진입 스크립트의 전제에 맞춘 뒤 데이터 소유자에게 알렸다
  • 빈 데이터: 기본 평가 그룹 가운데 하나가 아직 올라오지 않은 센서 데이터를 요구해 장면 수십 개가 모두 실패했다. 그 그룹을 빼고 돌린 뒤 데이터가 올라오면 다시 켜기로 했다
  • 샤딩 전제: 학습 코드는 아카이브 파일을 프로세스(rank)마다 나눠 주므로 파일 수가 프로세스 수보다 적으면 일부 rank가 빈 설정을 받는다. 처음에는 IndexError: list index out of range만 남아 원인을 짐작할 수 없었다. 이 전제를 명시적으로 검사하게 바꾼 뒤에는 GPU 수를 늘리고 아카이브 수 인자를 그대로 둔 실행이 아카이브 수가 프로세스 수보다 적다는 ValueError로 바로 실패했다

책 머신러닝 플랫폼 엔지니어링 7.3절처럼 파이프라인에 분할 단계를 두면 데이터의 모양을 처음 만지는 것은 그 단계다. 스터디에서 공유된 실습 기록에도 이 절의 데이터 준비 코드에서 같은 일이 있었다. 원본 코드는 데이터가 MINIDATA 디렉터리에 있다고 전제했는데 내려받은 전체 데이터셋에는 그 디렉터리가 없었다. 그대로 두면 파일 목록이 비어 분할 단계에서 ValueError가 날 상황이었다. 공유된 실습에서는 이미지와 라벨을 따로 모은 뒤 정렬 없이 나누던 부분도 이름 기준으로 짝을 맞춰 나누도록 고쳤다. 개수와 짝, 레이아웃 검사를 분할 단계에 두면 학습 단계까지 가지 않고 실패한다. 샤딩 전제에서 겪었듯 데이터의 모양에 대한 전제는 명시적으로 검사해야 실패 메시지가 원인을 말해 준다.


파생 파일과 읽기 전용 마운트

옮겨진 검증 데이터로 재개한 학습은 옛 위치에 남은 색인 캐시 때문에 한 번 더 실패했다. 쓰기 권한이 있는 작업이 색인 캐시를 미리 만들어 두는 대처에는 두 조건이 따른다. 미리 만든 캐시를 학습 이미지의 유틸리티가 읽을 수 있어야 하고(캐시 포맷), 학습 쪽 마운트가 실제로 읽기 전용이어야 데이터가 지켜진다(PV의 ro 옵션).

사례 3: 읽기 전용 마운트에 쓰는 색인 캐시

학습은 데이터를 읽기만 한다는 전제와 달리, 학습 코드가 데이터 옆에 색인 캐시를 쓰려다 읽기 전용 마운트에 막혔다. 실패는 학습이 데이터로더를 만드는 단계, 즉 첫 iteration 전에 났다.

# 데이터로더를 만드는 중에 난 예외
OSError: [Errno 30] Read-only file system: '/mnt/<공유 스토리지>/<데이터셋>/<아카이브 이름>.<캐시 확장자>'

같은 실패를 여러 번 만났다. 새로 들어온 아카이브가 섞인 데이터셋으로 학습할 때, 작은 데이터셋으로 돌릴 때, CPU로 기동만 확인하는 점검 때, 그리고 검증 데이터가 옮겨진 뒤의 재개 때다.

학습 데이터는 이미지와 라벨을 tar 아카이브로 묶어 둔 것이다. 학습 코드가 쓰는 아카이브 유틸리티는 아카이브를 처음 열 때 만든 목차(각 파일의 이름, 크기, 시작 위치)를 아카이브 옆에 같은 이름의 캐시 파일로 남긴다. 다음부터는 이 색인 캐시를 읽어 목차를 다시 만들지 않고 시작 위치로 바로 찾아간다. 캐시가 없는 아카이브를 열면 이 파일을 쓰려 하는데, 학습 파드는 데이터를 읽기 전용으로 마운트하므로 쓰기가 실패한다. 캐시를 쓰는 동작은 학습 코드에 고정돼 있어 설정으로 끌 수 없었고, 데이터 자체는 손상되지 않았다. 그동안 드러나지 않은 것은 학습이 읽은 아카이브에 캐시가 이미 있었기 때문이고, 그 캐시를 어느 실행이 만들었는지는 확인하지 못했다. 재개 사례에서는 데이터만 새 위치로 옮겨지고 캐시는 옛 위치에 남아 같은 오류가 났다.

tar는 zip처럼 끝에 전체 파일 목록을 따로 두지 않고, 파일마다 앞에 이름과 크기를 적은 헤더를 붙여 차례로 이어 쓴다(GNU tar 문서). 그래서 아카이브 안의 이미지 하나를 바로 꺼내려면 그 파일이 아카이브의 어디서 시작하는지 알아야 하고, 그 위치를 알려면 처음부터 헤더를 하나씩 따라가야 한다. 파일 내용은 건너뛰더라도 헤더는 파일 수만큼 읽어야 하므로, 파일이 많은 아카이브일수록 목차를 만드는 데 시간이 든다. 학습 코드가 아카이브 유틸리티를 사용하는 것은 데이터셋을 열 때마다 이 비용을 치러야 하는 일을 피하기 위함이다.

잘못 판단한 적도 있다. 새로 들어온 아카이브가 섞인 데이터셋으로 학습을 걸기 전에 캐시가 없다는 경고를 봤다. 같은 데이터셋 디렉터리로 다른 학습이 끝까지 돌았으니 캐시가 없어도 유틸리티가 알아서 처리할 것이라고 보고 넘어갔다. 그런데 그 학습이 읽은 아카이브에는 이미 캐시가 있었고 새로 들어온 아카이브에는 없었다.

그래서 학습 마운트는 읽기 전용으로 두고, 쓰기 권한이 있는 별도 작업이 캐시를 미리 만들게 했다. 읽기 전용은 데이터 자체가 아니라 파드마다 거는 마운트 설정이다. 노드에는 공유 스토리지가 읽기·쓰기로 붙어 있고, 학습 파드는 그 경로를 읽기 전용으로 마운트한다. 캐시를 만드는 작업은 같은 경로를 읽기 전용 설정 없이 마운트해 쓸 수 있었다. 이 작업은 학습 이미지를 그대로 써서 학습이 읽을 유틸리티와 같은 버전으로 캐시를 만들었고, 아카이브마다 캐시가 따로라 여러 스레드로 나눠 돌렸다.

학습 마운트를 읽기·쓰기로 바꾸는 방법은 여러 프로세스가 같은 캐시를 동시에 쓰다 경쟁하거나 데이터셋을 오염시킬 수 있어 택하지 않았다. 학습 파드 앞에 init 컨테이너를 두어 캐시를 만들게 하는 방법도 있지만, 캐시를 아카이브 옆에 쓰는 한 init 컨테이너에도 쓰기 마운트가 필요해 같은 문제가 init 단계로 옮겨 갈 뿐이다. rank마다 뜨는 파드의 init 컨테이너가 같은 캐시를 동시에 만들 수 있고, 학습을 걸 때마다 GPU를 잡아 둔 채 목차를 훑는 시간도 든다. 캐시를 파드 안의 임시 디렉터리에 쓰게 하면 공유 데이터는 지켜지지만, 유틸리티가 캐시를 아카이브 옆에서만 찾으니 학습 코드를 바꿔야 한다.

캐시를 미리 만들어도 되는지는 세 가지로 따졌다. 유틸리티는 아카이브를 읽기 모드로 열어 목차만 읽고 캐시가 생긴 아카이브의 수정 시각도 원본 그대로였으니, 원본은 손상되지 않고 파일만 더해진다. 아카이브가 나중에 바뀌어도 읽을 때 캐시에 적힌 크기와 실제 크기를 비교해 다르면 다시 만들므로, 크기가 바뀌는 변경은 자동으로 무효화된다. 생성이 중간에 끊기면 쓰기가 원자적이지 않아 깨진 캐시가 남지만, 다음 읽기가 예외로 실패하므로 조용히 틀리지는 않는다. 그래도 생성 뒤 캐시를 하나씩 검증하기로 했다. 크기가 같은 채 내용이 바뀌는 경우를 빼면 실패가 모두 예외로 드러나서 미리 만드는 쪽을 택했다. 재개 사례에서는 옛 위치의 캐시를 새 위치로 복사했다.

KFP에서는 9.1.1편의 아티팩트 전달처럼 출력을 run 전용 경로에 쓰므로, 색인을 만드는 단계를 따로 두고 출력 아티팩트로 넘기면 데이터 디렉터리에 쓸 일이 없다. 다만 이 유틸리티는 캐시를 아카이브 옆에서 아카이브 이름으로만 찾기 때문에 학습 코드가 다른 곳의 색인을 읽도록 바꿔야 한다. 캐시를 크기로만 검증하는 것도 KFP 실행 캐시 키처럼 내용을 보지 않는 구조라, 크기가 같은 채 내용이 바뀌면 낡은 목차를 그대로 쓰게 된다. 이 경우를 겪지는 않았다.

읽기 전용 마운트는 데이터를 지켰고, 데이터 옆에 파생 파일을 쓰는 코드가 그 보호에 막혔다. 별도 작업으로 캐시를 미리 만든 것은 학습 코드를 건드리지 않는다는 경계 안에서 플랫폼이 할 수 있는 임시 대처였고, 바람직한 해결은 아니었다. 플랫폼의 작업이 다른 쪽이 관리하는 공유 데이터에 파일을 써 넣었고, 그러려고 학습 이미지 안 유틸리티의 캐시 포맷과 버전, 기존 캐시 파일의 소유자와 권한까지 맞춰야 했다. 미리 만든 캐시는 그 뒤에 들어온 데이터를 덮지 못해 같은 실패가 또 났다. 근본적인 방향은 데이터를 내보내는 쪽이 아카이브와 색인을 함께 내보내거나, 학습 코드가 읽기 전용 데이터에서는 캐시를 쓰지 않게 하는 것이라고 본다. 캐시를 임시 파일에 쓴 뒤 이름을 바꾸게 하면 생성이 끊겨도 깨진 캐시가 남지 않는데, 이번에는 어느 쪽도 적용하지 않았다.

사례 4: 데이터로 전파되는 캐시 포맷

학습 코드를 바꾸지 않으면 같은 데이터로 같은 결과가 나온다는 전제가 데이터 옆의 파생 파일 때문에 깨졌다. 앞서 통과했던 같은 데이터셋의 학습이 시작 30초 만에 AttributeError: 'dict' object has no attribute 'sort'로 실패했다.

공유 스토리지의 아카이브 옆에는 색인 캐시가 새 포맷으로 대량 생성돼 있었다. 새 버전 유틸리티는 목차 목록을 딕셔너리로 한 번 감싸 저장하는데, 학습 이미지 안의 옛 버전은 캐시를 읽어 그대로 목록으로 쓰다 sort에서 실패했다. 딕셔너리로 감싼 포맷은 새 버전만 쓰므로 새 버전 유틸리티로 만든 캐시였고, 쓰기 권한이 있는 작업 가운데 어느 것이 만들었는지는 확인하지 못했다. 이 캐시는 실패한 날 한꺼번에 만들어졌고, 전날 같은 데이터셋으로 통과한 학습 때는 아직 없었다. 두 버전 유틸리티는 버전 문자열이 같아 버전으로는 구분할 수 없었다.

그래서 이미지의 유틸리티를 새 포맷을 읽는 버전으로 바꾸고, 빌드할 때는 버전 문자열 대신 소스에 새 포맷을 처리하는 코드가 있는지를 검사하게 했다. 유틸리티를 커밋 해시로 고정하거나 캐시 파일에 포맷 버전을 적어 읽는 쪽이 모르는 포맷을 버리게 하는 방법도 있지만 적용하지는 않았다.

KFP로 옮겨도 이 실패는 코드 리뷰와 CI 어디에도 걸리지 않는다. 데이터 옆의 파일만 바뀌었기 때문이다. 9.1.1편의 KFP 캐시 키로 보면 이미지, 명령, 입력 경로가 모두 같다. 그러니 같은 이름의 파이프라인에서 캐시를 켠 채 다시 돌렸다면 이전의 성공 결과를 재사용해 문제를 가렸을 것이다. 데이터 옆 파생 파일의 포맷은 데이터와 함께 배포되는 형식이라 코드 쪽 검증이 보지 못한다.

사례 5: PV의 ro 옵션과 컨테이너 안의 마운트 플래그

PersistentVolume(PV)에 읽기 전용을 걸면 컨테이너 안도 읽기 전용이 된다는 전제가 노드에 따라 맞지 않았다. 공유 NFS를 여러 서비스가 읽기 전용으로 쓰게 하려고 정적 PV에 mountOptions: [ro]를 걸었다. 파드 쪽에는 읽기 전용 설정을 따로 두지 않았다. 확인할 때는 공유 저장소에 쓰기를 시도하지 않았다. 작은 파드를 노드마다 띄워 컨테이너 안 /proc/mounts의 ro/rw 표시만 읽었는데, 노드에 따라 결과가 달랐다.

구성 노드 호스트의 마운트 컨테이너 안
PV ro, 파드 쪽 설정 없음 노드 A: ro, 노드 B: 확인 못 함 노드 A: rw, 노드 B: ro
볼륨 수준 readOnly: true(인라인 NFS 볼륨의 nfs.readOnly) — 두 노드 모두 ro

노드 A에서는 kubelet(노드에서 파드를 띄우는 에이전트)이 호스트에 붙인 NFS 마운트가 ro였다. 노드 B는 호스트에 접속하지 못해 호스트 쪽 플래그를 확인하지 못했다. 호스트의 ro가 컨테이너까지 가는 길을 소스로 따라가면 네 단계가 관여한다.

  1. 커널의 bind 마운트: 컨테이너 런타임은 kubelet이 붙인 NFS 마운트를 컨테이너 안 경로로 bind 마운트한다. bind 마운트는 이미 마운트된 경로를 다른 경로에 한 번 더 붙이는 것이다. 커널의 clone_mnt는 이렇게 만든 새 마운트에 원래 마운트의 플래그를 복사하므로 ro가 따라간다(Linux v6.8 소스).
  2. containerd: 파드 쪽에 readOnly가 없으면 containerd의 CRI 플러그인(kubelet의 요청을 받아 컨테이너를 만드는 부분)이 이 마운트의 옵션에 rw를 넣는다(containerd v1.7 소스).
  3. runc: runc 1.2부터는 bind 마운트에 옵션이 지정돼 있으면 다시 마운트해 그 옵션을 적용한다. rw처럼 플래그를 지우는 옵션도 대상이라 이때 마운트의 읽기 전용 플래그가 지워진다(runc v1.2.0 소스). runc 1.1은 플래그를 지우는 옵션만 있을 때는 다시 마운트하지 않아 복사된 ro가 남는다(runc v1.1.12 소스).
  4. 커널의 재마운트와 /proc/mounts: -o ro 마운트는 마운트 단위 플래그와 파일 시스템 단위(슈퍼블록) 플래그를 함께 켠다. 그런데 runc의 bind 재마운트는 마운트 단위 플래그만 바꾼다(Linux v6.8 소스). /proc/mounts는 둘 가운데 하나라도 읽기 전용이면 ro로 표시한다(proc_namespace.c, namespace.c). 그래서 -o ro로 붙인 NFS라면 runc 1.2를 거쳐도 컨테이너 안이 ro로 남는다. 노드 A의 컨테이너 안이 rw였다면 호스트 마운트의 ro가 마운트 단위에만 걸려 있었다는 뜻이다.

노드마다 결과가 갈린 것은 호스트 마운트의 ro가 걸린 수준과 노드별 runc 버전이 함께 갈린 결과로 설명되지만, 둘 다 확인하지 못했다. 마운트 단위 플래그와 슈퍼블록 플래그는 호스트와 컨테이너의 /proc/self/mountinfo(proc_pid_mountinfo(5))에서 나눠 볼 수 있다.

PV 옵션이 아니라 파드 쪽 볼륨 설정에 걸어야 런타임 버전과 관계없이 컨테이너 안까지 읽기 전용이 된다. 볼륨 수준 readOnly를 걸자 두 노드 모두 컨테이너 안이 ro가 됐다. 실험은 인라인 NFS 볼륨의 nfs.readOnly로 했다. PVC로 붙일 때는 persistentVolumeClaim.readOnly: true와 컨테이너의 volumeMounts[].readOnly: true 가운데 하나만 걸어도 된다. kubelet은 둘 중 하나라도 켜져 있으면 런타임에 읽기 전용 마운트를 요청하고(Kubernetes v1.33.0 소스), 이때 containerd가 넣는 ro는 플래그를 켜는 옵션이라 runc 1.1과 1.2 모두 적용한다. 처음에는 읽기 전용은 PV가 막아야 한다고 보고 파드 쪽 설정을 일부러 비웠는데, 그 전제가 틀렸다.

PVC의 accessModes를 ReadOnlyMany로 두는 것도 바인딩 조건일 뿐 마운트 뒤의 쓰기를 막지 않는다(Kubernetes 문서). 더 강하게 막으려면 NFS 서버가 클러스터 노드에는 읽기 전용으로만 내보내게 하거나, 정책 엔진(Kyverno, Gatekeeper)으로 이 PVC를 붙이는 파드에 readOnly를 강제하는 방법이 있다. 둘 다 시험하지는 않았다.

KFP의 mount_pvc에는 이 설정을 걸 자리가 없다. kfp-kubernetes(KFP 파이프라인에 쿠버네티스 설정을 붙이는 확장 SDK) 2.17.3의 mount_pvc는 PVC 이름, 마운트 경로, 하위 경로만 받는다. 백엔드의 driver도 이 값으로 볼륨과 마운트를 만들 때 readOnly를 넣지 않는다(KFP 2.17.3 소스). 그러니 mount_pvc로 붙인 공유 데이터의 읽기 전용은 PV 옵션에 기대야 하고, 이 사례처럼 그 보호는 노드에 따라 빠질 수 있다. 색인 캐시 사례에서 데이터를 지킨 것도 파드 쪽 설정(volumeMounts[].readOnly)이었다.


산출물 저장

사례 6: 체크포인트와 best의 위치

산출물 쪽에서는 학습 코드가 올린 곳과 MLflow가 찾는 곳, 그리고 올리는 시점이 맞물린다는 전제가 깨졌다. 학습은 epoch마다 체크포인트를 객체 저장소에 올렸는데 MLflow 화면의 run 아티팩트에는 보이지 않았다. 8.1.2편의 분산 검증에서 실패한 학습은 MLflow에 설정 파일과 best 모델을 하나도 남기지 못했다. 검증 지표 기준으로 가장 좋은 체크포인트(best)를 저장하는 기능을 켠 뒤에는 객체 저장소에 best가 없었다. 세 경우의 원인은 각각 달랐다.

  • MLflow 화면에 보이지 않은 체크포인트: 체크포인트 업로드 훅(학습 루프가 epoch 끝마다 부르는 업로드 함수로, 플랫폼이 학습 실행에 붙인 것)은 실험 이름으로 만든 별도 경로에 올렸는데, MLflow는 run의 아티팩트 경로(<실험 ID>/<run ID>/artifacts/) 아래만 보여 줬다
  • 종료 단계에만 있던 업로드: 설정 파일과 best 모델을 MLflow에 올리는 코드가 학습이 정상으로 끝난 뒤 한 번 도는 종료 훅에만 있어서, 검증에서 실패하면 돌지 않았다. epoch 체크포인트는 업로드 훅이 따로 올려 남았고 지표도 iteration마다 기록돼 남았다. 정상으로 끝난 다른 학습에서도 best 모델은 올라가지 않았다. 종료 훅의 best 업로드는 마지막 epoch의 체크포인트(그 학습은 1 epoch였다)를 best 모델 이름으로 바꿔 올리는 로직이었는데, 로컬 파일이 있는지 확인하는 조건문이 거짓이 되어 오류 없이 건너뛰었다. 그 시점에 로컬 파일이 왜 없었는지는 확인하지 못했다
  • 로컬에만 남는 best: best 저장 기능은 best를 로컬 디스크에만 저장했고 업로드 훅은 epoch 체크포인트만 올렸으니, 학습이 끝나 파드가 사라지면 best도 함께 사라질 수 있었다

업로드 훅과 종료 훅은 학습 코드가 아니라 플랫폼이 학습을 감싸며 붙인 코드라 플랫폼 쪽에서 고쳤다(8.1.1편의 플랫폼 구성). 그래서 업로드 훅이 MLflow가 알려 주는 run의 아티팩트 경로를 쓰게 바꿨다. 이미 끝난 run의 체크포인트는 저장소 안에서 서버 측 복사로 새 경로에 복사했다. 업로드 훅이 best가 바뀔 때마다 동기화하도록 고친 뒤에는 객체 저장소에 best가 올라오는 것을 확인했다.

KFP에서는 단계의 출력 아티팩트를 run마다 정한 경로에 올리는 일을 launcher가 맡는다. launcher는 각 단계 컨테이너에서 사용자 코드를 감싸 실행하며 입출력 아티팩트를 내려받고 올리는 KFP 프로그램이다. 단계가 성공했을 때만 출력 아티팩트를 등록하고, 실패하면 로그 게시가 켜져 있을 때 실행 로그만 남긴다(소스). 그래서 학습과 검증을 한 단계에 넣으면 검증이 실패할 때 학습 산출물도 올라가지 않는다. 책 8장처럼 학습 컴포넌트와 검증 컴포넌트를 나누면 학습 단계의 출력 모델은 학습 단계가 끝날 때 올라가 검증이 실패해도 남는다. 다만 책의 검증 컴포넌트는 최종 검증이라 best와 조기 종료를 정하는 epoch별 검증은 학습 단계 안에 남는다.

launcher가 막지 못하는 것도 있다. launcher는 단계가 끝날 때 한 번 올리므로 epoch마다 쌓이는 체크포인트는 단계 안의 코드(이 플랫폼에서는 업로드 훅)가 올려야 한다. 그 경로를 그 코드가 따로 정하면 MLflow 화면에 보이지 않은 체크포인트와 같은 경로 분리가 다시 생긴다. 또 launcher는 자기 컨테이너의 로컬 경로에서 올리므로, 단계가 분산 학습 잡을 따로 띄우면 워커 파드에 저장된 체크포인트는 보지 못한다. 출력 파일을 만들지 않은 단계도 경고만 남기고 아티팩트를 등록한 채 성공하니(소스), best 파일을 만들지 못한 학습 단계가 성공으로 끝날 수 있다. 5주차 범위인 책 9.3절은 컴포넌트 안에서 MLflow에도 기록한다. 그러면 산출물이 KFP의 run 경로와 MLflow의 run 아티팩트 두 곳에 남아 어느 쪽을 정본으로 볼지 정해야 한다. 산출물을 어디에, 언제 올리는지가 학습 프로세스 안 여러 곳에 흩어져 있으면 실패 경로에서 산출물이 사라진다.

사례 7: 객체 저장소와 기록 경로

산출물을 남기는 기록 경로 자체가 학습을 멈추거나 기록을 비게 만든 경우도 있었다. 업로드 훅에 설정 플래그를 초기화하는 코드가 빠져 epoch가 끝날 때마다 AttributeError로 학습이 죽은 적이 있다. 설정 파일을 상시 올리는 기능을 처음 켰을 때는 이미지에 업로드용 라이브러리(boto3)가 없어 여러 실행이 ModuleNotFoundError로 시작하자마자 끝났다. 둘 다 기록 보조 기능의 예외가 학습 본체까지 올라와 학습을 멈춘 경우다.

반대로 실패가 드러나지 않은 경우도 있다. 자격 증명 없이 로컬에서 돌린 실행은 이미지 업로드에 실패했는데, run에는 이미지를 기록했다는 태그만 남았다. MLflow의 MlflowClient.log_image는 기본이 동기지만, 플랫폼 라이브러리가 부르는 fluent API mlflow.log_image는 synchronous 기본값이 False다(MLflow 3.16.0 소스). 그래서 이미지 업로드를 큐에 넣고 곧바로 이미지 태그를 달았고(소스), 업로드 예외는 기록 함수를 부른 코드까지 오지 않았다.

저장소 쪽에도 원인이 있었는데, 이 S3 호환 저장소는 몇몇 동작이 S3와 달랐다.

동작 이 저장소에서 대처
aws s3 cp로 큰 객체 복사 멀티파트 복사일 때 기본 동작이 원본의 태그를 조회(GetObjectTagging)하는데, 이 저장소가 태그 조회 요청을 MalformedXML 오류로 거부함 --copy-props none으로 태그 복사를 끔
boto3 1.36부터 기본으로 붙는 요청 체크섬 업로드가 MissingContentLength로 실패 환경 변수 AWS_REQUEST_CHECKSUM_CALCULATION=when_required
폴더 모양 경로로 업로드 경로마다 0바이트 디렉터리 표시 객체가 생김 MLflow 서버 목록 코드에 디렉터리 표시 객체를 거르는 조건 추가

디렉터리 표시 객체는 폴더처럼 보이게 하려고 끝에 /를 붙인 이름으로 만든 빈 객체다. MLflow 서버의 S3 목록 코드가 이 객체를 걸러내지 않아 목록에 섞였는데, 프론트엔드는 목록 처리 전체를 예외 처리 하나로 감싸 오류를 화면에 내지 않았다. 그래서 MLflow 화면의 이미지 그리드에는 “No results”만 보였다. 체크섬 문제는 이미지를 다시 빌드하며 boto3가 올라갈 때마다 다시 나타날 수 있다.

초기화 누락은 고치고 회귀 테스트를 붙였다. 의존성 누락은 이미지에 boto3를 넣는 것과 별도로, 기록 업로드가 실패하면 경고와 run 태그만 남기고 학습은 계속하도록 바꿨다. 이미지 그리드는 당시 어느 MLflow 릴리스에도 이 조건이 없어 서버 코드에 한 줄을 더해 해결했다. MLflow 3.16.0에서 같은 조건이 추가됐으니(소스) 지금이라면 서버 버전을 올리는 방법도 있다.

MLflow 서버를 거쳐 아티팩트를 올리도록 바꾸기 전에는 업로드 경로를 미리 시험했다. 앞단의 nginx 기반 게이트웨이가 기본 본문 크기 상한으로 2 MiB 업로드를 413(요청 본문이 너무 큼)으로 거절해 게이트웨이 상한을 올렸다. 상한 안의 업로드라도 동시에 몰리면 서버가 본문을 받아 두는 임시 저장 공간이 파드의 한도를 넘고, 그러면 kubelet이 파드를 축출(노드에서 내보냄)한다. 그래서 임시 저장 공간 한도도 함께 올렸다.

KFP에서는 출력 아티팩트 업로드를 launcher가 맡는다. 업로드에 실패하면 자격 증명을 다시 읽어 한 번 더 시도하고, 그래도 안 되면 그 단계를 실패로 끝낸다(호출, 재시도). 실패한 단계는 캐시 항목을 남기지 않으므로(소스) 다시 돌리면 학습부터 다시 한다. 업로드 실패를 경고로 낮춘 학습 플랫폼과는 반대 방향이다. 학습 코드 안에서 부르는 MLflow 기록은 launcher의 범위 밖이라, 그 실패를 어떻게 다룰지는 KFP를 써도 컴포넌트 안의 일이다.

공유된 실습 기록에서는 객체 저장소 쪽 한계가 파이프라인 실패로 드러났다. 책 7.3절의 데이터 준비 파이프라인이 다운로드와 압축 해제를 마친 뒤 아티팩트 업로드에서 status code: 500으로 실패했다. 그 환경의 객체 저장소 SeaweedFS(3.85)는 -volume.max=100 -master.volumeSizeLimitMB=64 인자로 떠 있어 64MB 볼륨 슬롯 100개가 모두 차 있었다. 슬롯 수가 인자로 정해져 있어 호스트 디스크나 PVC의 크기와는 관계가 없었다. 공유된 실습에서는 이 인자를 -volume.max=1000으로 올려 해결했다.

업로드가 실패했을 때 학습을 계속할지는 고를 수 있지만, 어느 쪽을 고르든 실패는 run에 드러나야 한다. 업로드 실패를 경고와 태그로 남기게 바꾼 뒤에도 비동기 큐로 올린 이미지의 실패는 태그에 잡히지 않았다.


정리

9.1.1편에서 KFP의 캐시와 importer가 데이터를 경로로만 비교하는 것을 보고 운영 중에 겪은 장애를 다시 묶어 봤다. 이 시리즈가 이름과 내용의 어긋남이라고 부르는 일이 데이터 경로와 그 옆의 파생 파일, 산출물 경로에서 각각 다른 모양으로 나타났다. 데이터 쪽 사례는 모두 경로가 가리키는 내용과 위치가 학습 내내 그대로라는 가정이 깨진 경우였고, 산출물 쪽 사례는 올린 곳과 찾는 곳, 올리는 시점이 서로 어긋난 경우였다.

사례 어긋난 것 KFP 구조에서
학습 중에 옮겨진 검증 데이터 경로는 같고 데이터 위치가 바뀜 공유 PVC면 같은 실패. run 전용 사본은 막지만 복사 비용이 들고, 내용 다이제스트는 이유만 밝힘
데이터 준비의 전제 레이아웃·빈 데이터·샤딩 전제 분할 단계에서 검사하면 학습 전에 실패
색인 캐시 데이터 옆에 쓰는 파생 파일 색인을 출력 아티팩트로 떼려면 학습 코드도 바꿔야 함
캐시 포맷 데이터와 함께 배포되는 포맷 캐시 키가 같아 이전 성공을 재사용해 가릴 수 있음
PV의 ro 파드 쪽 readOnly 없이 PV 옵션에만 기댄 읽기 전용 mount_pvc로는 파드 쪽 readOnly를 걸 수 없음
체크포인트와 best 올리는 곳과 찾는 곳, 올리는 시점 단계 출력은 성공한 단계만 등록. epoch 체크포인트는 학습 코드 몫이고, 출력 파일이 없어도 경고만 남기고 성공
객체 저장소와 기록 경로 보조 기능 실패, API 차이, 드러나지 않는 실패 launcher가 재시도 후 단계를 실패로 끝내고 캐시를 남기지 않아 처음부터 다시

KFP의 아티팩트 모델은 단계 출력의 경로와 등록 시점을 launcher 한 곳에 모아 산출물 쪽 문제 일부를 줄인다. 그래도 파이프라인 밖에서 바뀌는 공유 데이터와 컨테이너 안의 마운트 플래그, 학습 루프 안의 업로드는 KFP로 옮겨도 그대로 남는다. 그래서 학습을 걸기 전에 데이터 경로가 학습 중에 바뀌지 않는지, 파드 쪽 readOnly가 걸렸는지, 업로드 경로가 MLflow의 run 경로와 같은지부터 확인하려 한다.

네 편의 사례를 실패를 언제 드러내는가와 실패가 났을 때 플랫폼이 어디까지 흡수하는가의 두 질문으로 다시 묶은 내용은 9.1.3편에서 다룬다.


참고 링크




hit count

댓글남기기