k3sとprefectでAIワークフロー基盤を構築する

概要

PrefectはPythonでワークフローを記述可能なオーケストレーションツールです。 本記事では、軽量Kubernetesであるk3s上にPrefectServerを構築し、冗長構成のワークフロー基盤を構築する方法を紹介します。

環境設計

  • コンピューティング: k3s Node (3台)
    • OS: Ubuntu Server 24.04 LTS
    • CPU: 2CPU
    • MEM: 8GB
  • ネットワーキング
    • k3s Node同士が同一のL2ネットワークで疎通可能な環境
    • k3s Nodeがそれぞれインターネットに疎通可能な環境
    • k3s NodeがそれぞれIPアドレスを1個ずつ持っていること
    • MetalLB用に代表IPアドレスを1個用意すること
      • 代表IPアドレスに対してドメインを割り当てること

構成図

Whoamiサービス(接続テスト用)

flowchart TB
    Client["Client<br/>Browser"]
    VIP["代表IP/32<br/>MetalLB VIP"]
    subgraph K3s["K3s Cluster(3 Nodes)"]
        subgraph MetalLB["ns:metallb-system"]
            Pool["IPAddressPool<br/>代表IP/32"]
        end
        subgraph EnvoyGWSystem["ns:envoy-gateway-system"]
            GC["GatewayClass<br/>envoy"]
        end
        subgraph Public["ns:public"]
            Secret["Secret<br/>whoami-tls<br/>tls.crt / tls.key"]
            GW["envoy-gateway<br/>spec.addresses:代表IP"]
            Route["HTTPRoute Host/Path Any<br/>parentRefs: envoy-gateway"]
            SVC["whoami Service"]
            subgraph Pods["DaemonSet"]
                Pod1["whoami Pod1 :80"]
                Pod2["whoami Pod2 :80"]
                Pod3["whoami Pod3 :80"]
            end
        end
    end
    Client -->|"HTTPS :443"| VIP
    Pool -.->|"IP Pool allocation"| EnvoyGWSystem
    Pool -.->|"IP Pool advertisement"| VIP
    VIP -->|"HTTPS :443"| GW
    GC -.->|"gatewayClassName: envoy"| GW
    Secret -.->|"certificateRefs"| GW
    GW -->|"HTTP :80"| Route
    Route -->|"backendRef: svc:80"| SVC
    SVC --> Pod1
    SVC --> Pod2
    SVC --> Pod3

Prefect基盤

flowchart TB
    Client["Client<br/>Browser"]
    VIP["代表IP/32<br/>MetalLB VIP"]
    DNS["DNS"]
    subgraph K3s["K3s Cluster(3 Nodes)"]
        subgraph MetalLB["ns:metallb-system"]
            Pool["IPAddressPool<br/>代表IP/32"]
        end
        subgraph EnvoyGWSystem["ns:envoy-gateway-system"]
            GC["GatewayClass<br/>envoy"]
        end
        subgraph Public["ns:public"]
            Secret["Secret<br/>public-tls<br/>tls.crt / tls.key"]
            GW["envoy-gateway<br/>spec.addresses:代表IP"]
            Route["HTTPRoute<br/>Host: PrefectUIホスト名<br/>Path: Any<br/>parentRefs: envoy-gateway"]
            SVC["Prefect Service"]
            subgraph Pods["ReplicaSet"]
                Pod1["Prefect Server Pod"]
                Pod2["Prefect Worker Pod"]
            end
        end
    end
    Client -->|"HTTPS :443"| VIP
    Pool -.->|"IP Pool allocation"| EnvoyGWSystem
    Pool -.->|"IP Pool advertisement"| VIP
    DNS -.->|"Resolve<br/>prefect.example.local<br/>代表IP"| VIP
    VIP -->|"HTTPS :443"| GW
    GC -.->|"gatewayClassName: envoy"| GW
    Secret -.->|"certificateRefs"| GW
    GW -->|"HTTP :4200"| Route
    Route -->|"backendRef: prefect-server:4200"| SVC
    SVC --> Pod1
    Pod2 -.-> Pod1

環境構築

k3sクラスタの作成

まず、初めに3台のうち適当な1台のサーバでk3sクラスタを作成します。この1台で最初にクラスタを作り、他の2台をクラスタに参加させます。

注意点

  • 全部rootで実行します。
  • NW環境で10.0.0.0/8,172.16.0.0/12が使用済みであったのでクラスタの内部ネットワークはCIDRを192.168.0.0/16の範囲におさめます。(–cluster-cidr,–service-cidr)
  • k3sのLBに今回はmetallbを使うため、デフォルトのlb(servicelb)を無効化(–disable)します。
  • k3sのingressに今回はenvoyを使い、gatewayを使用するため、デフォルトのingress(traefik)を無効化(–disable)します。
  • 各サーバ筐体に複数のインターフェースがある為、念の為、外部I/Fをeth0に指定しています。(–flannel-iface)

