Skip to content

Repository files navigation

Team Neki Workflow

이 문서는 Prefect 기반 워크플로 저장소의 구조와 실행 방법을 다룹니다.

디렉토리 구조

워크플로 하나가 디렉토리 하나입니다. @flow와 @task를 같은 디렉토리에 두되 파일은 나눕니다. 스케줄만 따로 모읍니다.

deployments/              언제 돌리나
  daily_sync.py           build()
flows/
  daily_sync/             무엇을 어떤 순서로 + 실제로 하는 일
    __init__.py           flow 재노출
    flow.py               @flow
    orders.py             @task
  stores_enrich/          법정동 보강. collect 의 최신 CSV 를 읽어 Kakao 로 b_code 를 붙임
  stores_sync/            지점 마스터 동기화. 원천 키로 locationId 유지, 수동 보정 보존
  search_index/           검색 색인. 서버 batch 의 색인 잡을 k8s Job 으로 띄움
  common/                 여러 워크플로가 함께 쓰는 task
aws/config                로컬 개발용 AWS 프로파일
compose.yaml              로컬 S3 (LocalStack)
serve.py                  로컬 개발용 - 한 프로세스로 서빙
deploy.py                 운영 배포용 - work pool에 스케줄 등록
Dockerfile                운영 이미지 - worker와 flow run이 같이 씀
.github/workflows/        ci.yml (PR 검증), build.yml (main merge 시 이미지 푸시, GitOps 갱신)
docs/spec/                수집 파이프라인 정책 (정본). 항목마다 구현 위치 anchor
docs/runbook.md           배포 절차와 장애 대응
AGENTS.md                 규약과 함정. 사람과 코딩 에이전트가 같이 읽음
tests/                    순수 판정 테스트. make check 가 돌림

의존 방향은 단방향입니다.

deployments/  ->  flows/<name>/flow.py  ->  flows/<name>/<task 모듈>

flows/는 스케줄을 모르고, 워크플로 안에서 task 모듈은 flow.py를 import하지 않습니다. 이 방향이 깨지면 분리의 의미가 없어집니다.

실행 모델

UI나 cron이 워크플로를 직접 실행하지는 않습니다. flow run 레코드를 만들어둘 뿐이고, serve.py나 worker가 API를 주기적으로 확인하다 집어갑니다.

[UI / cron]  ->  [Prefect API]  <-  폴링  <-  [serve.py / worker]  ->  [flow] -> [task]

따라서 실행 프로세스가 없으면 run이 SCHEDULED에 쌓이기만 하고, 버튼을 눌러도 폴링 주기만큼(기본 10초) 지연이 생깁니다.

운영에서는 worker가 k3s 클러스터 안에 있고, flow run은 worker와 같은 이미지의 Job 파드로 뜹니다. 코드가 클러스터에 들어가는 경로는 운영 배포에 있습니다.

수집 파이프라인

지점 수집은 collect, enrich, index 세 단계로 나뉩니다. collect 와 enrich 는 이 저장소의 flow 이고, index 는 서버 batch 의 잡을 search-index flow 가 k8s Job 으로 띄웁니다. 정책의 정본은 docs/spec/ 의 문서입니다. 여기서는 모양만 보입니다.

[사이트]  ->  collect  ->  enrich  ->  index
                 |           |          |
              원본 그대로  외부 보강   색인 가공

브랜드 11개를 stores_collect 가 스레드로 한 번에 돌립니다. 한 브랜드가 실패해도 나머지는 적재하고, 실패한 브랜드는 읽는 시점에 7일 안의 최근 사이클로 대신합니다.

s3://<bucket>/
  collect/
    platform=LIFE_FOUR_CUT/
      dt=2026-08-02/                              대상 일자 (사이클)
        2026-08-02_040009.csv                     실행 시각
        _raw/2026-08-02_040009/page-001.html …    응답 원문. Athena 가 무시하는 숨김 폴더

어느 CSV 가 현재인지는 S3 가 아니라 Postgres tb_store_collect_manifest 가 압니다. 적재 한 번이 한 행이고 덮어쓰지 않으며, 다음 단계는 manifest.read_cycle 이 정해 주는 행의 s3_path 만 따라갑니다. 그래서 수집 flow 에는 DATABASE_URL 이 필요합니다.

