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

앞 글에서 만든 홈랩 환경에 피처 스토어 Feast를 올려, 학습과 추론이 같은 피처를 꺼내 쓰는 책 4.2절 실습을 진행한다.

4.1절 MLOps 파이프라인 그림에서 피처 스토어만 강조한 그림. 데이터 웨어하우스 자리의 parquet 3개를 RustFS 오프라인 스토어에 두고, 모델 학습은 get_historical_features 로 시점 기준 피처를, 모델 API 는 materialize 로 Redis 에 옮긴 최신 피처를 get_online_features 와 피처 서버(:6566)로 가져간다. 오른쪽 카드에 오프라인 스토어, 레지스트리, materialize 30,162키, 학습·추론 조회 메서드를 정리했다

Feast는 별도 네임스페이스 feast에 올렸다. 오프라인 스토어와 레지스트리는 새로 만들지 않고 MLflow용으로 설치한 RustFS를 같이 쓴다.

홈랩 Kubernetes 클러스터의 feast 네임스페이스(전부 worker1-gpu)에 Redis 8.10.2 온라인 스토어, feast apply·materialize Job, feature server 0.67.0, Feast UI 를 올리고, 오프라인 스토어와 레지스트리는 mlflow 네임스페이스의 RustFS 를 같이 쓰는 구성. prepare-buckets Job 이 GitHub 에서 parquet 3개를 받아 RustFS 에 올리고, feast apply 가 엔티티와 피처 뷰 3개를 registry_local.db 에 등록하고, feast materialize 가 parquet 를 읽어 Redis 에 해시 키 30,162개를 쓴다. 피처 서버(192.168.0.240:6566)와 UI(192.168.0.241:8080)는 레지스트리를 읽고 Redis 를 조회하며, JupyterLab 의 Feast SDK 는 RustFS 에서 historical, Redis 에서 online 피처를 가져온다

구성요소버전어디에역할
Redis8.10.2ns feast, PV 2Gi온라인 스토어. 사용자 한 명당 해시 키 하나
RustFS (공용)1.0.3ns mlflow오프라인 스토어(버킷 feature-data-sets의 parquet 3개) + 레지스트리(feature-registry/registry_local.db)
feast apply / materialize0.67.0Job피처 정의 등록, parquet에서 Redis로 최신 값 복사
feature server / UI0.67.0Deployment, LB .240 / .241REST로 온라인 피처 조회, 레지스트리 열람
JupyterLab (공용)Feast SDK 0.67.0ns 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에 정리했다.

책은 Feast 명령을 로컬 PC에서 실행하고 MinIO와 Redis를 포트포워드로 연다. 홈랩에서는 모든 명령을 JupyterLab 터미널(192.168.0.239:8888 → File → New → Terminal)에서 실행한다. 접속 정보(AWS_*, FEAST_S3_ENDPOINT_URL, REDIS_PASSWORD)는 Pod 환경변수로 이미 들어가 있어서 책의 env.sh 같은 준비가 필요 없다.

Feast 구성도에서 GitHub raw, prepare-buckets Job, RustFS 만 강조한 그림. Job 이 책 저장소의 parquet 3개를 받아 RustFS feature-data-sets 버킷에 올린다

책은 4.1절의 소득 데이터를 피처 범주별로 파일 3개로 나눠, 피처가 여러 테이블에 흩어진 현실 상황을 흉내 낸다. 파일마다 user_id와 feature_timestamp 열이 있고 나머지가 그 범주의 피처다.

파일피처 뷰피처
demographic_features.parquetdemographicSex, Native_country, Race
relationship_features.parquetrelationshipRelationship, Marital-Status
occupational_features.parquetoccupationWorkclass, Education, Occupation

세 파일 모두 30,162행이고 사용자 한 명당 기록이 하나다. feature_timestamp는 2022-09-16 ~ 2023-09-16 사이에 흩어져 있다.

설치 때 Job feast-prepare-buckets가 책 저장소에서 이 파일을 받아 RustFS 버킷 feature-data-sets에 올려 두었다. 레지스트리용 버킷 feature-registry도 같은 Job이 만든다.