1台目の設定

#!/bin/bash
# encoding: utf-8
openssl rand -hex 32 > /tmp/k3s_token.txt
curl -sfL https://get.k3s.io | K3S_TOKEN=$(cat /tmp/k3s_token.txt) sh -s - server \
    --cluster-init \
    --cluster-cidr=192.168.0.0/17 \
    --service-cidr=192.168.128.0/17 \
    --disable servicelb \
    --disable traefik \
    --flannel-iface="L2ネットワークインターフェース名"
# /tmp/k3s_token.txt の内容はクラスタに参加する際に必要なので、他の2台のサーバにコピーしておきます。

2台目,3台目の設定

#!/bin/bash
# encoding: utf-8
curl -sfL https://get.k3s.io | K3S_TOKEN="1台目で設定したk3s_token.txtの内容" sh -s - server \
    --server https://"1台目のIP":6443 \
    --cluster-cidr=192.168.0.0/17 \
    --service-cidr=192.168.128.0/17 \
    --disable servicelb \
    --disable traefik \
    --flannel-iface="L2ネットワークインターフェース名"

Warning

k3sのアンインストールをするには `/usr/local/bin/k3s-uninstall.sh` を実行します。ただし、ローカルにPersistent Volume (PV)を作っている場合はそれらの手動削除(rm)が必要です。


以後のコマンドは全て1台目のサーバで行います

Helmのインストール

HelmはKubernetesのパッケージマネージャです。 Helmを使うことでKubernetes上にアプリケーションを簡単にデプロイできます。 以下のコマンドでHelmをインストールします。

#!/bin/bash
# encoding: utf-8
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-4 | bash

MetalLBのインストール

MetalLBはL2ネットワークでKubernetesのLoadBalancerを実現するためのプラグインです。 ActiveなNodeがクラスタの代表IPアドレスを名乗る事により冗長化を実現します。

#!/bin/bash
# encoding: utf-8
helm repo add metallb https://metallb.github.io/metallb
helm repo update
helm pull --untar metallb/metallb
helm upgrade --install metallb ./metallb --namespace metallb-system --create-namespace

MetalLBの設定

MetalLBが代表するIPアドレスプールを設定します。 以下のYAMLファイルを作成し、kubectl apply -fで適用します。

# metallb-envoy-public.yaml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: metallb-public-ip-pool
  namespace: metallb-system
spec:
  addresses:
    - "代表IPアドレス/32"
  serviceAllocation:
    priority: 10
    namespaces:
      - envoy-gateway-system
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: envoy-gateway-system-public-l2-advertisement
  namespace: metallb-system
spec:
  ipAddressPools:
    - metallb-public-ip-pool
#!/bin/bash
# encoding: utf-8
kubectl apply -f metallb-envoy-public.yaml

これで Namespace が envoy-gateway-system のものに対して MetalLBが代表IPアドレスプールを割り当てるようになります。

Warning

MetalLBが割り当てるのはアドレスプールのみである事に注意してください。実際に割り当てるIPアドレスはGatewayのspec.addressesで指定する必要があります。

Envoy Gatewayのインストール

Envoy GatewayはKubernetes上で動作するL7ロードバランサです。MetalLBのIPアドレスプールを制御してインストールします。 MetalLB側で指定したnamespace(envoy-gateway-system)をここで作成します。

#!/bin/bash
# encoding: utf-8
helm pull --untar oci://docker.io/envoyproxy/gateway-helm --version v0.0.0-latest
helm install envoygateway ./gateway-helm -n envoy-gateway-system --create-namespace --set deployment.replicas=2

Tip

replicas=2 にすることで、片方のPodがダウンしてももう片方のPodが稼働し続けるため、サービスの可用性が向上します。(podAntiAffinityをセットすることでGatewayが2台のk3s Nodeに分散してデプロイされ、1台のNodeがダウンしてももう1台のNodeが稼働し続けるため、さらにサービスの可用性が向上します。)

Gatewayの作成

代表IPアドレスを指定してGatewayを作成します。 代表IPアドレスを利用可能なNamespaceを “public” とします。

# public-envoy-gateway.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: public
---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: envoy
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: envoy-gateway
  namespace: public
spec:
  gatewayClassName: envoy
  addresses:
    - type: IPAddress
      value: "代表IPアドレス"
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: Same
#!/bin/bash
# encoding: utf-8
kubectl apply -f public-envoy-gateway.yaml

テスト用HTTPRouteの作成