보강 파이프라인 (enrich)

stores-enrich 는 매일 05:00 KST 에 collect 가 남긴 브랜드별 최신 CSV 를 읽어 Kakao coord2regioncode 로 법정동 코드를 붙이고, Postgres tb_photo_booth_enriched 세대로 남깁니다. 좌표가 직전 세대와 같은 지점은 Kakao 를 부르지 않습니다. 정책은 docs/spec/enrich-pipeline.md 가 정본입니다.

색인은 별도 flow search-index 가 띄웁니다. 서버 batch 의 searchIndexJob 을 k8s Job 으로 만들고 종료 코드를 flow 결과로 삼습니다. 정책은 docs/spec/search-index.md 가 정본입니다.

stores-sync는 enrich 현재 세대를 지점 마스터에 증분 반영하는 수동 flow입니다. (platform, idx)로 같은 지점을 갱신하며 locationId, 관리자 보정과 노출 설정을 유지합니다. 서버 Flyway V34 적용이 선행되어야 합니다. make stores-sync-dry-run은 쓰기 없이 예상 건수를 확인하고, make stores-sync는 실제 반영합니다. 정책과 어드민/색인 연결 계약은 지점 동기화 정책에 있습니다. 현재 검색 색인은 여전히 enrich를 직접 읽습니다. 단일 지점 원천으로의 서버 전환과 기존 카카오 수집 중지 전에는 정기 실행하지 않습니다.

네이밍 규약

  • 파일, 모듈 : snake_case (e.g. daily_sync.py)
  • flow 함수 : snake_case, 접미사 없음 (e.g. def daily_sync(...))
  • @flow(name=) : kebab-case, UI 표시명 (e.g. "daily-sync")
  • deployment name : kebab-case (e.g. "daily-sync", "daily-sync-backfill")
  • task 함수 : 동사로 시작 (e.g. fetch_orders, upload_report)

deployments/<name>.py와 flows/<name>/은 이름을 1:1로 맞춥니다. deployment 파일을 열었을 때 대응하는 워크플로 위치를 이름만 보고 알 수 있어야 합니다.

워크플로 추가하기

serve.py와 deploy.py는 건드리지 않습니다. 두 곳만 손대면 됩니다.

먼저 flows/<name>/을 만들고 task를 역할별 모듈에 담습니다. 여러 워크플로가 함께 쓰는 task는 flows/common/에 둡니다.

# flows/daily_sync/orders.py
from prefect import task


@task(retries=2)
def fetch_orders() -> list[dict]:
    ...

같은 디렉토리의 flow.py에서 순서를 조립합니다.

# flows/daily_sync/flow.py
from prefect import flow

from flows.daily_sync.orders import fetch_orders


@flow(name="daily-sync")
def daily_sync() -> None:
    fetch_orders()

__init__.py에서 flow만 재노출합니다.

# flows/daily_sync/__init__.py
from flows.daily_sync.flow import daily_sync

__all__ = ["daily_sync"]

마지막으로 deployments/<name>.py에 RunnerDeployment를 반환하는 build()를 정의합니다. serve.py와 deploy.py가 이 이름으로 찾아가므로 함수명은 반드시 build여야 합니다.

from prefect.deployments.runner import RunnerDeployment

from flows.daily_sync import daily_sync


def build() -> RunnerDeployment:
    return daily_sync.to_deployment(name="daily-sync", cron="0 3 * * *")

로컬 실행

make가 진입점입니다. 인자 없이 실행하면 명령 목록이 나옵니다.