RustFS 콘솔에서 feature-data-sets 버킷을 연 화면. demographic_features.parquet, occupational_features.parquet, relationship_features.parquet 가 각각 1.4MiB 로 올라가 있다

Feast 구성도에서 ConfigMap feast-repo, Secret redis-auth, 등록된 피처 목록, JupyterLab 만 강조한 그림. 정의 파일 3개를 ConfigMap 으로 만들어 Job, 서버, UI, Jupyter 의 /opt/feast-repo 에 마운트한다

정의 파일 3개는 ConfigMap feast-repo로 Jupyter의 /opt/feast-repo에 마운트돼 있다. 책 코드에서 바꾼 곳은 주소와 암호뿐이다.

개발 PC (kubectl): Jupyter Pod describe 요지
$ kubectl -n mlflow describe pod -l app=jupyter ... Environment: REDIS_PASSWORD: <set to the key 'redis-password' in secret 'redis-auth'> Optional: true FEAST_REPO: /opt/feast-repo Mounts: /home/jovyan from home (rw) /opt/feast-repo from feast-repo (ro) /usr/local/bin/before-notebook.d/10-chapter4.sh from hooks (rw,path="10-chapter4.sh") ... Volumes: feast-repo: Type: ConfigMap (a volume populated by a ConfigMap) Name: feast-repo Optional: true $ kubectl -n mlflow exec deploy/jupyter -- ls -l /opt/feast-repo/ lrwxrwxrwx 1 root users 16 Oct 26 13:46 entity.py -> ..data/entity.py lrwxrwxrwx 1 root users 25 Oct 26 13:46 feature_store.yaml -> ..data/feature_store.yaml lrwxrwxrwx 1 root users 18 Oct 26 13:46 features.py -> ..data/features.py

마운트는 읽기 전용(ro)이고, 파일은 ConfigMap 볼륨이 만드는 ..data/ 아래를 가리키는 심볼릭 링크다. 이 구조 때문에 2.3절에서 통째 복사(cp -r)가 문제를 일으킨다. Redis 암호는 같은 Pod에 Secret redis-auth에서 환경변수로 들어간다.

entity.py (Listing 4.8)
from feast import Entity, ValueType

# 0.67 은 value_type 을 생략하면 DeprecationWarning 이라 명시한다
user = Entity(name="user_id", description="A user", value_type=ValueType.STRING)
features.py (Listing 4.9, 4.10 요지)
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 도 같은 모양
feature_store.yaml (Listing 4.11)
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: 3

Redis 암호는 파일에 쓰지 않는다. Feast가 feature_store.yaml을 읽을 때 ${REDIS_PASSWORD}를 환경변수 값으로 바꾼다(0.67.0 repo_config.py의 os.path.expandvars). entity_key_serialization_version: 3을 넣어서 책 실습에서 나오던 직렬화 버전 경고도 나오지 않는다.

Feast 구성도에서 feast apply Job, feast-repo, 등록된 피처, RustFS 만 강조한 그림. apply 가 엔티티 user_id 와 피처 뷰 3개를 feature-registry 버킷의 registry_local.db 에 쓴다

아래 명령은 개발 PC가 아니라 JupyterLab 터미널(192.168.0.239:8888 → File → New → Terminal)에서 실행한다. 터미널은 Jupyter Pod 안의 셸이라 feast 명령과 RustFS, Redis 접속 정보가 이미 갖춰져 있다. /opt/feast-repo는 읽기 전용이라 정의 파일을 홈 디렉터리로 복사한 뒤 실행한다.

JupyterLab 메뉴에서 File → New → Terminal 을 고르는 화면. New 하위 메뉴에 Console, Notebook, Terminal, Text File, Markdown File, Python File 이 있다

새로 연 JupyterLab 터미널에서 2.3절을 실행한 화면. hostname 이 Jupyter Pod 이름 jupyter-5455bf946d-mkr4l 을 찍고, source 로 chapter-4 venv 를 켜자 프롬프트 앞에 (chapter-4) 가 붙는다. 정의 파일 3개를 ~/feast-repo 로 복사한 뒤 feast apply 가 Deploying infrastructure 3줄을, feast feature-views list 가 피처 뷰 3개를 AVAILABLE_ONLINE 으로 보여 준다

hostname이 jupyter-5455bf946d-mkr4l, 즉 Jupyter Pod 이름을 찍는다. 새로 연 터미널은 venv가 꺼져 있다. source ~/.venvs/chapter-4/bin/activate를 빼먹으면 bash: feast: command not found가 나고, 켜면 프롬프트 앞에 (chapter-4)가 붙는다.

JupyterLab 터미널
$ source ~/.venvs/chapter-4/bin/activate $ mkdir ~/feast-repo && cp /opt/feast-repo/*.py /opt/feast-repo/*.yaml ~/feast-repo/ && cd ~/feast-repo $ feast apply No project found in the repository. Using project name my_feature_repo defined in feature_store.yaml Applying changes for project my_feature_repo Deploying infrastructure for relationship Deploying infrastructure for demographic Deploying infrastructure for occupation $ feast feature-views list NAME ENTITIES TYPE ENABLED STATE demographic {'user_id'} FeatureView Yes AVAILABLE_ONLINE occupation {'user_id'} FeatureView Yes AVAILABLE_ONLINE relationship {'user_id'} FeatureView Yes AVAILABLE_ONLINE

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) 하나다.

Feast UI 의 프로젝트 홈. Project my_feature_repo 에 Feature Views 3, Features 8, Entities 2, Data Sources 3 이 있고, 오른쪽에 description 과 authors 태그별 피처 뷰 목록이 보인다

UI의 Entities가 2개인 것은 Feast가 엔티티 없는 피처 뷰용으로 내부에 두는 __dummy 엔티티까지 세기 때문이다. feast entities list는 user_id 하나만 보여 준다.

Feast 구성도에서 feast materialize Job, RustFS, Redis 만 강조한 그림. materialize 가 RustFS 의 parquet 를 읽어 Redis 에 해시 키 30,162개를 쓴다

온라인 조회 전에 오프라인 스토어(RustFS parquet)의 값을 온라인 스토어(Redis)로 옮긴다. 시작·끝 시각은 책과 같고 parquet의 feature_timestamp 범위를 모두 덮는다. 2.3과 같은 JupyterLab 터미널의 ~/feast-repo에서 실행한다.

JupyterLab 터미널 (~/feast-repo)
$ feast materialize 2022-09-16T00:00:00 2023-09-17T00:00:00 Materializing 3 feature views from 2022-09-16 00:00:00+00:00 to 2023-09-17 00:00:00+00:00 into the redis online store. demographic: occupation: relationship:

Redis에 들어갔는지는 두 곳에서 볼 수 있다. Jupyter Pod에는 kubectl과 redis-cli가 없으므로 같은 터미널에서는 Feast와 함께 설치된 Python redis 패키지로 센다. 암호는 Pod 환경변수 REDIS_PASSWORD에 들어 있다.

JupyterLab 터미널
$ python -c "import os, redis; r = redis.Redis(host='redis.feast.svc', password=os.environ['REDIS_PASSWORD']); print(r.dbsize(), r.info('memory')['used_memory_human'])" 30162 12.58M

kubectl이 있는 곳(개발 PC나 클러스터 노드)에서는 Redis Pod 안의 redis-cli를 쓴다. Pod에 REDISCLI_AUTH가 들어 있어 암호를 따로 주지 않는다.

개발 PC (kubectl)
$ kubectl -n feast exec redis-0 -- redis-cli DBSIZE 30162 $ kubectl -n feast exec redis-0 -- redis-cli INFO memory | grep used_memory_human used_memory_human:12.58M

JupyterLab 터미널에서 2.4절을 실행한 화면. source 로 venv 를 켜고 ~/feast-repo 로 이동한 뒤 feast materialize 가 3 feature views 를 2022-09-16 ~ 2023-09-17 구간으로 redis online store 에 materialize 한다고 찍고, Python redis 패키지로 센 키 수와 메모리가 30162 12.58M 으로 나온다

위는 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]로 이미 들어 있어 위 명령이 그대로 돈다.

Feast 구성도에서 개발 PC, JupyterLab, RustFS, Redis 만 강조한 그림. Feast SDK 의 historical 조회는 RustFS parquet 에서, online 조회는 Redis 에서 읽는다

책의 조회 스크립트 두 개는 FeatureStore(repo_path=".")라 정의 파일이 있는 ~/feast-repo에서 실행한다. 이것도 JupyterLab 터미널(venv 켠 상태)에서 한다.

JupyterLab 터미널 (~/feast-repo)
$ python ~/practical-mlops/chapter-4/feast_retrieval/retrieve_features.py ----- Example features ----- user_id event_timestamp Sex 0 9f2ac416-06e1-44a0-87bd-d4787c85bf66 2023-01-30 10:59:42+00:00 Male $ python ~/practical-mlops/chapter-4/feast_retrieval/retrieve_online_features.py ----- Example features ----- user_id Sex 0 74b0638a-5742-4d1a-a03f-483794d7aa7c Male

JupyterLab 터미널에서 2.5절을 실행한 화면. (chapter-4) venv 의 ~/feast-repo 에서 retrieve_features.py 가 9f2ac416… 사용자의 2023-01-30 10:59:42 시점 Sex = Male 을, retrieve_online_features.py 가 74b0638a… 사용자의 Sex = Male 을 출력한다. 온라인 결과에는 event_timestamp 열이 없다

오프라인 조회(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의 parquetRedis
출력의 event_timestamp 열있음없음
용도학습 데이터셋, 배치 추론실시간 추론

Feast 구성도에서 개발 PC, feature server, Redis, RustFS 레지스트리만 강조한 그림. PC 가 192.168.0.240:6566 에 get-online-features 를 보내면 서버가 레지스트리를 읽고 Redis 에서 값을 꺼낸다

책은 feast serve를 로컬에서 띄우지만, 홈랩에는 Deployment feast-server가 192.168.0.240:6566에 떠 있다. Feast SDK가 없는 언어의 애플리케이션도 HTTP로 온라인 피처를 가져갈 수 있다. 사용자 두 명과 없는 ID 하나를 섞어 세 뷰를 한 번에 조회했다.

개발 PC
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

응답 전체는 다음과 같다.

POST /get-online-features 응답
{
  "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_idSexOccupationMarital-Status피처 기록 시각
9f2ac416…MaleAdm-clericalNever-married2023-01-24 06:52:20
74b0638a…MaleFarming-fishingMarried-civ-spouse2023-05-12 16:58:28
no-such-usernullnullnull1970-01-01 00:00:00

없는 사용자도 statuses는 PRESENT로 온다(0.67.0 실측). 값이 있는지는 values가 null인지, event_timestamps가 1970-01-01인지로 판단해야 한다. 값 앞의 공백(" Male")은 원본 CSV에서 따라온 것이다.

Feast 구성도에서 개발 PC, Feast UI, 등록된 피처, RustFS 레지스트리만 강조한 그림. PC 가 192.168.0.241:8080 으로 레지스트리 내용을 본다

UI도 Deployment feast-ui로 192.168.0.241:8080에 떠 있어 책처럼 feast ui -p 8889를 따로 띄울 필요가 없다. 레지스트리를 읽어 엔티티, 피처 뷰, 데이터 소스와 태그를 보여 준다.

Feast UI 에서 demographic 피처 뷰를 연 화면. Features 3개(Native_country, Sex, Race, 모두 STRING), 엔티티 user_id, 태그 description User Demographics, used_by Income_Calculation_Team, authors 가 보인다

features.py의 tags가 화면의 Tags로 그대로 나오고, 홈 화면에서는 이 태그 값으로 피처 뷰를 걸러 볼 수 있다.

MLflow는 모델 쪽(실험 기록, 모델 버전)을, Feast는 데이터 쪽(학습과 추론에 같은 피처를 같은 시점 기준으로)을 맡는다. 두 도구 모두 아직은 사람이 명령을 실행해야 돈다. 재학습과 feast materialize를 주기적으로 돌리는 일은 5장 Kubeflow Pipelines에서 자동화한다.

MLflow 설치 과정은 Kubernetes에 MLflow 설치하기에, 4.1절 실습은 홈랩 MLflow로 실험 추적과 모델 레지스트리 실습하기에 있다.