Snowflake Gateway による A/B テスト用トラフィック分割を試してみた
はじめに
2026年8月のアップデートで、リアルタイム推論サービスとしてデプロイされた ML モデルを複数のモデルバージョンにトラフィックを振り分けながら、品質を継続的に監視・比較できる Gateway Monitoring と A/B Testing が一般提供となりました。
こちらについて、特にゲートウェイの作成からトラフィックの分割について試してみましたので、その内容を本記事でまとめてみます。
本機能の概要
本機能については以下に記載があります。
Snowflake では Model Registry という機能に機械学習モデル(ML モデル)を登録することで、そのメタデータを管理することができます。
Model Registry に登録したモデルは、サービスとしてコンテナ(SPCS)で実行することが可能です。これにより、リアルタイム推論サービスとしてデプロイできます。
今回のアップデートでは、以下の機能群によりデプロイ後のモデルの運用・監視が容易になります。
- Snowflake Gateway:複数のモデルバージョン(サービス)に対してトラフィックを割合で分配できるエンドポイント(traffic split gateway)を提供する。A/B テストの土台となる仕組みとして利用できる
- Gateway Monitoring:ゲートウェイ経由の推論ログを Auto Capture で自動収集し、ドリフト(予測分布のズレ)・性能指標(精度・RMSE など)・統計指標を定期的に集計できる
- A/B Testing:Gateway によるトラフィック分割と Gateway Monitoring を組み合わせることで、既存モデル(baseline)と新モデル(challenger)を実際のユーザーリクエストで比較できる
また、現時点での主な制約は以下の通りです。
- 単一出力の二値分類・回帰・多クラス分類モデルのみサポートされる
- 監視対象にしたい推論サービスは、すべて Auto Capture を有効化しておく必要がある
Model Registry や SPCS へのモデルデプロイについては、以下の記事でも紹介されていますので、あわせてご参照ください。
試してみる
前提条件
モデルの学習自体は Snowflake の機能ではなく、通常の Python の機械学習ワークフロー(scikit-learn / XGBoost 等)で行い、学習済みモデルを snowflake.ml.registry.Registry の log_model() で Model Registry に登録する流れです。
ここでは以下の設定としています。
- 実行環境:Snowflake Notebooks
- モデル
- ここでは簡単に scikit-learn の
LinearRegression(単純な線形回帰)を用いました - また、モデル自体もデータからパラメータを推定せずに、パラメータ値(傾き・切片)を直接代入する形としています
- ここでは簡単に scikit-learn の
モデルを作成して Model Registry に登録する
Workspace 上の Notebook でライブラリ・コンテキストを設定し、v1 モデル(ここでのベースモデル)を用意しました。
import numpy as np
from sklearn.linear_model import LinearRegression
from snowflake.ml.registry import Registry
from snowflake.snowpark.context import get_active_session
session = get_active_session()
session.use_database("YASUHARA_TEST_DB")
session.use_schema("PUBLIC")
reg = Registry(session=session)
# ベースモデル: y = 2x + 3
model_v1 = LinearRegression()
model_v1.coef_ = np.array([2.0])
model_v1.intercept_ = 3.0
# 入力データの見本
sample_input = np.array([[1.0], [2.0], [3.0]])
mv1 = reg.log_model(
model_v1,
model_name="my_model",
version_name="v1",
conda_dependencies=["scikit-learn"],
metrics={"score": 1.0},
sample_input_data=sample_input
)
続けて、challenger(v2)としてパラメータの値を変えたモデルも同様に登録します。
# challenger(v2):あえてズラしたパラメータのモデル
# y = 1.2x + 8
model_v2 = LinearRegression()
model_v2.coef_ = np.array([1.2])
model_v2.intercept_ = 8.0
mv2 = reg.log_model(
model_v2,
model_name="my_model",
version_name="v2",
conda_dependencies=["scikit-learn"],
metrics={"score": 0.6},
sample_input_data=sample_input
)
コンピュートプールを作成
リアルタイム推論でサービングするため、SPCS(Snowpark Container Services)上でモデルサービスを稼働させる Compute Pool を作成します。
CREATE COMPUTE POOL IF NOT EXISTS yasuhara_inference_pool
MIN_NODES = 1
MAX_NODES = 2
INSTANCE_FAMILY = 'CPU_X64_XS'
AUTO_RESUME = TRUE;
モデルを推論サービスとしてデプロイする
v1・v2 それぞれを推論サービスとしてデプロイします。
この際、Auto Captureを有効化します。監視対象にする推論サービスは Auto Capture の有効化が必要となります。注意点として、autocapture は一度作成したサービスに対して後から有効化することはできません。
mv1 = reg.get_model("MY_MODEL").version("V1")
mv1.create_service(
service_name="MY_MODEL_V1_SERVICE",
service_compute_pool="yasuhara_inference_pool",
ingress_enabled=True,
autocapture=True
)
mv2 = reg.get_model("MY_MODEL").version("V2")
mv2.create_service(
service_name="MY_MODEL_V2_SERVICE",
service_compute_pool="yasuhara_inference_pool",
ingress_enabled=True,
autocapture=True
)
デプロイ後、Snowsight でもサービスの状態を確認できました。


