Snowpark Container Services上でWebアプリ(FastAPI/React/TypeScript)を動かしてみた

シンプルな Multi-Container App を動かしている以下の記事にインスパイアされてみました。
以下の記事では、Docker networkを前提にフロントがサーバの名前解決を行っています。
これをデプロイすると、ブラウザで動くフロントコードがサーバの名前を解決できません(SPCS無関係)。
リバースプロキシを挟んでプライベートなダウンストリームにAPIを配置する方法が良さそうです。
今回の記事はSPCSの動作確認をすることが目的なので凝ったことはせず、
ViteをそのままデプロイしてProxyで解決してみたのでご紹介します。

SPCSのアーキテクチャ

Image Registryはイメージリポジトリです。AWSのECR、AzureのACRと似た感じで操作できます。
Container Serviceはデプロイメントの単位で、1個以上のコンテナをデプロイできます。
Compute Poolは計算資源で、複数のContainer Serviceが共有します。

Serviceは基本的にはPrivateなリソースとなりますが、SpecでEndpointsを定義することで、
Publicリソースとすることができます。自動的に80にmapされます。

Serviceには以下のフォーマットでDNS名が付きます。
Service同士は以下のDNS使ってプライベート通信できます。
同一DB、Schema内に作成したServiceは先頭のServiceNameが異なるだけとなりますが、
その場合に限り、ServiceNameだけで互いの名前解決ができます。
Service間連携については、公式で”Service-to-Service”というパターンで紹介されています。

<Service Name>-<Schema Name>-<DB Name>.snowflakecomputing.internal

1つのService内に複数のコンテナを配置することができます。
同一Service内で各コンテナ内の通信したいと考えたのですが、方法を見つけることができませんでした。
(出来ないはずはなさそうで方法はあるのかもしれません..)

今回作るもの

フロント側、サーバ側の2つのコンテナを、それぞれ別々のServiceとしてデプロイします。
フロント側をPublic、サーバ側はPrivateとします。概要は以下の通りです。

  • フロント側
    • Vite
    • React/TypeScript
    • ReactからのAPIリクエストは一旦ViteのProxyで受けて、ProxyからAPIに流す
    • サーバ側にGETリクエストして応答を表示するだけ
  • サーバ側
    • uvicorn
    • FastAPI
    • poetry
    • GETリクエストを受けて”Hello World”をJSONで返すだけ

思いっきり開発用な感じですが、SPCSの動作確認が目的ですのでこれでいきます。
ローカルで動作確認をして、SPCSにデプロイします。ファイル構成は以下の通りです。


$ tree . -I node_modules -I __pycache__
.
├── api
│   ├── Dockerfile
│   ├── api.py
│   ├── app.py
│   ├── main.py
│   └── pyproject.toml
├── compose.yml
└── front
    ├── Dockerfile
    ├── entry.sh
    ├── index.html
    ├── package-lock.json
    ├── package.json
    ├── src
    │   └── hello.tsx
    ├── tsconfig.json
    ├── tsconfig.node.json
    └── vite.config.ts

compose.yml

サーバ側、フロント側の2つのコンテナが、サーバ側->フロント側の順に起動するように書きます。
それぞれ、8080、5173 で待つようにします。


services:

  api:
    build: api
    volumes:
      - ./api:/app
    ports:
      - 8080:8080

  front:
    build: front
    volumes:
      - ./front:/app
    ports:
      - 5173:5173
    depends_on:
      - api

サーバ側

Snowflakeの公式のチュートリアルだとFlaskが使われています。
今回は、最近使い始めたFastAPIを使ってAPIサーバを立ててみようと思います。
FlaskとFastAPIの比較はこちらが詳しいです。
FastAPIの特徴はPydanticによるデータ検証とAsyncI/O。TypeScriptのように型チェックできます。
パッケージマネージャにはpoetryを使います。デファクトが無いPythonのパッケージ管理界隈で
npmやcomposer的な使い勝手が提供されます。

