왜 학습 기록을 따로 남겨야 하는가
모델이 하나일 때는 로그 파일과 체크포인트 디렉터리 이름만으로 버틴다. 문제는 두 달 뒤다. 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_rev와 git_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=False와 max_active_runs=1은 GPU 파이프라인에서 사실상 필수다. start_date를 과거로 잡고 catchup을 켜두면 DAG를 켜는 순간 밀린 날짜만큼 학습 잡이 한꺼번에 뜬다. GPU가 여덟 개 있어도 그중 하나만 살아남는다.
retries는 1 이하로 둔다. 학습 실패는 대개 재시도로 해결되는 종류가 아니고, 재시도가 실패 알림을 늦추기만 한다. 반면 데이터 수집처럼 네트워크에 의존하는 태스크는 재시도가 실제로 도움이 되므로 태스크 단위로 다르게 준다.
이 둘을 어디서 나누는가
경계를 정리하면 이렇다.
| MLflow | Airflow | |
|---|---|---|
| 답하는 질문 | “이 모델은 어떻게 만들어졌나” | “이 작업이 언제 어떻게 돌았나” |
| 저장하는 것 | 파라미터, 지표, 아티팩트 | 태스크 상태, 실행 로그, 의존성 |
| 없으면 생기는 일 | 재현 불가 | 사람이 매번 손으로 실행 |
둘을 하나로 합치려는 시도를 몇 번 했는데 전부 실패했다. Airflow 태스크 로그에 지표를 찍어두면 비교가 안 되고, MLflow에 스케줄 개념을 억지로 넣으면 run 이름 규칙이 감당이 안 된다. 각자 잘하는 일만 시키는 편이 결국 손이 덜 갔다.