推論方法ごとの Auto Capture の記録有無を確認する
ゲートウェイ作成前に、各サービス単体で推論を試してみます。
SQL と Python それぞれ以下のように推論できます。
-- SQLで推論
> SELECT YASUHARA_TEST_DB.PUBLIC.MY_MODEL_V1_SERVICE!predict(5.0);
+----------------------------------------------------------+
| YASUHARA_TEST_DB.PUBLIC.MY_MODEL_V1_SERVICE!PREDICT(5.0) |
|----------------------------------------------------------|
| { |
| "output_feature_0": 13 |
| } |
+----------------------------------------------------------+
Python の例:
import pandas as pd
mv1 = reg.get_model("MY_MODEL").version("V1")
input_df = pd.DataFrame({"input_feature_0": [5.0]})
result = mv1.run(input_df, function_name="predict", service_name="MY_MODEL_V1_SERVICE")
print(result)
上記で単発実行はできましたが、Auto Capture がログを記録するのはリアルタイム推論(REST API 経由)のリクエストのみでした。
そのため、ログを溜めたい場合は、REST API エンドポイント経由でリクエストを送る必要があります。
curl -X POST "https://<サービスの ingress_url>/predict" \
-H "Content-Type: application/json" \
-H 'Authorization: Snowflake Token="<PAT トークン>"' \
-d '{"dataframe_split": {"index": [0], "columns": ["input_feature_0"], "data": [[5.0]]}}'
サービスのエンドポイントは、以下で確認できます。
SHOW ENDPOINTS IN SERVICE YASUHARA_TEST_DB.PUBLIC.MY_MODEL_V1_SERVICE;
なお、このエンドポイントに外部からアクセスする際は、当然認証が必要です。
ノートブック内から呼ぶ場合は、セッショントークンを使って以下のようにリクエストできます。
import requests
url = "http://my-model-v1-service.xxxxx.svc.spcs.internal:5000/predict"
headers = {
"Content-Type": "application/json"
}
payload = {
"dataframe_records": [{"input_feature_0": 5.0}]
}
resp = requests.post(url, json=payload, headers=headers)
print(resp.status_code)
print(resp.json())
上記で URL として指定している内部 DNS 名・ポートは、以下で取得できます。
- 内部 DNS 名
DESCRIBE SERVICE YASUHARA_TEST_DB.PUBLIC.MY_MODEL_V1_SERVICE
->> SELECT "dns_name" FROM $1;
+--------------------------------------------+
| dns_name |
|--------------------------------------------|
| my-model-v1-service.xxxx.svc.spcs.internal |
+--------------------------------------------+
- ポート
SHOW ENDPOINTS IN SERVICE YASUHARA_TEST_DB.PUBLIC.MY_MODEL_V1_SERVICE;
REST API 経由でリクエスト後、Auto Capture で自動収集された推論ログを INFERENCE_TABLE テーブル関数で取得できます。
-- Auto Capture が自動収集した推論ログを確認
SELECT * FROM TABLE(INFERENCE_TABLE('MY_MODEL'));
+-------------------------+-----------------+-------------------------+-------+----------+---------------------------------------------------------------+-----------------------------+------------------+-------------+--------+------------------------------------------------------------------------------+-------+-----------+
| TIMESTAMP | START_TIMESTAMP | OBSERVED_TIMESTAMP | TRACE | RESOURCE | RESOURCE_ATTRIBUTES | SCOPE | SCOPE_ATTRIBUTES | RECORD_TYPE | RECORD | RECORD_ATTRIBUTES | VALUE | EXEMPLARS |
|-------------------------+-----------------+-------------------------+-------+----------+---------------------------------------------------------------+-----------------------------+------------------+-------------+--------+------------------------------------------------------------------------------+-------+-----------|
| 2026-09-04 09:30:14.915 | NULL | 2026-09-04 09:30:14.915 | NULL | NULL | { | { | NULL | LOG | NULL | { | null | NULL |
| | | | | | "snow.account.name": "xxxxx", | "name": "inference-table" | | | | "snow.model_serving.function.name": "predict", | | |
| | | | | | "snow.compute_pool.id": 72, | } | | | | "snow.model_serving.hop_ids": [ | | |
| | | | | | "snow.compute_pool.name": "YASUHARA_INFERENCE_POOL", | | | | | 4 | | |
| | | | | | "snow.compute_pool.node.id": "10.16.69.57", | | | | | ], | | |
| | | | | | "snow.compute_pool.node.instance_family": "CPU_X64_XS", | | | | | "snow.model_serving.last_hop_id": 4, | | |
| | | | | | "snow.database.id": 294, | | | | | "snow.model_serving.request.data.input_feature_0": 5, | | |
| | | | | | "snow.database.name": "YASUHARA_TEST_DB", | | | | | "snow.model_serving.request.extra_columns.request_id": "5", | | |
| | | | | | "snow.executable.engine": "SnowparkContainers", | | | | | "snow.model_serving.request.timestamp": "2026-09-04T09:30:14.910084066Z", | | |
| | | | | | "snow.executable.type": "MODEL_INFERENCE_INTERNAL_SERVICE", | | | | | "snow.model_serving.response.code": 200, | | |
| | | | | | "snow.model.version.id": 270, | | | | | "snow.model_serving.response.data.output_feature_0": 13, | | |
| | | | | | "snow.model.version.name": "V1", | | | | | "snow.model_serving.response.timestamp": "2026-09-04T09:30:14.915649429Z", | | |
| | | | | | "snow.query.id": "01c6d742-0104-948d-0000-00b40c93c39e", | | | | | "snow.model_serving.truncation_policy": "NONE" | | |
| | | | | | "snow.schema.id": 1142, | | | | | } | | |
| | | | | | "snow.schema.name": "PUBLIC", | | | | | | | |
| | | | | | "snow.service.container.name": "proxy", | | | | | | | |
| | | | | | "snow.service.id": 301, | | | | | | | |
| | | | | | "snow.service.instance": "0", | | | | | | | |
| | | | | | "snow.service.name": "MY_MODEL_V1_SERVICE", | | | | | | | |
| | | | | | "snow.service.type": "Service" | | | | | | | |
| | | | | | } | | | | | | | |
+-------------------------+-----------------+-------------------------+-------+----------+---------------------------------------------------------------+-----------------------------+------------------+-------------+--------+------------------------------------------------------------------------------+-------+-----------+
Gateway を作成してトラフィックを分割する
ベースラインと challenger(v2 のサービス)にトラフィックを振り分けるゲートウェイを作成します。ゲートウェイ自体もスキーマ配下に作成されるオブジェクトです。
CREATE OR REPLACE GATEWAY my_gateway
FROM SPECIFICATION $$
spec:
type: traffic_split
split_type: custom
targets:
- type: endpoint
value: YASUHARA_TEST_DB.PUBLIC.MY_MODEL_V1_SERVICE!inference
weight: 70
- type: endpoint
value: YASUHARA_TEST_DB.PUBLIC.MY_MODEL_V2_SERVICE!inference
weight: 30
$$;
ポイントは以下の通りです。
- type:
traffic_splitを指定します。カスタムの重み付けでトラフィックを分割したい場合は、split_type: customも合わせて指定します - targets.value:
!inferenceの部分は、サービス仕様で定義された実際のエンドポイント名を指定する必要がありました。これはSHOW ENDPOINTS IN SERVICE <サービス名>;のname列で確認できます - weight:各ターゲットへの振り分け比率を整数で指定します。合計値が100になるよう指定します。今回は
70・30としているため、ベースライン(v1)に7割、challenger(v2)に3割のトラフィックが振り分けられる想定です
作成後、ゲートウェイの URL は以下のingress_url列で確認できます。
DESC GATEWAY my_gateway;
Snowsight 上からも確認できます。