make
  help            명령 목록을 출력한다
  setup           의존성을 uv.lock 기준으로 설치한다
  check           임포트와 deployment 수집을 확인한다
  hello           hello 워크플로를 실행한다
  lifefourcuts    인생네컷 지점을 수집한다
  photoism        포토이즘 지점을 수집한다
  dontlxxkup      돈룩업 지점을 수집한다
  photosignature  포토시그니처 지점을 수집한다
  photogray       포토그레이 지점을 수집한다 (KAKAO_API_KEY 필요)
  planbstudio     플랜비스튜디오 지점을 수집한다
  picdot          픽닷 지점을 수집한다 (KAKAO_API_KEY 필요)
  monomansion     모노맨션 지점을 수집한다 (KAKAO_API_KEY 필요)
  harufilm        하루필름 지점을 수집한다 (KAKAO_API_KEY 필요)
  photolabplus    포토랩플러스 지점을 수집한다 (KAKAO_API_KEY 필요)
  broomstudio     비룸스튜디오 지점을 수집한다 (KAKAO_API_KEY 필요)
  collect         전체 브랜드를 병렬로 수집한다
  localstack      로컬 S3(LocalStack)를 띄운다
  localstack-down 로컬 S3를 내린다
  s3-init         로컬 버킷을 만든다
  s3-ls           적재된 키를 나열한다
  serve           로컬 개발용으로 deployment를 서빙한다
  server          Prefect 서버를 띄운다
  deploy          work pool에 스케줄을 등록한다
  build           wheel을 빌드하고 포함된 패키지를 확인한다
  image           컨테이너 이미지를 빌드하고 안에서 deployment 수집을 확인한다
  clean           빌드 산출물과 캐시를 지운다

준비

uv만 있으면 됩니다. 가상환경을 따로 만들거나 활성화하지 않아도 됩니다.

make setup

내부적으로 uv run을 씁니다. uv run은 .venv가 없으면 만들고 uv.lock에 맞춰 채운 뒤 실행하므로, 클론 직후 make hello를 바로 실행해도 동작합니다.

uv.lock은 반드시 커밋된 것을 그대로 씁니다. 지우고 다시 만들면 팀원마다 다른 버전이 설치되어 로컬에서만 재현되는 문제가 생깁니다.

환경변수는 .env.example을 복사해서 채웁니다.

cp .env.example .env

로컬 S3

수집 결과를 적재하려면 S3가 필요합니다. 로컬은 LocalStack을 씁니다.

make localstack

docker compose up으로 컨테이너를 띄우고 버킷까지 만듭니다. 적재된 내용은 make s3-ls로 확인하고, 다 쓰면 make localstack-down으로 내립니다. 컨테이너를 내리면 버킷 내용도 사라집니다. LocalStack 커뮤니티 판은 상태를 보존하지 않기 때문인데, make localstack이 버킷을 다시 만들어주므로 재생성 비용은 없습니다.

로컬과 운영의 차이는 환경변수뿐입니다. 코드는 endpoint를 모릅니다.

  • 로컬 : AWS_PROFILE=neki-local. aws/config의 endpoint_url이 LocalStack을 가리킴
  • 운영 : 프로파일을 쓰지 않음. k8s Secret이 AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION을 파드 환경변수로 넣고 boto3 기본 자격증명 체인이 집어감
AWS_PROFILE=neki-local   ->  http://localhost:4566
운영 (환경변수)          ->  https://s3.ap-northeast-2.amazonaws.com

aws/config에 있는 키는 LocalStack이 검증하지 않는 더미라 커밋되어 있습니다. 실제 자격증명은 이 파일에 두지 않습니다.

LocalStack 표준 포트는 4566입니다. 다른 프로젝트가 이미 쓰고 있다면 .env의 LOCALSTACK_PORT와 AWS_ENDPOINT_URL을 같이 옮깁니다. AWS_ENDPOINT_URL은 프로파일의 endpoint_url보다 우선합니다.

워크플로 실행

서버 없이 flow만 돌려볼 때 씁니다.

make hello
make lifefourcuts
make photoism
make dontlxxkup
make photosignature
make photogray
make planbstudio
make picdot
make monomansion
make harufilm
make photolabplus
make broomstudio
make collect
make enrich
make search-index

enrich 는 collect 가 남긴 브랜드별 최신 CSV 에 Kakao 로 법정동 코드를 붙여 tb_photo_booth_enriched 테이블에만 적재합니다. 결과는 S3 에 쓰지 않습니다. 두 번 돌리면 둘째는 전부 재사용이라 Kakao 를 부르지 않습니다. 정책은 docs/spec/enrich-pipeline.md 에 있습니다. search-index 는 로컬에서 NEKI_BATCH_IMAGE 가 없어 경고 후 끝납니다.