FastAPIの公式では、Python用Webサーバのuvicornを使ってホストされています。
uvicornでFastAPIを動かすコンテナを1個立てていきます。

Dockerfile

最新のPythonのイメージにpoetryをインストールします。
公式がインストーラを配布していて公式の手順通りに叩けばインストールできます。
公式のガイドの通り、POETRY_HOME=/etc/poetry とします。
Installation with the official installer


FROM python:3.12

# 公式の通り /etc/poetry にインストールする
ENV POETRY_HOME=/etc/poetry
RUN curl -sSL https://install.python-poetry.org | python -
ENV PATH $POETRY_HOME/bin:$PATH

WORKDIR /app
COPY . .

RUN poetry install
CMD ["python","main.py"]

pyproject.toml

バニラのPythonとpyproject.tomlだけで依存関係を考慮したパッケージ管理が出来ますが、
要はnpmやbundle,composer的な使い勝手に寄せたパッケージ管理に対する需要があります。
poetry用の依存関係を書いていきます。fastapi、uvicornの現在(2/16)の最新を指定します。


[tool.poetry]
name = "test"

[tool.poetry.dependencies]
python = "^3.12"
fastapi = "^0.109.2"
uvicorn = "^0.27.1"

api.py

[GET] /hello に対して Hello World 的な JSON を返す FastAPI の Hello Worldです。
後のapp.pyで FastAPIインスタンスに紐付けます。app.pyと分離することでロジックを分離できます。


from fastapi import APIRouter
router = APIRouter()
@router.get("/hello")
async def hoge():
    return {"result":"Hello World"}

app.py

FastAPIのHello Worldコードです。Dockerfileから開始します。


from api import router as api_router
from fastapi import FastAPI

app = FastAPI()
app.include_router(api_router, prefix="/api")

main.py

pythonコードからuvicornを起動します。uvicorn.runの公式の仕様はこちら
第1引数の”app:app”は”app”モジュールの中の”app”オブジェクトという表記です。
app.pyに記述したFastAPI()のインスタンスappを指します。stringで渡す必要があります。
hostは”0.0.0.0″を指定します。なぜ”127.0.0.1″でないのかはこちらが参考になります。
今回はPort=8080で起動します。reload=Trueとすると、HotReload機能が有効になります。便利。


import uvicorn

if __name__ == "__main__":
    uvicorn.run(
        "app:app",
        host="0.0.0.0",
        port=8080,
        reload=True,
    )

起動してみる

docker compose up して http://localhost:8080/docs を開くと以下が表示されます。
ちゃんとJSONでHello Worldが戻りました。

フロント側

VueとReactの開発用に使われるローカル開発用のサーバ Vite をホストするコンテナを立てます。
Viteは Vue.jsの開発者Evan You氏が開発したJavaScript/TypeScriptで、ヴィートと読み、
フランス語で”素早い”という意味だそう。(webpackのように)リソースバンドルが不要で起動が速い。
(Laravelも9.xでwebpackを捨ててViteになってた..)
素の状態でTypeScriptを扱えるため、すぐにTypeScriptを書き始められる特徴があります。

Dockerfile

nodeのrelease scheduleはこちら
2/17のnodeのActiveLTSのMajor Versionは20で、2026-04-30がEnd of lifeとなっています。
これを使いたいので node:20 を指定します。


FROM node:20

WORKDIR /app
COPY . .

RUN npm install
ENTRYPOINT [ "./entry.sh" ]

entry.sh

Dockerfile の ENTRYPOINT で npm run dev するだけのshです。


#!/bin/bash
npm run dev

package.json

npm create vite@latest で Prjディレクトリ内に様々なファイルが作られます。
package.jsonも作られます。 Hello World で必要なもの以外を削ってみました。
npm install後、npm run devで viteを実行します。

TS用のconfigは別です。
他に生成されるpackage-lock.jsonが必要ですが省略します。


{
    "name": "front",
    "private": true,
    "version": "0.0.0",
    "scripts": {
        "dev": "vite"
    },
    "dependencies": {
        "react": "^18.2.0",
        "react-dom": "^18.2.0"
    },
    "devDependencies": {
        "@vitejs/plugin-react": "^4.2.1"
    }
}

