MLPE 스터디 2주차 - Ch4.2 홈랩 Kubernetes에 Feast 피처 스토어 올리고 실습하기

앞 글에서 만든 홈랩 환경에 피처 스토어 Feast를 올려, 학습과 추론이 같은 피처를 꺼내 쓰는 책 4.2절 실습을 진행한다.
1. 구성
Feast는 별도 네임스페이스 feast에 올렸다. 오프라인 스토어와 레지스트리는 새로 만들지 않고 MLflow용으로 설치한 RustFS를 같이 쓴다.
| 구성요소 | 버전 | 어디에 | 역할 |
|---|---|---|---|
| Redis | 8.10.2 | ns feast, PV 2Gi | 온라인 스토어. 사용자 한 명당 해시 키 하나 |
| RustFS (공용) | 1.0.3 | ns mlflow | 오프라인 스토어(버킷 feature-data-sets의 parquet 3개) + 레지스트리(feature-registry/registry_local.db) |
| feast apply / materialize | 0.67.0 | Job | 피처 정의 등록, parquet에서 Redis로 최신 값 복사 |
| feature server / UI | 0.67.0 | Deployment, LB .240 / .241 | REST로 온라인 피처 조회, 레지스트리 열람 |
| JupyterLab (공용) | Feast SDK 0.67.0 | ns mlflow | 책의 조회 스크립트 실행 |
주황 화살표와 번호(1 ~ 3)가 Feast의 핵심 흐름이고, 번호는 그림의 Job 상자와 화살표에 같이 붙어 있다.
① Job이 책 저장소의 parquet를 RustFS에 올리고,
② feast apply가 엔티티 user_id와 피처 뷰 3개(demographic, relationship, occupation)를 레지스트리에 쓴다.
③ feast materialize가 parquet를 읽어 사용자 30,162명의 최신 값을 Redis에 채운다.
조회는 두 갈래다. 학습용 과거 시점 피처(get_historical_features)는 RustFS의 parquet에서, 추론용 최신 피처(get_online_features)는 Redis에서 읽는다. 피처 정의 파일(entity.py, features.py, feature_store.yaml)은 ConfigMap feast-repo 하나로 Job, 피처 서버, UI, Jupyter에 같이 마운트한다.
Feast 공식 이미지(quay.io/feastdev/feature-server:0.67.0)는 amd64 전용이라 Feast Pod는 모두 worker1-gpu에 있다. 책의 0.40.1 대신 0.67.0을 쓰면서 달라진 점은 홈랩 저장소의 feast/README.md에 정리했다.
2. 실습
책은 Feast 명령을 로컬 PC에서 실행하고 MinIO와 Redis를 포트포워드로 연다. 홈랩에서는 모든 명령을 JupyterLab 터미널(192.168.0.239:8888 → File → New → Terminal)에서 실행한다. 접속 정보(AWS_*, FEAST_S3_ENDPOINT_URL, REDIS_PASSWORD)는 Pod 환경변수로 이미 들어가 있어서 책의 env.sh 같은 준비가 필요 없다.
2.1 피처 파일 3개
책은 4.1절의 소득 데이터를 피처 범주별로 파일 3개로 나눠, 피처가 여러 테이블에 흩어진 현실 상황을 흉내 낸다. 파일마다 user_id와 feature_timestamp 열이 있고 나머지가 그 범주의 피처다.
| 파일 | 피처 뷰 | 피처 |
|---|---|---|
demographic_features.parquet | demographic | Sex, Native_country, Race |
relationship_features.parquet | relationship | Relationship, Marital-Status |
occupational_features.parquet | occupation | Workclass, Education, Occupation |
세 파일 모두 30,162행이고 사용자 한 명당 기록이 하나다. feature_timestamp는 2022-09-16 ~ 2023-09-16 사이에 흩어져 있다.
설치 때 Job feast-prepare-buckets가 책 저장소에서 이 파일을 받아 RustFS 버킷 feature-data-sets에 올려 두었다. 레지스트리용 버킷 feature-registry도 같은 Job이 만든다.
2.2 피처 정의 (4.2.1)
정의 파일 3개는 ConfigMap feast-repo로 Jupyter의 /opt/feast-repo에 마운트돼 있다. 책 코드에서 바꾼 곳은 주소와 암호뿐이다.
마운트는 읽기 전용(ro)이고, 파일은 ConfigMap 볼륨이 만드는 ..data/ 아래를 가리키는 심볼릭 링크다. 이 구조 때문에 2.3절에서 통째 복사(cp -r)가 문제를 일으킨다. Redis 암호는 같은 Pod에 Secret redis-auth에서 환경변수로 들어간다.
from feast import Entity, ValueType
# 0.67 은 value_type 을 생략하면 DeprecationWarning 이라 명시한다
user = Entity(name="user_id", description="A user", value_type=ValueType.STRING)S3_ENDPOINT = "http://rustfs.mlflow.svc:9000" # 책: http://localhost:9000 (MinIO 포트포워드)
demo_features_parquet_file_source = FileSource(
file_format=ParquetFormat(),
path="s3://feature-data-sets/demographic_features.parquet",
s3_endpoint_override=S3_ENDPOINT,
)
demo_features = FeatureView(
name="demographic",
entities=[user],
schema=[
Field(name="Native_country", dtype=String),
Field(name="Sex", dtype=String),
Field(name="Race", dtype=String),
],
ttl=timedelta(days=365), # 과거 조회 때 1년 전까지만 본다
source=demo_features_parquet_file_source,
tags={"description": "User Demographics", "used_by": "Income_Calculation_Team"},
)
# relationship, occupation 도 같은 모양project: my_feature_repo
registry: s3://feature-registry/registry_local.db
provider: local
offline_store:
type: file
online_store:
type: redis
connection_string: "redis.feast.svc:6379,password=${REDIS_PASSWORD}" # 책: localhost:6379
entity_key_serialization_version: 3Redis 암호는 파일에 쓰지 않는다. Feast가 feature_store.yaml을 읽을 때 ${REDIS_PASSWORD}를 환경변수 값으로 바꾼다(0.67.0 repo_config.py의 os.path.expandvars). entity_key_serialization_version: 3을 넣어서 책 실습에서 나오던 직렬화 버전 경고도 나오지 않는다.
2.3 등록: feast apply
아래 명령은 개발 PC가 아니라 JupyterLab 터미널(192.168.0.239:8888 → File → New → Terminal)에서 실행한다. 터미널은 Jupyter Pod 안의 셸이라 feast 명령과 RustFS, Redis 접속 정보가 이미 갖춰져 있다. /opt/feast-repo는 읽기 전용이라 정의 파일을 홈 디렉터리로 복사한 뒤 실행한다.
hostname이 jupyter-5455bf946d-mkr4l, 즉 Jupyter Pod 이름을 찍는다. 새로 연 터미널은 venv가 꺼져 있다. source ~/.venvs/chapter-4/bin/activate를 빼먹으면 bash: feast: command not found가 나고, 켜면 프롬프트 앞에 (chapter-4)가 붙는다.
cp -r /opt/feast-repo ~/feast-repo로 통째 복사하면 feast apply가 죽는다. ConfigMap 볼륨에는 ..data와 ..2026_10_26_… 같은 숨은 디렉터리가 있고, Feast는 하위 디렉터리의 .py까지 모듈로 임포트하다가 TypeError: the 'package' argument is required to perform a relative import for '..2026_10_26_13_46_49.1163188509.entity'를 낸다. 파일 3개만 복사하면 된다.
설치 Job이 이미 한 번 등록했기 때문에 책 출력의 Created entity user_id, Created feature view … 줄은 나오지 않는다. 처음 등록할 때만 찍히고, 다시 돌리면 바뀐 것이 없어 Deploying infrastructure 줄만 남는다. 레지스트리는 RustFS feature-registry/registry_local.db(4.7KiB) 하나다.
UI의 Entities가 2개인 것은 Feast가 엔티티 없는 피처 뷰용으로 내부에 두는 __dummy 엔티티까지 세기 때문이다. feast entities list는 user_id 하나만 보여 준다.
2.4 구체화: feast materialize
온라인 조회 전에 오프라인 스토어(RustFS parquet)의 값을 온라인 스토어(Redis)로 옮긴다. 시작·끝 시각은 책과 같고 parquet의 feature_timestamp 범위를 모두 덮는다. 2.3과 같은 JupyterLab 터미널의 ~/feast-repo에서 실행한다.
Redis에 들어갔는지는 두 곳에서 볼 수 있다. Jupyter Pod에는 kubectl과 redis-cli가 없으므로 같은 터미널에서는 Feast와 함께 설치된 Python redis 패키지로 센다. 암호는 Pod 환경변수 REDIS_PASSWORD에 들어 있다.
kubectl이 있는 곳(개발 PC나 클러스터 노드)에서는 Redis Pod 안의 redis-cli를 쓴다. Pod에 REDISCLI_AUTH가 들어 있어 암호를 따로 주지 않는다.
위는 JupyterLab 터미널에서 실제로 실행한 화면이다. 2.3에 이어 같은 터미널을 쓰면 source와 cd는 다시 할 필요가 없다.
뷰가 3개인데 키는 90,486개가 아니라 30,162개다. Feast Redis 스토어는 사용자 한 명당 해시 키 하나에 세 뷰의 피처 값과 _ts:<뷰 이름> 타임스탬프 필드를 같이 넣는다. 그래서 키 수가 사용자 수와 같고, 여러 뷰를 한 번에 조회해도 사용자당 Redis 조회는 한 번이다.
Feast 공식 이미지에는 s3fs가 없어서 클러스터의 materialize Job은 시작할 때 pip install s3fs를 한다. Jupyter venv에는 feast[aws]로 이미 들어 있어 위 명령이 그대로 돈다.
2.5 조회: 오프라인과 온라인 (4.2.2)
책의 조회 스크립트 두 개는 FeatureStore(repo_path=".")라 정의 파일이 있는 ~/feast-repo에서 실행한다. 이것도 JupyterLab 터미널(venv 켠 상태)에서 한다.
오프라인 조회(Listing 4.12)는 “2023-01-30 10:59:42 시점에 이 사용자의 Sex는?“이라는 질문이다. parquet에 이 사용자의 기록은 2023-01-24 06:52:20 하나뿐이고 조회 시점보다 앞이며 TTL(365일) 안이라 그 값 Male이 나온다.
조회 시점을 2023-01-20처럼 기록보다 앞으로 당기면 아직 없던 값이라 결과가 0행으로 나온다(0.67.0 file 오프라인 스토어 실측). 시점을 거꾸로 거슬러 미래 값을 끌어오는 일(데이터 누수)을 Feast가 막아 주는 것이다.
오프라인 get_historical_features | 온라인 get_online_features | |
|---|---|---|
| 입력 | user_id + event_timestamp 데이터프레임 | user_id 목록 |
| 값의 기준 | 그 시점 이전의 가장 최근 값 | 마지막 materialize 때의 최신 값 |
| 읽는 곳 | RustFS의 parquet | Redis |
출력의 event_timestamp 열 | 있음 | 없음 |
| 용도 | 학습 데이터셋, 배치 추론 | 실시간 추론 |
2.6 피처 서버 (4.2.3)
책은 feast serve를 로컬에서 띄우지만, 홈랩에는 Deployment feast-server가 192.168.0.240:6566에 떠 있다. Feast SDK가 없는 언어의 애플리케이션도 HTTP로 온라인 피처를 가져갈 수 있다. 사용자 두 명과 없는 ID 하나를 섞어 세 뷰를 한 번에 조회했다.
curl -s -X POST http://192.168.0.240:6566/get-online-features \
-H 'Content-Type: application/json' -d '{
"features": ["demographic:Sex", "occupation:Occupation", "relationship:Marital-Status"],
"entities": {"user_id": ["9f2ac416-06e1-44a0-87bd-d4787c85bf66",
"74b0638a-5742-4d1a-a03f-483794d7aa7c",
"no-such-user"]}
}' | jq응답 전체는 다음과 같다.
{
"results": [
{
"values": [
"9f2ac416-06e1-44a0-87bd-d4787c85bf66",
"74b0638a-5742-4d1a-a03f-483794d7aa7c",
"no-such-user"
],
"statuses": [
"PRESENT",
"PRESENT",
"PRESENT"
],
"event_timestamps": [
"1970-01-01T00:00:00Z",
"1970-01-01T00:00:00Z",
"1970-01-01T00:00:00Z"
]
},
{
"values": [
" Male",
" Male",
null
],
"statuses": [
"PRESENT",
"PRESENT",
"PRESENT"
],
"event_timestamps": [
"2023-01-24T06:52:20Z",
"2023-05-12T16:58:28Z",
"1970-01-01T00:00:00Z"
]
},
{
"values": [
" Adm-clerical",
" Farming-fishing",
null
],
"statuses": [
"PRESENT",
"PRESENT",
"PRESENT"
],
"event_timestamps": [
"2023-01-24T06:52:20Z",
"2023-05-12T16:58:28Z",
"1970-01-01T00:00:00Z"
]
},
{
"values": [
" Never-married",
" Married-civ-spouse",
null
],
"statuses": [
"PRESENT",
"PRESENT",
"PRESENT"
],
"event_timestamps": [
"2023-01-24T06:52:20Z",
"2023-05-12T16:58:28Z",
"1970-01-01T00:00:00Z"
]
}
],
"metadata": {
"feature_names": [
"user_id",
"Sex",
"Occupation",
"Marital-Status"
]
}
}results의 첫 항목은 요청한 엔티티(user_id) 자신이고, 그 뒤로 metadata.feature_names 순서대로 피처마다 values, statuses, event_timestamps 배열이 하나씩 온다. 표로 옮기면 이렇다.
| user_id | Sex | Occupation | Marital-Status | 피처 기록 시각 |
|---|---|---|---|---|
| 9f2ac416… | Male | Adm-clerical | Never-married | 2023-01-24 06:52:20 |
| 74b0638a… | Male | Farming-fishing | Married-civ-spouse | 2023-05-12 16:58:28 |
| no-such-user | null | null | null | 1970-01-01 00:00:00 |
없는 사용자도 statuses는 PRESENT로 온다(0.67.0 실측). 값이 있는지는 values가 null인지, event_timestamps가 1970-01-01인지로 판단해야 한다. 값 앞의 공백(" Male")은 원본 CSV에서 따라온 것이다.
2.7 Feast UI (4.2.4)
UI도 Deployment feast-ui로 192.168.0.241:8080에 떠 있어 책처럼 feast ui -p 8889를 따로 띄울 필요가 없다. 레지스트리를 읽어 엔티티, 피처 뷰, 데이터 소스와 태그를 보여 준다.
features.py의 tags가 화면의 Tags로 그대로 나오고, 홈 화면에서는 이 태그 값으로 피처 뷰를 걸러 볼 수 있다.
2.8 정리
MLflow는 모델 쪽(실험 기록, 모델 버전)을, Feast는 데이터 쪽(학습과 추론에 같은 피처를 같은 시점 기준으로)을 맡는다. 두 도구 모두 아직은 사람이 명령을 실행해야 돈다. 재학습과 feast materialize를 주기적으로 돌리는 일은 5장 Kubeflow Pipelines에서 자동화한다.
MLflow 설치 과정은 Kubernetes에 MLflow 설치하기에, 4.1절 실습은 홈랩 MLflow로 실험 추적과 모델 레지스트리 실습하기에 있다.






