Posts MLflow와 Airflow로 학습 기록을 남기는 최소 구성
Post
Cancel

MLflow와 Airflow로 학습 기록을 남기는 최소 구성

왜 학습 기록을 따로 남겨야 하는가

모델이 하나일 때는 로그 파일과 체크포인트 디렉터리 이름만으로 버틴다. 문제는 두 달 뒤다. best_v3_final_2.pth가 어떤 하이퍼파라미터로, 어떤 데이터셋 버전에서, 어떤 코드 커밋으로 학습됐는지 아무도 모른다. 그 모델이 지금 서비스에 올라가 있으면 더 곤란해진다.

필요한 최소 구성은 두 가지다. 실험 기록(무엇을 했고 결과가 얼마였나)과 스케줄링(그것을 언제 어떻게 반복하나). 각각 MLflow와 Airflow가 맡는다.

MLflow — 실험 기록

MLflow는 서버 하나와 아티팩트 저장소 하나로 시작한다. 아티팩트는 체크포인트처럼 큰 파일이라 DB가 아니라 파일 저장소로 따로 뺀다.

1
2
3
4
mlflow server \
  --backend-store-uri sqlite:///mlflow.db \
  --default-artifact-root sftp://mlm@192.168.1.178:/home/mlm/mlserver/artifacts \
  --host 0.0.0.0 --port 1111

SFTP를 아티팩트 저장소로 쓰면 클라이언트마다 SSH 키를 등록해야 한다. 비밀번호를 URI에 박아 넣는 예제가 많이 돌아다니는데, 그 URI가 그대로 MLflow DB에 저장되고 웹 UI에도 노출된다. 키 등록이 한 번 더 귀찮을 뿐이다.

1
2
ssh-keygen                                   # 클라이언트에서
ssh-copy-id mlm@192.168.1.178                # 서버에 공개키 등록

학습 코드 쪽은 이 정도만 붙인다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import mlflow
from mlflow.tracking import MlflowClient

TRACKING_URI = "http://192.168.1.178:1111"
EXPERIMENT   = "detector-milveh"

mlflow.set_tracking_uri(TRACKING_URI)
client = MlflowClient(tracking_uri=TRACKING_URI)

if client.get_experiment_by_name(EXPERIMENT) is None:
    client.create_experiment(EXPERIMENT)
mlflow.set_experiment(EXPERIMENT)

with mlflow.start_run(run_name=cfg.run_name) as run:
    mlflow.log_params({
        "lr": cfg.lr, "batch": cfg.batch, "imgsz": cfg.imgsz,
        "dataset_rev": dataset_revision(),      # 데이터셋 버전
        "git_sha": git_head_sha(),              # 코드 커밋
    })

    for epoch in range(cfg.epochs):
        train_loss = train_one_epoch(...)
        val = validate(...)
        mlflow.log_metrics(
            {"train/loss": train_loss, "val/mAP50": val["mAP50"], "lr": current_lr()},
            step=epoch,
        )

    mlflow.log_artifact(best_ckpt_path)

dataset_revgit_sha를 남기는 것이 실제로 가장 값어치가 컸다. 하이퍼파라미터는 대개 설정 파일에 남지만, “어떤 데이터로 학습했나”는 아무데도 안 남아서 몇 달 뒤 재현이 불가능해진다.

step=epoch을 빼먹으면 지표가 전부 step 0에 쌓여 곡선이 그려지지 않는다. 로그는 남지만 비교가 안 되는 상태가 되는데, 이건 학습이 끝난 뒤에야 발견된다.

실패한 학습도 기록에 남긴다

mlflow.start_run()을 컨텍스트 매니저로 쓰면 예외가 나도 run이 FAILED 상태로 닫힌다. 이게 중요한 이유는, 실패한 실험이 기록에 안 남으면 같은 설정으로 두 번 세 번 다시 돌리게 되기 때문이다. 특히 OOM으로 죽는 배치 크기는 반드시 남겨야 한다.

Airflow — 반복되는 것만 맡긴다