ゲートウェイの詳細:

モデルモニターを作成する
Auto Capture を有効化した推論サービスに対して、モデルモニターを作成します。これにより、各モデルのドリフトや性能指標を継続的に集計し、A/B テストの結果を定量的に比較できるようになります。
今回は Snowsight UI からモデルモニターを作成しました。対象のゲートウェイの「Monitoring」タブから作成可能です。
作成時は、作成先のデータベース・スキーマ、対象のモデルを指定します。

オプションで Ground Truth に関する設定を追加できます。正解データが入った Ground Truth テーブルと、そのテーブル上で推論リクエストとの突合に使う ID カラム、正解値が入ったスコアカラムをそれぞれ指定します。

実行された SQL は以下のようになっていました。
CREATE MODEL MONITOR YASUHARA_TEST_DB.PUBLIC."yasuhara_monitor"
WITH GATEWAY = YASUHARA_TEST_DB.PUBLIC.MY_GATEWAY
MODEL = YASUHARA_TEST_DB.PUBLIC.MY_MODEL
FUNCTION = 'PREDICT'
WAREHOUSE = X_SMALL_WH
REFRESH_INTERVAL = '1 minutes'
AGGREGATION_WINDOW = '1 hours'
GROUND_TRUTH = YASUHARA_TEST_DB.PUBLIC.MY_MODEL_GROUND_TRUTH
ID_COLUMNS = ('REQUEST_ID')
ACTUAL_SCORE_COLUMNS = ('ACTUAL_VALUE');
ここで指定している GROUND_TRUTH = YASUHARA_TEST_DB.PUBLIC.MY_MODEL_GROUND_TRUTH は、あらかじめ用意しておく必要がある Ground Truth(正解データ)テーブルです。作成時のポイントは後述しますが、ここで使用した SQL を先に記載しておきます。
CREATE OR REPLACE TABLE yasuhara_test_db.public.my_model_ground_truth (
request_id STRING,
actual_value FLOAT
);
INSERT INTO yasuhara_test_db.public.my_model_ground_truth (request_id, actual_value)
VALUES
('1', 5.0),
('2', 7.0),
('5', 13.0),
('10', 23.0);
Gateway 経由でリクエストを送信し、トラフィック分割を確認する
ここから、実際にゲートウェイ経由で複数回リクエストを送り、指定した割合(70:30)で振り分けられているかを確認します。
リクエストの形式自体は、各サービスへ直接送っていたときと同じdataframe_split形式です。異なるのは送信先で、個々のサービスではなくゲートウェイの URL(DESC GATEWAYで確認したingress_urlの/predict)を指定します。ゲートウェイがリクエストを受け取り、内部で v1・v2 いずれかのサービスへ振り分けます。以下ではxに1, 2, 5, 10を3周分(計12回)送信しています。
GATEWAY_URL="https://xxxxx-xxxxx-xxxxx.snowflakecomputing.app/predict"
PAT="<token>"
for x in 1 2 5 10 1 2 5 10 1 2 5 10; do
resp=$(curl -s -X POST "$GATEWAY_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Snowflake Token=\"$PAT\"" \
-d "{\"dataframe_split\": {\"index\": [0], \"columns\": [\"input_feature_0\"], \"data\": [[$x]]}}")
echo "x=$x -> $resp"
done
出力は以下の通りでした。
x=1 -> {"data":[[0,{"output_feature_0":5}]]}
x=2 -> {"data":[[0,{"output_feature_0":10.4}]]}
x=5 -> {"data":[[0,{"output_feature_0":13}]]}
x=10 -> {"data":[[0,{"output_feature_0":23}]]}
x=1 -> {"data":[[0,{"output_feature_0":9.2}]]}
x=2 -> {"data":[[0,{"output_feature_0":7}]]}
x=5 -> {"data":[[0,{"output_feature_0":13}]]}
x=10 -> {"data":[[0,{"output_feature_0":23}]]}
x=1 -> {"data":[[0,{"output_feature_0":9.2}]]}
x=2 -> {"data":[[0,{"output_feature_0":7}]]}
x=5 -> {"data":[[0,{"output_feature_0":13}]]}
x=10 -> {"data":[[0,{"output_feature_0":23}]]}
v1(y=2x+3)と v2(y=1.2x+8)は同じ x に対して異なる値を返すため、応答値からどちらのモデルが処理したかを判定できます。
| x | v1 の想定出力 | v2 の想定出力 |
|---|---|---|
| 1.0 | 5 | 9.2 |
| 2.0 | 7 | 10.4 |
| 5.0 | 13 | 14 |
| 10.0 | 23 | 20 |
12回のリクエストのうち、v1 が9回・v2 が3回という結果になりました。サンプル数が少ないためブレはありますが、意図した70:30に近い比率で振り分けられていることが確認できました。
Snowsight 上でも、対象のゲートウェイの「Metrics」タブから同様の結果を確認できます。

