Next.jsで作ったデータアプリケーションを Snowpark Container Services で簡単デプロイ!
Snowpark Container Servicesでコンテナ化したアプリケーションをデプロイしよう
Snowflake上で利用できるコンテナ基盤 Snowpark Container Services(SPCS)を利用するとSnowflake内にコンテナ化したアプリケーションをホストすることができます。
アプリケーションはSnowlakeが提供するフルマネージドなコンテナ基盤上にデプロイでき、Snowflake内にあるデータもセキュアに取得することが可能なので簡単にデータアプリケーションが展開可能です。
今回はSPCS上でSnowflakeのデータを可視化するダッシュボードを作成してみましょう。
v0で素案作成
まずはダッシュボードに表示する内容やレイアウトを考えていきたいです。
今回は題材として私が最近ハマっている相撲のデータを可視化するダッシュボードを作成してみます。
v0を使ってダッシュボードのレイアウトや中身を考えていくと素早くアプリケーションの骨子を作ることができます。
v0と一緒に考えたダッシュボードのコードの一部です。
sumo.tsx
import { useState, useEffect } from 'react'
import Link from "next/link"
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuCheckboxItem } from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { SumoIcon } from "@/components/icons"
import { SumoMap } from '@/components/sumoMap'
export function Sumo() {
const [rikishiData, setRikishiData] = useState([])
const [filteredRikishi, setFilteredRikishi] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [filter, setFilter] = useState({ heya: 'All', shusshin: 'All' })
const [uniqueShusshin, setUniqueShusshin] = useState(['All'])
const [expandedList, setExpandedList] = useState(false)
const [activeTab, setActiveTab] = useState('list')
useEffect(() => {
fetchRikishiData()
}, [])
useEffect(() => {
filterRikishi()
}, [rikishiData, filter])
const fetchRikishiData = async () => {
try {
const response = await fetch('/api/rikishi')
if (!response.ok) {
throw new Error('Failed to fetch data')
}
const data = await response.json()
setRikishiData(data)
setLoading(false)
} catch (error) {
setError(error.message)
setLoading(false)
}
}
const filterRikishi = () => {
let filtered = rikishiData
if (filter.heya !== 'All') {
filtered = filtered.filter(rikishi => rikishi.HEYA === filter.heya)
}
if (filter.shusshin !== 'All') {
filtered = filtered.filter(rikishi => rikishi.SHUSSHIN === filter.shusshin)
}
setFilteredRikishi(filtered)
const newUniqueShusshin = ['All', ...new Set(filtered.map(rikishi => rikishi.SHUSSHIN))]
setUniqueShusshin(newUniqueShusshin)
if (!newUniqueShusshin.includes(filter.shusshin)) {
setFilter(prev => ({ ...prev, shusshin: 'All' }))
}
}
const handleFilterChange = (type, value) => {
setFilter(prev => ({ ...prev, [type]: value }))
if (type === 'heya') {
setFilter(prev => ({ ...prev, shusshin: 'All' }))
}
}
const uniqueHeya = ['All', ...new Set(rikishiData.map(rikishi => rikishi.HEYA))]
const displayedRikishi = expandedList ? filteredRikishi : filteredRikishi.slice(0, 12)
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error}</div>
return (
<div className="flex flex-col min-h-screen">
<header className="bg-primary text-primary-foreground py-4 px-6 shadow">
<div className="container mx-auto flex justify-between items-center">
<h1 className="text-4xl font-bold flex items-center">
<SumoIcon className="mr-2 w-16 h-16" />Sumo Wrestler Dashboard<SumoIcon className="ml-2 w-16 h-16" />
</h1>
</div>
</header>
<main className="flex-1 bg-muted/20 py-8">
<div className="container mx-auto">
<div className="mb-4">
<Button
onClick={() => setActiveTab('list')}
variant={activeTab === 'list' ? 'outline' : 'default'}
className="mr-2"
>
力士一覧
</Button>
<Button
onClick={() => setActiveTab('map')}
variant={activeTab === 'map' ? 'outline' : 'default'}
>
出身地マップ
</Button>
</div>
{activeTab === 'list' ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-1">
<div className="bg-background rounded-lg shadow p-6 sticky top-4">
<h2 className="text-xl font-bold mb-4">力士フィルター</h2>
<div className="space-y-4">
<div className="flex flex-col space-y-2">
<span className="font-medium">部屋別</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="w-full justify-between">
{filter.heya}
<ChevronDownIcon className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-60 overflow-y-auto">
{uniqueHeya.map(heya => (
<DropdownMenuCheckboxItem
key={heya}
checked={filter.heya === heya}
onCheckedChange={() => handleFilterChange('heya', heya)}
>
{heya}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex flex-col space-y-2">
<span className="font-medium">出身地</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="w-full justify-between">
{filter.shusshin}
<ChevronDownIcon className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-60 overflow-y-auto">
{uniqueShusshin.map(shusshin => (
<DropdownMenuCheckboxItem
key={shusshin}
checked={filter.shusshin === shusshin}
onCheckedChange={() => handleFilterChange('shusshin', shusshin)}
>
{shusshin}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
<div className="md:col-span-2 bg-background rounded-lg shadow p-6 space-y-4">
<h2 className="text-xl font-bold">力士一覧</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{displayedRikishi.map((rikishi, index) => (
<div key={`${rikishi.ID}-${rikishi.BASHO}-${index}`} className="bg-card p-4 rounded-lg shadow">
<div className="flex flex-col items-center">
<SumoIcon className="w-20 h-20 text-primary" />
<span className="text-sm font-medium mt-2">{rikishi.RIKISHI}</span>
<span className="text-xs text-muted-foreground">{rikishi.HEYA}</span>
<span className="text-xs text-muted-foreground">{rikishi.RANK}</span>
<span className="text-xs text-muted-foreground mt-1">{rikishi.SHUSSHIN}</span>
</div>
</div>
))}
</div>
{filteredRikishi.length > 12 && (
<div className="text-center mt-4">
<Button onClick={() => setExpandedList(!expandedList)}>
{expandedList ? '表示を縮小' : 'もっと表示'}
</Button>
</div>
)}
</div>
</div>
) : (
<div className="bg-background rounded-lg shadow p-6">
<h2 className="text-xl font-bold mb-4">力士出身地マップ</h2>
<SumoMap />
</div>
)}
</div>
</main>
<footer className="bg-primary text-primary-foreground py-4 px-6 shadow mt-8">
<div className="container mx-auto flex justify-between items-center">
<span>© 2024 Sumo Wrestler Dashboard</span>
<div className="flex items-center gap-4">
<Link href="#" className="hover:text-primary/80" prefetch={false}>
Privacy
</Link>
<Link href="#" className="hover:text-primary/80" prefetch={false}>
Terms
</Link>
</div>
</div>
</footer>
</div>
)
}
function ChevronDownIcon(props) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
)
}
ディレクトリ構成は概ねこのような形になります。
Snowflakeとの接続設定についてはSPCSではServiceのOwnerのRoleの権限でSnowflakeの操作が行えるOAuthトークンがコンテナ内に格納されているので手軽にSnowflakeへの接続が行えます。
参考:https://docs.snowflake.com/en/developer-guide/snowpark-container-services/additional-considerations-services-jobs#connecting-to-snowflake
.
├── public
└── src
├── app
│ └── api
│ ├── rikishi
│ └── rikishi_stats
├── components
│ └── ui
└── lib
Service specificationの準備
次に作成したNext.jsのアプリケーションをSPCS上にデプロイするための準備を行います。
まず最初にService specificationを作成します。Service specificationはサービスの実行に必要な情報を記載したYAMLファイルです。
中身について軽く解説します。
- container → name : コンテナの名前を指定します。
- container → image : デプロイするコンテナイメージを指定します。/<データベース名>/<スキーマ名>/<イメージ名>の形式で指定します。
- container → env : コンテナに渡す環境変数を指定します。
- endpoint : サービスのエンドポイントを指定します。portはコンテナがlistenするポート番号を指定します。publicは外部からアクセス可能かどうかを指定します。
frontend.yml
spec:
container:
- name: frontend
image: /sumo/chanco/images/frontend_service
env:
PORT: 3000
FRONTEND_SERVICE_PORT: 3000
REACT_APP_BACKEND_SERVICE_URL: /api
REACT_APP_CLIENT_VALIDATION: Snowflake
SNOWFLAKE_WAREHOUSE: compute_warehouse
endpoint:
- name: frontendpoint
port: 3000
public: true
SPCSにアプリケーションをデプロイ
ではいよいよSPCSにアプリケーションをデプロイしていきます。
デプロイにはSnowflake CLIを使うと便利です。
まずはイメージリポジトリを作成します。
$ snow spcs image-repository create tutorial_repository
イメージリポジトリにログインします。
$ snow spcs image-registry login
イメージリポジトリにプッシュするためのイメージをビルドします。
その後、イメージをイメージリポジトリにプッシュします。
$ docker build -t tutorial_repository/frontend_service .
$ docker push tutorial_repository/frontend_service
コンピュートプールを作成します。
$ snow spcs compute-pool create "app_pool" --min-nodes 1 --max-nodes 1 --family "CPU_X64_XS"
サービスを作成します。
既に作成しているコンピュートプールとService specificationを指定します。
$ snow spcs service create "sumo_dashboard_service" --compute-pool "app_pool" --spec-path "/path/to/frontend.yml"
ここまででアプリケーションのデプロイが完了しました。
サービスが正常にデプロイされたか確認するためには以下のコマンドを実行します。
サービスの状態確認
$ snow spcs service status "sumo_dashboard_service"
サービスのログ確認
$ snow spcs service logs "sumo_dashboard_service" --container_name "frontend" --insatnce_id 0
これでSPCS上にアプリケーションがデプロイすることができました!
見た目はこんな感じになりました。Snowflakeからデータを実際に取得して表示しています。
SPCSを利用するとSnowflake上に簡単にアプリケーションをデプロイすることができるのでデータアプリケーションを作成する際にはぜひ利用してみてください。
次の記事では作成したデータアプリケーションをNative App化して配布する方法について紹介します。
お楽しみに!
Snowlfake データクラウドのユーザ会 SnowVillage のメンバーで運営しています。 Publication参加方法はこちらをご参照ください。 zenn.dev/dataheroes/articles/db5da0959b4bdd
Discussion