Airflow는 “매일 새 데이터로 재학습” 같은 반복 작업에 쓴다. 탐색적 실험까지 DAG로 만들려고 하면 DAG 수정이 실험보다 오래 걸린다.

로컬에 세우는 최소 구성은 메타 DB(MySQL)와 브로커(Redis)다.

1
2
3
4
docker run -d --name mysql -p 3306:3306 \
  -v /data/mysql:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=root mysql:8.0

docker run -d --name redis -p 6379:6379 -v /data/redis:/data redis:5.0
1
2
3
4
CREATE USER 'airflow'@'%' IDENTIFIED BY 'airflow';
GRANT ALL PRIVILEGES ON *.* TO 'airflow'@'%';
FLUSH PRIVILEGES;
CREATE DATABASE airflow CHARACTER SET utf8mb4;

CHARACTER SET utf8mb4를 빼면 나중에 한글이 들어간 DAG 설명이나 로그에서 인코딩 오류가 난다. 그때는 이미 테이블에 데이터가 쌓여 있어서 고치기가 번거롭다.

airflow.cfg에서 실행기만 정하면 된다.

1
2
executor = LocalExecutor
sql_alchemy_conn = mysql+pymysql://airflow:airflow@192.168.1.131:3306/airflow

노드 하나로 시작하면 LocalExecutor가 맞다. CeleryExecutor는 워커를 여러 대로 늘릴 때 필요한데, 그 시점까지는 브로커 운영 부담만 늘어난다.

DAG는 이렇게 생겼다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from datetime import datetime, timedelta
from airflow import DAG
from airflow.operators.bash import BashOperator

default_args = {
    "owner": "dnchoi",
    "retries": 1,
    "retry_delay": timedelta(minutes=10),
}

with DAG(
    dag_id="nightly_retrain",
    default_args=default_args,
    schedule_interval="0 2 * * *",
    start_date=datetime(2023, 5, 1),
    catchup=False,
    max_active_runs=1,
) as dag:

    collect = BashOperator(
        task_id="collect",
        bash_command="python /opt/pipeline/collect.py --date {{ ds }}",
    )
    train = BashOperator(
        task_id="train",
        bash_command="python /opt/pipeline/train.py --date {{ ds }}",
    )
    evaluate = BashOperator(
        task_id="evaluate",
        bash_command="python /opt/pipeline/eval.py --date {{ ds }}",
    )

    collect >> train >> evaluate

catchup=Falsemax_active_runs=1은 GPU 파이프라인에서 사실상 필수다. start_date를 과거로 잡고 catchup을 켜두면 DAG를 켜는 순간 밀린 날짜만큼 학습 잡이 한꺼번에 뜬다. GPU가 여덟 개 있어도 그중 하나만 살아남는다.

retries는 1 이하로 둔다. 학습 실패는 대개 재시도로 해결되는 종류가 아니고, 재시도가 실패 알림을 늦추기만 한다. 반면 데이터 수집처럼 네트워크에 의존하는 태스크는 재시도가 실제로 도움이 되므로 태스크 단위로 다르게 준다.

이 둘을 어디서 나누는가

경계를 정리하면 이렇다.

 MLflowAirflow
답하는 질문“이 모델은 어떻게 만들어졌나”“이 작업이 언제 어떻게 돌았나”
저장하는 것파라미터, 지표, 아티팩트태스크 상태, 실행 로그, 의존성
없으면 생기는 일재현 불가사람이 매번 손으로 실행

둘을 하나로 합치려는 시도를 몇 번 했는데 전부 실패했다. Airflow 태스크 로그에 지표를 찍어두면 비교가 안 되고, MLflow에 스케줄 개념을 억지로 넣으면 run 이름 규칙이 감당이 안 된다. 각자 잘하는 일만 시키는 편이 결국 손이 덜 갔다.

This post is licensed under CC BY 4.0 by the author.

온프레미스 GPU 서버로 쿠버네티스 클러스터 구성하기

Ubuntu 서버 운영하면서 반복해서 만난 문제들