tsconfig.json

viteのPrj生成で自動生成されるTS用のconfigファイルです。
手をつけずに配置します。


{
    "compilerOptions": {
      "target": "ES2021",
      "useDefineForClassFields": true,
      "lib": ["ES2021", "DOM", "DOM.Iterable"],
      "module": "ESNext",
      "skipLibCheck": true,

      /* Bundler mode */
      "moduleResolution": "bundler",
      "allowImportingTsExtensions": true,
      "resolveJsonModule": true,
      "isolatedModules": true,
      "noEmit": true,
      "jsx": "react-jsx",

      /* Linting */
      "strict": true,
      "noUnusedLocals": true,
      "noUnusedParameters": true,
      "noFallthroughCasesInSwitch": true
    },
    "include": ["src"],
    "references": [{ "path": "./tsconfig.node.json" }]
}

tsconfig.node.json

viteのPrj生成で自動生成されるTS用のconfigファイルです。
手をつけずに配置します。


{
    "compilerOptions": {
      "composite": true,
      "skipLibCheck": true,
      "module": "ESNext",
      "moduleResolution": "bundler",
      "allowSyntheticDefaultImports": true
    },
    "include": ["vite.config.ts"]
}

vite.config.ts

viteのPrj生成で自動生成されるvite用のconfigファイルです。
設定ファイルが.tsなところが凄いです。普通にimport文を書けます。
上のuvicornの起動で127.0.0.1ではなく0.0.0.0を指定したのと同様に、
viteも127.0.0.1ではなく0.0.0.0で待たせる必要があります。
serverオプションのhostにtrueを設定すると、0.0.0.0となります。公式

FastAPIの同一パスと対応するProxyを設定します。
以下で、server.proxy.api.target は SPCS上のAPIコンテナのPrivateエンドポイント を表します。
DNS名はサービス単位で作られます。本来長いFQDNを指定する必要がありますが、
同一スキーマに作られたサービスに限り、サービス名だけで解決できるようです。
DNS名はアンダースコア(_)がハイフン(-)に置き換わります。6時間くらいハマりました..
後で ikuty_api_service サービスを作りますが、ikuty-api-serviceを 使います。

詳細は以下を参照してください。
Service-to-service communications


import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    host: true,
    proxy: {
      "/api": {
        target: `http://ikuty-api-service:8080/`,
        changeOrigin: true
      }
    }
  },
})

index.html

Reactのコンポーネントを表示するガワとなるhtmlです。


<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script type="module" src="/src/hello.tsx" defer></script>
  </head>
  <body>
    <div id="root">Waiting...</div>
  </body>
</html>

src/hello.tsx

ようやくHello WorldするReactコンポーネントの本体です。
画面にはvalueというStateを表示しています。APIのURLは上記の通りproxyとします。
今回作成した/api/hello APIの応答を受けた後、setValueによりStateを更新します。


import React from 'react'
import { useState } from 'react'
import ReactDOM from 'react-dom/client'

const App = () =>  {
    const [value, setValue] = useState('')
    const url = '/api/hello'
    fetch(url,{})
        .then(res=>res.json())
        .then(data=>setValue(data['result']))
    return (
        
{value},{}
) } ReactDOM.createRoot(document.getElementById('root')!).render( )
起動してみる

docker compose up すると、ほとんど一瞬でviteが起動します。
http://localhost:5173 を開きます。
Waiting…という表示が一瞬で Hello World に書き変わります。

ロールの作成

SPCSの各リソースの作成に必要な権限はこちらにあります。
ゴリ押ししただけなので間違っている可能性大です..
行ったり来たりしたので足りないものがあるかもしれません。


use role ACCOUNTADMIN;

