🤼

Next.jsで作ったデータアプリケーションを Snowpark Container Services で簡単デプロイ!

2024/10/02に公開

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>&copy; 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化して配布する方法について紹介します。
お楽しみに!

Snowflake Data Heroes

Discussion