代表IPアドレスのGatewayで受けた通信のHTTPRouteを作成します。 whoamiというテスト用サーバーにルーティングするように設定します。

# whoami-http-route.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: whoami-http-route
  namespace: public
spec:
  parentRefs:
    - name: envoy-gateway
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: whoami
          port: 80
#!/bin/bash
# encoding: utf-8
kubectl apply -f whoami-http-route.yaml

テスト用サーバー(DaemonSet)のデプロイ

テスト用サーバーをデプロイします。 whoamiサービスはDaemonSetでデプロイすることで クラスタ内の全てのNodeにwhoami Podが配置され 代表IPアドレスにアクセスした際にどのNodeのPodに ルーティングされるかを確認することができます。

# whoami-daemonset.yaml
apiVersion: v1
kind: Service
metadata:
  name: whoami
  namespace: public
spec:
  type: ClusterIP
  selector:
    app: whoami
  ports:
    - name: http
      port: 80
      targetPort: 80
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: whoami
  namespace: public
spec:
  selector:
    matchLabels:
      app: whoami
  template:
    metadata:
      labels:
        app: whoami
    spec:
      containers:
        - name: whoami
          image: traefik/whoami
          ports:
            - containerPort: 80
#!/bin/bash
# encoding: utf-8
kubectl apply -f whoami-daemonset.yaml

確認

#!/bin/bash
# encoding: utf-8
curl "http://<代表IPアドレス>/"
# 3回ほど実行してwhoamiのPodが3台のNodeに分散していることを確認。

補足: TLS終端(自己署名証明書)

ワイルドカード自己署名証明書の設定