CREATE ROLE IKUTY_CONTAINER_USER_ROLE;
GRANT ROLE IKUTY_CONTAINER_USER_ROLE TO ROLE ACCOUNTADMIN;
GRANT USAGE ON DATABASE IKUTY_DB TO ROLE IKUTY_CONTAINER_USER_ROLE;
GRANT USAGE ON SCHEMA IKUTY_DB.PUBLIC TO ROLE IKUTY_CONTAINER_USER_ROLE;
GRANT CREATE IMAGE REPOSITORY ON SCHEMA T_IKUTA_DB.PUBLIC TO ROLE IKUTY_CONTAINER_USER_ROLE;

-- CREATE SERVICEに必要な権限
-- https://docs.snowflake.com/en/sql-reference/sql/create-service#access-control-requirements
GRANT USAGE ON DATABASE IKUTY_DB TO ROLE IKUTY_CONTAINER_USER_ROLE;
GRANT USAGE ON SCHEMA IKUTY_DB.PUBLIC TO ROLE IKUTY_CONTAINER_USER_ROLE;
GRANT CREATE COMPUTE POOL ON ACCOUNT TO ROLE IKUTY_CONTAINER_USER_ROLE;
GRANT CREATE IMAGE REPOSITORY ON SCHEMA IKUTY_DB.PUBLIC TO ROLE IKUTY_CONTAINER_USER_ROLE;
GRANT CREATE SERVICE ON SCHEMA IKUTY_DB.PUBLIC TO ROLE IKUTY_CONTAINER_USER_ROLE;
GRANT USAGE ON COMPUTE POOL IKUTY_SCS_POOL TO ROLE IKUTY_CONTAINER_USER_ROLE;
GRANT BIND SERVICE ENDPOINT ON ACCOUNT TO ROLE IKUTY_CONTAINER_USER_ROLE;
GRANT IMPORTED PRIVILEGES ON DATABASE snowflake TO ROLE IKUTY_CONTAINER_USER_ROLE;
-- GRANT READ ON STAGE IKUTY_SCS_STAGE TO ROLE IKUTY_CONTAINER_USER_ROLE;
GRANT READ ON IMAGE REPOSITORY IKUTY_SCS_REPOSITORY TO ROLE IKUTY_CONTAINER_USER_ROLE;
GRANT BIND SERVICE ENDPOINT ON ACCOUNT TO ROLE IKUTY_CONTAINER_USER_ROLE;


Image Repositoryの作成

SPCSで使用するイメージを配置するリポジトリを作成します。
AWSのECR、AzureのACR的に、dockerコマンドから透過的にpushできるようです。
公式は以下。
CREATE IMAGE REPOSITORY


USE ROLE IKUTY_CONTAINER_USER_ROLE;
CREATE OR REPLACE IMAGE REPOSITORY IKUTY_SCS_REPOSITORY;
SHOW IMAGE REPOSITORIES;

SHOW IMAGEを叩くと repository_url が返ってます。

Image Repositoryにプッシュする

作成したImage Repositoryにローカルで作成したイメージをpushしていきます。
pushは指定されたタグを送信するという仕様のため、docker tagコマンドでイメージにタグを付けます。
docker tagの仕様はこちら

ローカルで以下を行います。(サニタイズのため分かりづらいですが補完してください..)


# タグをつける
# docker tag  

$ docker tag app_front:latest /app_front:scs
$ docker tag app_api:latest /app_api:scs

# Snowflake Image Repositoryにログインする
$ docker login  -u 
Login Succeeded

# イメージをpushする
$ docker push /app_front:scs
...
$ docker push /app_api:scs
...


Compute Poolを作成する

Compute Poolを作成します。
CREATE COMPUTE POOL


CREATE COMPUTE POOL ikuty_scs_pool
  MIN_NODES = 1
  MAX_NODES = 1
  INSTANCE_FAMILY = CPU_X64_XS
  AUTO_RESUME = TRUE
  INITIALLY_SUSPENDED = FALSE
  AUTO_SUSPEND_SECS = 3600
;

以下でCREATEしたCompute poolをDESCRIBEできます。
DESCRIBE COMPUTE POOL