再度リクエストを送ると、値が更新されていることが確認できました。

モニタリング結果の確認
Monitoring タブでは、各モデルについて Auto Capture で記録された推論リクエスト・レスポンスの分布等を確認できます。

本来はここで RMSE などの指標も確認できるはずですが、今回は Ground Truth との突合を必要とする性能指標がうまく取得できませんでした。
指標はMODEL_MONITOR_DRIFT_METRIC / MODEL_MONITOR_PERFORMANCE_METRICを使ってクエリから取得することも可能です。
Ground Truth との突合
検証時はうまくいきませんでしたが、性能指標(RMSE 等)を計算するには Ground Truth(正解データ)との ID による突合が行われます。
ここでは、以下のようなテーブルを正解として用意しました。
CREATE OR REPLACE TABLE yasuhara_test_db.public.my_model_ground_truth (
request_id STRING,
actual_value FLOAT
);
INSERT INTO yasuhara_test_db.public.my_model_ground_truth (request_id, actual_value)
VALUES
('1', 5.0),
('2', 7.0),
('5', 13.0),
('10', 23.0);
各列は以下の通りです。
request_id:推論リクエストとの突合に使う ID 列。STRING 型で用意する必要があるactual_value:モデルの予測値と比較される正解値
推論リクエスト時にextra_columnsとしてrequest_idを送ることで、その値が Ground Truth テーブルのrequest_id列と突合され、一致した行のactual_valueが正解値として使われます。
{
"dataframe_split": {
"index": [0],
"columns": ["input_feature_0", "request_id"],
"data": [[5.0, "5"]]
},
"extra_columns": ["request_id"]
}
上記がリクエストの形式ですが、extra_columnsをdataframe_split(またはdataframe_records)と並列のトップレベルフィールドとして指定します。
先のSELECT * FROM TABLE(INFERENCE_TABLE('MY_MODEL'));でRECORD_ATTRIBUTES列を確認すると、"snow.model_serving.request.extra_columns.request_id": "5" として正しく記録されていましたが、今回は指標の自動算出まではできませんでした。
さいごに
Gateway Monitoring と A/B Testing を試してみました。トラフィック分割による A/B テストの仕組みとして、同じ入力に対して異なるモデルの応答が返ってくることが確認できました。振り分けが行われるゲートウェイの構築も CREATE 文のみで設定まで完了する点は容易と思います。
性能指標(RMSE 等)の算出に必要な Ground Truth との突合は、最終的な指標反映までは確認できませんでしたので、また試してみたいと思います。
同じような検証をされる方の参考になれば幸いです。
参考