# openssl.cnf
[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = req_distinguished_name
x509_extensions = v3_ca

[req_distinguished_name]
C = JP
ST = Tokyo
L = Shinjuku
O = MyOrg
OU = MyOrgUnit
CN = *.example.local

[v3_req]
basicConstraints = critical, CA:false
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectKeyIdentifier = hash
subjectAltName = @alt_names

[alt_names]
DNS.1 = *.example.local
DNS.2 = example.local
IP.1 = 代表IPアドレス

自己署名証明書の作成とSecretの作成

10年有効な自己署名証明書を作成し、Secretに登録します。

#!/bin/bash
# encoding: utf-8
openssl genrsa -out tls.key 2048
openssl req \
  -x509 \
  -new \
  -key tls.key \
  -sha256 \
  -days 3650 \
  -out tls.crt \
  -config openssl.cnf \
  -extensions v3_req
kubectl create secret tls public-tls \
  -n public \
  --cert=tls.crt \
  --key=tls.key

TLS終端Gatewayの作成

既存のGatewayを編集する形で自己署名証明書によるTLS終端を有効化します。

# public-envoy-gateway.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: public
---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: envoy
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: envoy-gateway
  namespace: public
spec:
  gatewayClassName: envoy
  addresses:
    - type: IPAddress
      value: "代表IPアドレス"
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: Same
    - name: https
      protocol: HTTPS
      port: 443
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: public-tls

CloudNative-PG(PostgreSQL)のインストール

CloudNative-PGはKubernetes上で動作するPostgreSQLのOperatorです。冗長構成のPostgreSQLクラスタを簡単に構築できます。

helm repo add cnpg https://cloudnative-pg.github.io/charts
helm repo update
helm pull --untar cnpg/cloudnative-pg
helm upgrade --install cnpg ./cloudnative-pg --namespace cnpg-system --create-namespace 

CloudNative-PGクラスタの設定

今回はPrefect用に64GiBのPersistent Volumeを配置するように設定します。 PodAntiAffinityがprefferedになっているので、最大3台のNode(instance)に分散して配置されます。

# cloudnative-pg.yaml
--- # Create Namespace cnpg
kind: Namespace
apiVersion: v1
metadata:
    name: cnpg-system
--- # Create Secret for Initial Database
apiVersion: v1
data:
  username: ZGVmYXVsdA== # default
  password: ZGVmYXVsdA== # default
kind: Secret
metadata:
  name: default-postgres-secret
  namespace: cnpg-system
type: kubernetes.io/basic-auth
--- # Create Secret for prefect
apiVersion: v1
data:
  username: cHJlZmVjdA== # prefect
  password: cHJlZmVjdA== # prefect
kind: Secret
metadata:
  name: prefect-postgres-secret
  namespace: cnpg-system
type: kubernetes.io/basic-auth
--- # create Cluster and Initial Database and Additional Roles
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: cnpgcluster
  namespace: cnpg-system
spec:
  instances: 3
  storage:
    size: 64Gi
  bootstrap:
    initdb:
      database: default
      owner: default
      secret:
        name: default-postgres-secret
  managed:
    roles:
    - name: prefect
      ensure: present
      comment: PrefectServer
      login: true
      passwordSecret:
        name: prefect-postgres-secret
--- # Create prefect Database
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
  name: prefect-db
  namespace: cnpg-system
spec:
  name: prefect
  owner: prefect
  cluster:
    name: cnpgcluster

Prefect Serverのインストール

PrefectServerをインストールします。PrefectServerはPostgreSQLを利用するため、CloudNative-PGで作成したPostgreSQLクラスタの情報をSecretとして渡す必要があります。admin:pass というBasic認証を有効化してPrefectServerをインストールしますが、例ですので実際の運用ではもっと強固なパスワードに変更してください。

helm repo add prefect https://prefecthq.github.io/prefect-helm
helm repo update
helm pull --untar prefect/prefect-server
helm upgrade --install prefect-server ./prefect-server \
  --namespace public \
  --set global.prefect.image.prefectTag=3.8.2-python3.12 \
  --set server.basicAuth.enabled=true \
  --set server.basicAuth.authString="admin:pass" \
  --set server.uiConfig.prefectUiApiUrl="https://prefect.example.local/api" \
  --set secret.create=true \
  --set secret.name="prefect-postgresql-secret" \
  --set secret.username="prefect" \
  --set secret.password="prefect" \
  --set secret.host="cnpgcluster-rw.cnpg-system.svc.cluster.local" \
  --set secret.port="5432" \
  --set secret.database="prefect" \
  --set postgresql.enabled=false \
  --set postgresql.existingSecret="prefect-postgresql-secret"

prefect-serverという名前でServiceが作成されます。

Prefect Server へのアクセス

HTTPRouteの作成

先ほどHelmで自動作成されたServiceへのルーティングを追加します。

# prefect-http-route.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: prefect-http-route
  namespace: public
spec:
  parentRefs:
    - name: envoy-gateway
  hostnames: 
    - "prefect.example.local"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: prefect-server
          port: 4200
#!/bin/bash
# encoding: utf-8
kubectl apply -f prefect-http-route.yaml

Webブラウザ上で自己署名証明書のインストールとログイン

  1. https://prefect.example.local/api/admin/version にアクセスして自己署名証明書を信頼します。
  2. https://prefect.example.local/ にアクセスしてPrefect UIが表示されることを確認します。
  3. ログインパスワードは admin:pass のはずです。

Prefect Workerのインストール

helm repo add prefect https://prefecthq.github.io/prefect-helm
helm repo update
helm pull --untar prefect/prefect-worker
helm upgrade --install prefect-worker ./prefect-worker \
  --namespace public \
  --set worker.apiConfig=selfHostedServer \
  --set worker.config.workPool=work_pool_local_k3s \
  --set worker.selfHostedServerApiConfig.apiUrl=http://prefect-server.public.svc.cluster.local:4200/api \
  --set worker.selfHostedServerApiConfig.basicAuth.enabled=true \
  --set worker.selfHostedServerApiConfig.basicAuth.authString="admin:pass" \
  --set worker.image.prefectTag=3.8.2-python3.12-kubernetes

以下のようにWorkPoolが作成され、KubernetesWorkerが登録されます。

work_pool_local_k3s.png

Note

KubernetesWorkerはEnvoy経由(つまり、k8sクラスタの外側)からでも登録可能ですが、今回はPrefectServerと同じk3sクラスタ内に配置するため、prefect-server.public.svc.cluster.local:4200/api という内部DNS名でアクセスしています。

Prefectワークフローのテスト実行

確認のため、1台目のサーバからk8sの “外側から” PrefectServerに接続するサンプルスクリプト( hello.py )を作成、実行します。

#!/usr/bin/env python3
# encoding: utf-8

import asyncio

from prefect import flow, get_run_logger, task

@task(log_prints=True)
async def print_log_prints(text: str) -> None:
    print(f"log_prints=True: {text}")

@task
async def log_prefect_run_logger(text: str) -> None:
    logger = get_run_logger()
    logger.info(f"Prefect runtime logger: {text}")

@flow
async def hello_world_flow(text: str) -> None:
    await print_log_prints(text)
    await log_prefect_run_logger(text)

if __name__ == "__main__":
    asyncio.run(hello_world_flow("Hello, Prefect!"))

実行の際に今回構築したPrefect Serverを指定します。 仮想環境はuvを使用します。

mkdir myproj
cd myproj
uv init
# Prefect Server/Workerのバージョンになるべく合わせる
uv add prefect==3.8.2
# Prefect ServerのURLを指定
uv run prefect config set PREFECT_API_URL="https://prefect.example.local/api"
# Prefect ServerのBasic認証を指定
uv run prefect config set PREFECT_API_AUTH_STRING="admin:pass"
# Prefect Serverの自己署名証明書を無視する設定
uv run prefect config set PREFECT_API_TLS_INSECURE_SKIP_VERIFY="true"
uv run hello.py

実行結果、標準出力に以下が生成されます。

06:10:58.650 | INFO    | prefect.engine - View at https://prefect.example.local/runs/flow-run/88ca1546-310b-45f1-ae31-16d64dd135a6
06:10:58.829 | INFO    | Flow run 'sincere-manticore' - Beginning flow run 'sincere-manticore' for flow 'hello-world-flow'
06:10:58.834 | INFO    | Flow run 'sincere-manticore' - View at https://prefect.example.local/runs/flow-run/88ca1546-310b-45f1-ae31-16d64dd135a6
06:10:58.857 | INFO    | Task run 'print_log_prints-458' - log_prints=True: Hello, Prefect!
06:10:58.859 | INFO    | Task run 'print_log_prints-458' - Finished in state Completed()
06:10:58.863 | INFO    | Task run 'log_prefect_run_logger-b30' - Prefect runtime logger: Hello, Prefect!
06:10:58.865 | INFO    | Task run 'log_prefect_run_logger-b30' - Finished in state Completed()
06:10:58.919 | INFO    | Flow run 'sincere-manticore' - Finished in state Completed()
06:10:58.970 | WARNING | EventsWorker - Still processing items: 6 items remaining...

ServerUIから確認すると、以下のようにログが出力されていることが確認できます。(実行時間が短いのでキレイなグラフにはなっていません) pure_python_run_hello.png

Warning

PrefectライブラリのバージョンはPrefect Server, Prefect Workerと合わせておくとトラブルシューティングが容易です。今回の例ではPrefect 3.8.2を使用しています。

Prefectワークフロー開発環境の構築

Prefectワークフロー開発のキーポイント

PrefectワークフローはPrefectServerに登録されたWorkPoolに所属するPrefectWorkerが動作している実行環境にデプロイされ実行されます。つまり、PrefectServerとPrefectWorkerが疎通していればクラウドやオンプレなど様々な実行環境にPrefectワークフローをデプロイすることが可能です。実行されるワークフローのコードは毎回PrefectServerが外部に参照しに行く構成となります。例えば、コードの管理とコンテナの管理はGithub、実行環境はオンプレk8sやAWSのECS、テスト環境はローカルのDockerコンテナやローカルマシン上のPythonプロセス・・・という形で、ワークフローの構成を複数組み立てることで柔軟なワークフロー開発が可能となります。

Prefect Deployment

PrefectのワークフローはPythonスクリプトとして作成されます。Prefect Deploymentを使用することで、ワークフローをPrefectServerに登録することができます。今回はPrefectWorkerとしてk8s WorkPoolを作成しておりますので、k8sのBatchJobとしてPrefectワークフローを実行することができます。k8sのBatchJobでは、ワークフローの実行環境をコンテナ化し、依存関係の管理やスケーリングが容易になります。

Prefect Variables/Blocks

Prefectのワークフローで使用する変数やシークレットはVariables/BlocksとしてPrefectServerに登録することができます。PrefectワークフローはPrefectServerに問い合わせすることでこれらのVariables/BlocksをPythonスクリプトから参照することが可能になります。

Github Workflows (CI/CD)

今回はCI/CDでGithub上でコード管理、コンテナ管理、シークレット管理を行う為、Github Workflowsを使用します。

  • Github Workflowsのワークフローシーケンス
    • Developer: コードをpushする開発者
    • Github: いわゆるGithubCloud(github.com)
    • SelfHostedActionsRunner: 今回構築したPrefectServer及びGithubCloudとの接続性を有するGithubActionsRunner
    • GithubContainerRegistry: GithubCloudのPackagesコンテナレジストリ(ghcr.io)
    • PrefectServer: 今回構築したPrefectServer(prefect.example.local)
sequenceDiagram
    participant Developer
    participant Github
    participant SelfHostedActionsRunner
    participant GithubContainerRegistry
    participant PrefectServer
    Developer->>Github: Github Secrets/Variables
    Developer->>Github:Push Code Changes
    Github->>SelfHostedActionsRunner:Detect Code Changes
    alt Github CI pipeline
        SelfHostedActionsRunner->>GithubContainerRegistry:Build & Push DockerImage
        Github->>SelfHostedActionsRunner:Github Secrets/Variables
        SelfHostedActionsRunner->>PrefectServer:Update Prefect Blocks/Variables
        SelfHostedActionsRunner->>PrefectServer:Deploy Prefect Workflow
    end
  • GithubActionsのそれぞれのWorkflowの役割
# .github/workflows/
entrypoint.yaml: 1. 全体管理
├── docker_build_and_push.yaml: 2. ワークフロー用DockerイメージのBuildとPush
└── deploy_prefect.yaml: 3. ワークフロー用BlocksをGithubSecretsと同期しワークフローをデプロイ
  • 必要なGithub Secrets
    • PREFECT_API_AUTH_STRING: PrefectServerのBasic認証のユーザ名とパスワードをコロンで連結した文字列(admin:pass)
  • 必要なGithub Variables
    • PREFECT_API_URL: “https://prefect.example.local/api”
    • PREFECT_API_TLS_INSECURE_SKIP_VERIFY: “true”

Workflows

entrypoint.yaml

name: entrypoint
on:
  push: # git push をトリガーにする。
  workflow_dispatch: # git push 以外からの手動実行を可能にする
  workflow_call:
    secrets: # 必要なsecretの指定
      PREFECT_API_AUTH_STRING:
        description: 'PrefectServerのBasic認証のユーザ名とパスワードをコロンで連結した文字列(admin:pass)'
        required: true
jobs:
  # コードベースのPull、DockerイメージのRunner上でのBuild、GithubContainerRegistryへのPush
  docker_build_and_push:
    uses: ./.github/workflows/docker_build_and_push.yaml
    with:
      DOCKER_REGISTRY: ghcr.io
      DOCKER_IMAGE_NAME: ${{ github.repository }}
      DOCKER_USERNAME: ${{ github.actor }}
      DOCKER_WILL_PUSH: ${{ github.event_name != 'pull_request' }} # pull_requestの時はDockerイメージをPushしない
    concurrency: # 親が同時に複数動作しないようにする。
      group: ${{ github.workflow }}-${{ github.ref }}
      cancel-in-progress: true # 後勝ちでWorkflowが実行される
  # PrefectServerにBlocks/Variablesを登録しDeployする
  deploy_prefect:
    needs: [docker_build_and_push]
    uses: ./.github/workflows/deploy_prefect.yaml
    with:
      PREFECT_API_URL: ${{ vars.PREFECT_API_URL }}
      PREFECT_API_TLS_INSECURE_SKIP_VERIFY: ${{ vars.PREFECT_API_TLS_INSECURE_SKIP_VERIFY }}
      DOCKER_REGISTRY: ghcr.io
      DOCKER_IMAGE_NAME: ${{ github.repository }}
    secrets:
      PREFECT_API_AUTH_STRING: ${{ secrets.PREFECT_API_AUTH_STRING }} # PrefectServerにGithubContainerRegistryの認証情報等様々なBlocks/Variablesを登録するのに必要
    concurrency: # 親が同時に複数動作しないようにする。
      group: ${{ github.workflow }}-${{ github.ref }}
      cancel-in-progress: true # 後勝ちでWorkflowが実行される

docker_build_and_push.yaml

name: docker_build_and_push
on:
  workflow_call:
    inputs:
      DOCKER_REGISTRY:
        description: 'Github Container Registry(ghcr.io)'
        required: true
        type: string
      DOCKER_IMAGE_NAME:
        description: 'Github Container Registry Repository Name(github.repository)'
        required: true
        type: string
      DOCKER_USERNAME:
        description: 'Github Container Registry UserName(github.actor)'
        required: true
        type: string
      DOCKER_WILL_PUSH:
        description: 'Whether Should Push Container Image to Registry or NOT'
        required: true
        type: boolean
jobs:
  main:
    runs-on: self-hosted
    defaults:
      run:
        shell: bash
    permissions:
      contents: read # Github Code Repository
      packages: write # Github Container Registry
    steps:
      -
        name: Check out source repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout v7.0.1
        with:
          fetch-depth: 1 # shallow clone
      -
        name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # https://github.com/docker/metadata-action v6.2.0
        with:
          images: ${{ inputs.DOCKER_REGISTRY }}/${{ inputs.DOCKER_IMAGE_NAME }}
          tags: |
            # set latest tag for default branch
            type=raw,value=latest,enable={{is_default_branch}}
      -
        name: Login to GitHub Container Registry
        uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # https://github.com/docker/login-action v4.6.0
        with:
          registry: ${{ inputs.DOCKER_REGISTRY }}
          username: ${{ inputs.DOCKER_USERNAME }}
          password: ${{ secrets.GITHUB_TOKEN }}
      -
        name: Set up Docker Buildx
        uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # https://github.com/docker/setup-buildx-action v4.2.0
      -
        name: Build and push
        uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # https://github.com/docker/build-push-action v7.3.0
        with:
          context: . # リポジトリ直下のDockerfileを使います。
          push: ${{ inputs.DOCKER_WILL_PUSH }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=${{ inputs.DOCKER_REGISTRY }}/${{ inputs.DOCKER_IMAGE_NAME }}:buildcache
          cache-to: type=registry,ref=${{ inputs.DOCKER_REGISTRY }}/${{ inputs.DOCKER_IMAGE_NAME }}:buildcache,mode=max

deploy_prefect.yaml

name: deploy_prefect
on:
  workflow_call:
    inputs:
      PREFECT_API_URL:
        description: 'API Endpoint for Prefect Server'
        required: true
        type: string
      PREFECT_API_TLS_INSECURE_SKIP_VERIFY:
        description: 'Ignore or not, TLS verification error'
        required: true
        type: string
      DOCKER_REGISTRY:
        description: 'Github Container Registry'
        required: true
        type: string
      DOCKER_IMAGE_NAME:
        description: 'Github Container Registry Repository Name'
        required: true
        type: string
    secrets:
      PREFECT_API_AUTH_STRING:
        description: 'Prefect API Auth String'
        required: true
jobs:
  main:
    runs-on: self-hosted
    defaults:
      run:
        shell: bash
    permissions:
      contents: read # Github Code Repository
    steps:
      -
        name: Check out source repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # https://github.com/actions/checkout v7.0.1
        with:
          fetch-depth: 1
      -
        name: Install uv
        uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # https://github.com/astral-sh/setup-uv v10.0.1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
      -
        name: Setup Python 3.12
        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # https://github.com/actions/setup-python v7.0.0
        with:
          python-version: '3.12'
      -
        name: Install Prefect
        run: |
          set -x
          uv init
          uv add -r requirements.txt
        # TODO: version pin (uv.lock and sync --frozen)
      -
        name: Login to Prefect Server
        run: |
          uv run prefect config set PREFECT_API_AUTH_STRING=${{ secrets.PREFECT_API_AUTH_STRING }}
          set -x
          uv run prefect config set PREFECT_API_URL=${{ inputs.PREFECT_API_URL }}
          uv run prefect config set PREFECT_API_TLS_INSECURE_SKIP_VERIFY=${{ inputs.PREFECT_API_TLS_INSECURE_SKIP_VERIFY }}
          uv run prefect server status
        # 認証情報は ~/.prefect/profiles.toml に保存されます
      -
        name: Update Prefect Blocks
        run: |
          set -x
          uv run settings.py
        env:
          THIS_IS_SECRET_ENV: SECRETS # {{ secrets.THIS_IS_SECRET_ENV }}
          THIS_IS_VARIABLE_ENV: VARIABLES # {{ inputs.THIS_IS_VARIABLE_ENV }}
      -
        name: Deploy Prefect Workflows
        run: |
          set -x
          uv run prefect deploy --all
        # prefect.yamlに定義されたPrefectワークフローをPrefectServerにデプロイします
        env:
          DOCKER_REGISTRY: ${{ inputs.DOCKER_REGISTRY }}
          DOCKER_IMAGE_NAME: ${{ inputs.DOCKER_IMAGE_NAME }}

Dockerfile

Dockerfileはほぼ定型文です。

# Global Variables
ARG _PYTHON_VERSION=3.12
# Use the official Python image with uv pre-installed
FROM ghcr.io/astral-sh/uv:python${_PYTHON_VERSION}-bookworm-slim
# Set the working directory
WORKDIR /opt/prefect/
# Set environment variables
ENV UV_SYSTEM_PYTHON=1
ENV PYTHONUNBUFFERED=1
ENV PATH="/root/.local/bin:$PATH"
# Copy only the requirements file first to leverage Docker cache
COPY requirements.txt .
# Install dependencies
RUN --mount=type=cache,target=/root/.cache/uv \
    uv pip install -r requirements.txt
# Copy the rest of the application code
COPY . /opt/prefect/

Prefectワークフローで使うPythonライブラリはrequirements.txtで管理します。

settings.py

settings.pyもほぼ定型文です。本来はリポジトリのSecretsにTHIS_IS_SECRET_ENVTHIS_IS_VARIABLE_ENVをそれぞれ登録し deploy_prefect.yamlでそれらの値を環境変数として渡すことでGithubSecrets/VariablesをPrefect Blocks/Variablesを同期することができます。

#!/usr/bin/env python
# encoding: utf-8
import os
import asyncio

from prefect import flow, task
from prefect.blocks.system import Secret
from prefect.variables import Variable


@task(log_prints=True)
async def set_blocks() -> None:
    THIS_IS_SECRET_ENV = os.environ["THIS_IS_SECRET_ENV"]
    async with asyncio.TaskGroup() as t:
        t.create_task(
            Secret(value=THIS_IS_SECRET_ENV).save(name="this-is-secret-block", overwrite=True)
        )


@task(log_prints=True)
async def set_variables() -> None:
    THIS_IS_VARIABLE_ENV = os.environ["THIS_IS_VARIABLE_ENV"]
    async with asyncio.TaskGroup() as t:
        t.create_task(
            Variable.set(name="this-is-variable", value=THIS_IS_VARIABLE_ENV, tags=["test",], overwrite=True)
        )

@flow
async def setup() -> None:
    async with asyncio.TaskGroup() as t:
        t.create_task(set_blocks())
        t.create_task(set_variables())


if __name__ == "__main__":
    asyncio.run(setup())

prefect.yaml

GithubContainerRegistry imagePullSecrets

prefect.yamlを記述する前に、当該リポジトリにおけるGithubContainerRegistry(Packages)へのRead権限を保有したPAT(classic)を取得しておきます。 これは、PrefectWorker(k8s job)がGithubContainerRegistryからDockerイメージをPullするのに必要です。 それをk8sのSecretとして登録します。ここでは一旦Secret名をprefect-ghcr-secretとします。

kubectl create secret docker-registry prefect-ghcr-secret \
  --namespace public \
  --docker-server=ghcr.io \
  --docker-username="GITHUB_PATの発行元ユーザ名" \
  --docker-password="GITHUB_PAT値"

作成したSecretをimagePullSecretsに指定したServiceAccountを作成します。ここでは一旦SA名をprefect-service-accountとします。

# prefect-service-account.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: prefect-service-account
  namespace: public
imagePullSecrets:
- name: prefect-ghcr-secret

applyします。

kubectl apply -f prefect-service-account.yaml

このSAはPrefect deploymentsのjob_variablesでservice_account_nameとして指定します。

prefect.yamlの内容

ほぼprefect initで出力される内容と同じです。

# Welcome to your prefect.yaml file! You can use this file for storing and managing
# configuration for deploying your flows. We recommend committing this file to source
# control along with your flow code.

# Generic metadata about this project
name: "dev"
prefect-version: "3.8.2"

# build section allows you to manage and build docker images
build: null

# push section allows you to manage if and how this project is uploaded to remote locations
push: null

# pull section allows you to provide instructions for cloning this project in remote locations
pull:
- prefect.deployments.steps.set_working_directory:
    directory: "/opt/prefect"

# Pointers to the work pools that this project will use for deployments
work_pool_local_k3s: &work_pool_local_k3s
  name: "work_pool_local_k3s"
  job_variables:
    image: "{{ $DOCKER_REGISTRY }}/{{ $DOCKER_IMAGE_NAME }}:latest"
    namespace: "public"
    service_account_name: "prefect-service-account"
    image_pull_policy: "Always"
    finished_job_ttl: 30

deployments:
- name: "hello_world"
  flow_name: "flow_hello_world"
  entrypoint: "flows/hello_world.py:hello_world"
  description: "Hello World"
  tags:
    - "misc"
  work_pool:
    <<: *work_pool_local_k3s
- name: "hello_secret_variables"
  flow_name: "flow_hello_secret_variables"
  entrypoint: "flows/hello_secret_variables.py:hello_secret_variables"
  description: "Hello World with Prefect Variables and Blocks"
  tags:
    - "misc"
  work_pool:
    <<: *work_pool_local_k3s

flows/hello_world.py

#!/usr/bin/env python3
# encoding: utf-8

import asyncio

from prefect import flow, get_run_logger, task

@task(log_prints=True)
async def print_log_prints(text: str) -> None:
    print(f"log_prints=True: {text}")

@task
async def log_prefect_run_logger(text: str) -> None:
    logger = get_run_logger()
    logger.info(f"Prefect runtime logger: {text}")

@flow
async def hello_world(text: str = "Hello Prefect!") -> None:
    await print_log_prints(text)
    await log_prefect_run_logger(text)

if __name__ == "__main__":
    asyncio.run(hello_world("Hello, Prefect!"))

flows/hello_secret_variables.py

#!/usr/bin/env python3
# encoding: utf-8

import asyncio

from prefect import flow, get_run_logger, task
from prefect.blocks.system import Secret
from prefect.variables import Variable

@task(log_prints=True)
async def print_log_prints() -> None:
    v = await Variable.get("this-is-variable")
    print(f"log_prints=True: {v}")

@task
async def log_prefect_run_logger() -> None:
    logger = get_run_logger()
    s = (await Secret.load("this-is-secret-block")).get()
    logger.info(f"Prefect runtime logger: {s}")

@flow
async def hello_secret_variables() -> None:
    await print_log_prints()
    await log_prefect_run_logger()

if __name__ == "__main__":
    asyncio.run(hello_secret_variables())