自分の環境だと、CREATE COMPUTE POOLしてから15分ほどステータスがSTARTINGでした。
15分ぐらいして叩くとステータスがACTIVEに変わりました。(結構かかるイメージ)
以下、公式の実行例です。


DESCRIBE ikuty_scs_pool
+-----------------------+--------+-----------+-----------+-----------------+--------------+----------+-------------------+-------------+--------------+------------+-------------------------------+-------------------------------+-------------------------------+--------------+---------+
| name                  | state  | min_nodes | max_nodes | instance_family | num_services | num_jobs | auto_suspend_secs | auto_resume | active_nodes | idle_nodes | created_on                    | resumed_on                    | updated_on                    | owner        | comment |
|-----------------------+--------+-----------+-----------+-----------------+--------------+----------+-------------------+-------------+--------------+------------+-------------------------------+-------------------------------+-------------------------------+--------------+---------|
| IKUTY_SCS_POOL | ACTIVE |         1 |         1 | CPU_X64_XS      |            1 |        0 |                 0 | false       |            1 |          0 | 2023-05-01 11:42:20.323 -0700 | 2023-05-01 11:42:20.326 -0700 | 2023-08-27 17:35:52.761 -0700 | ACCOUNTADMIN | NULL    |
+-----------------------+--------+-----------+-----------+-----------------+--------------+----------+-------------------+-------------+--------------+------------+-------------------------------+-------------------------------+-------------------------------+--------------+---------+

Serviceの作成

フロント側、サーバ側の2つのServiceを作成していきます。
specについて、ステージにファイルを配置してそれを指定するスタイルのほかに、
以下のようにCREATE SERVICEに含めるスタイルがあるようです。
CREATE SERVICE

フロント側のServiceは以下です。


CREATE SERVICE ikuty_api_service
  IN COMPUTE POOL ikuty_scs_pool
  FROM SPECIFICATION $$
    spec:
      containers:
      - name: api-container
        image: 
      endpoints:
      - name: api
        port: 8080
  $$
;

サーバ側のServiceは以下です。


CREATE SERVICE ikuty_front_service
  IN COMPUTE POOL ikuty_scs_pool
  FROM SPECIFICATION $$
    spec:
      containers:
      - name: front-container
        image: 
      endpoints:
      - name: front
        port: 5173
        public: true
  $$
;

SERVICE用のシステム関数

SaaSで動くコンテナの動作を確認するのは結構面倒なことなのかなと思います。
自分の操作に対してSaaS側で何が行われているのか知りたいことは結構あるのかなと思います。
SPCSには以下のコマンドがあるようです。

エンドポイントURLの取得

SHOW ENDPOINTS すると、Specで指定したpublicなendpointを得られました。
ingress_url に なんとかかんとか.snowflakecomputing.app というURLが入っています。
SHOW ENDPOINTS

動作確認

Computing poolのStatusがACTIVEになってから、エンドポイントURLをブラウザで開くと、
期待通り、Reactで作ったHello Worldアプリが表示されます。

SYSEM$GET_SERVICE_LOGS()でフロントサービスのログを覗くと、viteの起動ログが出ていました。
そうえいば 5173 を 80 に mapping する記述をどこにもしていないのですが、そうなっています。


> front@0.0.0 dev
> vite


  VITE v4.5.2  ready in 314 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: http://10.244.1.3:5173/
  ➜  Network: http://172.16.0.6:5173/

同様に、サーバ側のログを覗くと、uvicornの起動ログが出ていました。
ViteのProxyから8080で繋がるので、こちらは8080が開いています。


INFO:     Will watch for changes in these directories: ['/app']
INFO:     Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
INFO:     Started reloader process [1] using StatReload
INFO:     Started server process [8]
INFO:     Waiting for application startup.
INFO:     Application startup complete.

まとめ

SnowflakeをWebサーバのインフラにするだけの内容で正直意味がないです。
しかし、APIでSnowflakeに触ったり、Reactで格好良い可視化をしたり、夢は広がります。
FastAPI,React,TypeScriptの恩恵ゼロなので、今後ちょっと凝ったものを作ってみます。