picdot, monomansion, photogray, harufilm, photolabplus, broomstudio는 Kakao Local API를 호출하므로 KAKAO_API_KEY가 필요합니다. planbstudio, photosignature, lifefourcuts, photoism, dontlxxkup은 키가 없어도 돌지만 좌표 보정만 건너뜁니다. .env에 넣어두면 make가 알아서 읽습니다. uv run은 .env를 자동으로 읽지 않으므로, Makefile을 거치지 않고 직접 실행할 때는 uv run --env-file .env ...로 지정해야 합니다.

Kakao Developers에서 앱을 만들고 앱 > 플랫폼 키 > REST API 키를 씁니다. 서버 호출용이라 플랫폼 등록이나 비즈 앱 전환은 필요 없습니다.

포토랩플러스는 사이트에 지점 목록이 있는데도 Kakao를 씁니다. 지역 탭이 무규칙한 iframe(tab000, tab00)으로 나뉘어 있고 사람이 손으로 만든 텍스트 위젯이라 주소가 두 줄로 쪼개진 항목이 있으며, 무엇보다 제주 지점 주소로 서울 주소가 들어가 있는 등 사이트가 틀린 값을 줍니다. 자세한 근거는 flows/photolabplus_stores/flow.py의 docstring에 있습니다.

비룸스튜디오는 브랜드 사이트가 아니라 Kakao 장소검색만이 수집원입니다. broomstudio.co.kr이 www, m 서브도메인까지 모두 NXDOMAIN이라 긁을 사이트가 없습니다. 표기가 비룸스튜디오와 비룸 스튜디오로 갈려 있어 flow가 질의어를 목록으로 받습니다. 기본값은 공백 없는 표기이며, 어느 쪽이 전량을 잡는지는 키를 확보한 뒤 total_count로 확인해야 합니다.

지점 수집 워크플로는 모두 결과를 S3에 적재하므로 make localstack이 먼저 떠 있어야 합니다. 적재 없이 파싱만 확인하려면 persist를 끕니다.

uv run --env-file .env python -c \
  "from flows.picdot_stores import picdot_stores; picdot_stores(persist=False)"

좌표 보정까지 끄려면 geocode도 함께 끕니다. 파싱만 볼 때는 Kakao를 부를 이유가 없습니다.

uv run --env-file .env python -c \
  "from flows.planbstudio_stores import planbstudio_stores; \
   planbstudio_stores(persist=False, geocode=False)"

make check는 임포트와 deployment 수집, docs/spec 의 anchor, tests/ 의 단위 테스트를 확인합니다. 구조를 바꾼 뒤 회귀를 빠르게 잡을 때 유용합니다.

UI로 확인하기

터미널 두 개가 필요합니다. 먼저 서버를 띄웁니다.

make server

다른 터미널에서 deployment를 서빙합니다.

make serve

http://127.0.0.1:4200/deployments에서 Run 버튼으로 실행할 수 있습니다. 두 프로세스가 모두 떠 있어야 합니다. 하나라도 없으면 목록에 안 뜨거나 눌러도 SCHEDULED에서 멈춥니다.

make serve는 PREFECT_API_URL을 대신 넣어줍니다. 직접 python serve.py를 실행할 때는 이 값을 지정해야 합니다. 기본 프로파일이 ephemeral이라 지정하지 않으면 프로세스 안에 임시 서버가 떴다 사라져 UI로 접근할 수 없습니다.

포트를 바꾸려면 변수를 넘깁니다.

make server PORT=4300
make serve PORT=4300

운영 배포

운영에서는 serve.py를 쓰지 않습니다. 코드는 컨테이너 이미지로 k3s 클러스터에 들어가고, 스케줄 등록은 클러스터 안에서 일어납니다. Prefect 서버가 외부에 노출되어 있지 않으므로 GitHub Actions는 서버에 접근하지 않습니다. Actions가 하는 일은 이미지 푸시와 GitOps 레포 태그 커밋 둘뿐입니다.

main merge
  -> build.yml      이미지 빌드, ghcr.io/team-neki/team-neki-workflow:<version>-<sha7> 와 :main 푸시
  -> build.yml      Team-Neki-GitOps overlays/prefect/worker.yaml 의 image 태그 커밋
  -> ArgoCD         worker Deployment 롤링
  -> initContainer  /opt/prefect 에서 python deploy.py (deployment 등록 갱신, pause 보존)
  -> 다음 flow run 부터 새 이미지

worker와 flow run(Job 파드)이 같은 이미지를 씁니다. 이미지에 WORKFLOW_IMAGE로 자기 참조가 구워져 있어 deploy.py가 그 값을 각 deployment의 job_variables.image에 넣습니다. 태그의 version은 pyproject.toml에서 읽습니다.

자격증명(KAKAO_API_KEY, DATABASE_URL 등)은 GitOps 레포의 k8s Secret prefect-workflow가 flow run Job 파드 환경변수로 넣습니다. worker 파드가 아니라 Job 파드입니다. flow 코드는 Job 안에서 돌기 때문에 worker에 env를 넣어도 flow에는 전달되지 않습니다. IAM role은 없고 코드는 환경변수만 봅니다. 매니페스트와 RBAC은 GitOps 레포 overlays/prefect/에 있습니다.

DATABASE_URL은 legal-dong, subway-station이 앱 DB(Team-Neki-Server의 PostgreSQL)에 적재할 때 쓰고, 지점 수집 flow가 tb_store_collect_manifest에 적재 위치를 남길 때도 씁니다. Prefect 메타DB가 아닙니다. 없으면 flow가 시작 직후 RuntimeError로 실패합니다. 새 flow가 환경변수를 추가로 읽으면 GitOps의 workflow-secret.example.yaml에 키를 같이 추가합니다.

Actions 준비

secrets는 둘뿐입니다.

  • GITHUB_TOKEN : 자동 제공. GHCR 푸시에 씀
  • GITOPS_PAT : Team-Neki 조직 secret. GitOps 레포에 contents:write 권한이 있는 PAT로 다른 저장소 CI와 같은 것을 씀. 조직 설정에서 이 저장소에 Repository access를 열어야 함

첫 푸시 뒤에는 GHCR 패키지를 public으로 바꿔야 클러스터가 인증 없이 당길 수 있습니다. Actions로는 바꿀 수 없어 한 번 손으로 합니다.

  1. https://github.com/orgs/Team-Neki/packages/container/team-neki-workflow/settings
  2. Danger Zone > Change visibility > Public

절차와 장애 대응은 docs/runbook.md에 있습니다.

이미지 확인

make image

이미지를 빌드하고 운영과 같은 조건(uid 1001, 읽기 전용 루트)으로 안에서 prefect 버전, deployment 수집, WORKFLOW_IMAGE를 확인합니다. PR의 ci.yml도 같은 것을 돌립니다.

로컬에서 직접 등록하기

클러스터 API로 터널을 열면 make deploy가 그대로 동작합니다. 이미지 밖에서 실행하므로 WORKFLOW_IMAGE를 직접 넘겨야 flow run 파드 이미지가 지정됩니다.

kubectl -n prefect port-forward svc/prefect-server 4200:4200
WORKFLOW_IMAGE=ghcr.io/team-neki/team-neki-workflow:main make deploy WORK_POOL=neki-pool

make deploy는 스케줄만 등록하고 끝납니다. 실행은 클러스터의 worker가 담당합니다.

pause가 배포에 지워지는 문제

Prefect는 deployment를 등록할 때 기존 정의를 통째로 덮어씁니다. 따라서 운영자가 UI에서 꺼둔 스케줄이 배포할 때마다 되살아납니다. Airflow에서 DAG를 pause하면 그 상태가 유지되는 것과 다릅니다.

serve.py는 프로세스가 뜰 때마다 재등록하므로 재시작할 때마다 풀리고, deploy()를 직접 호출하는 방식도 배포할 때마다 풀립니다. 스케줄의 active를 대신 꺼도 마찬가지로 되살아납니다. 반면 Prefect 서버나 worker의 재시작은 정의를 건드리지 않으므로 pause가 유지됩니다.

deploy.py는 배포 전 paused와 각 스케줄의 active를 읽어두고 등록 후 되돌려 이 문제를 막습니다. 반대로 UI에서 다시 켠 것을 배포가 도로 끄지도 않습니다.

따라서 운영 스케줄 등록은 반드시 deploy.py를 거쳐야 합니다. prefect deploy나 flow.deploy()를 직접 호출하면 보존 로직을 건너뛰어 꺼둔 스케줄이 되살아납니다.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages