/* terashim.com */

システム開発・データエンジニアリング・データ分析についての個人的なノート

ShinyをCloud Runで動かす

Shinyとは

Shiny は Rでインタラクティブなウェブアプリケーションを簡単に作成できるパッケージです。

作成したShinyアプリを公開する方法としては shinyapps.io を利用したり、 汎用サーバーに Shiny Server をインストールしたりするのが一般的です。

本記事では別の選択肢として、コンテナ化したShinyアプリを Cloud Run で実行する方法について説明します。

Cloud Runとは

Cloud Run は Google Cloud Platform のサービスの1つで、コンテナ化されたアプリケーションをウェブサービスとしてデプロイできるマネージド環境です。

Cloud Run を利用するとコンテナを動作させるためのサーバーの管理が不要になります。 アプリケーションをデプロイするとURLが自動で作成され、サービスが公開されます。 サービスの負荷に応じて起動するコンテナ数を自動調節するオートスケール機能もあります。 また ゼロスケール (Scale to zero) が可能という特徴があり、アクセスがないときにはコンテナを完全に停止して料金を抑えることができます。

背景

以前の Cloud Run は WebSocket をサポートしていませんでした。そのため、Shiny Server の設定で WebSocket を無効にしたり、インスタンス数を1個に限定したりと、かなり無理をしないと Shiny アプリを動かすことはできませんでした。

参考:

その後、2021年1月に Cloud Run で WebSocket がサポートされ、通常の設定で Shiny が動かせるようになりました。

同年6月には リクエストのタイムアウト時間 が最大1時間まで設定できるようになりました。

さらに2023年4月には セッションアフィニティ が正式リリースされました。 これは複数のインスタンスが起動しているときに同一ユーザーからのリクエストを単一のインスタンスにルーティングできる機能です。 これによって、インスタンス数を増やしてサービスをスケールさせやすくなりました(ただしセッションアフィニティはベストエフォートでしかないので、厳密に状態管理するためには外部のDB等にデータを永続化する必要があります)。

参考:

こうしたアップデートのおかげで、現在では Shiny アプリを容易に Cloud Run で動かすことができるようになっています。

デモ

デモ用のShinyアプリケーションを公開しました。

https://shiny-on-cloud-run.terashim.com

のURLでアクセスできます。

ソースコードは GitHub リポジトリ terashim/shiny-on-cloud-run で公開しています。

システム構成

  • Artifact Registryにリポジトリを作成し、そこにShinyアプリのイメージをプッシュしています。
  • プッシュされたイメージを使ってCloud Runサービスをデプロイしました。
  • リクエストタイムアウト時間を60分に設定し、セッションアフィニティを有効にしました。
  • Cloud Run のドメイン マッピング(プレビュー版)を使い、独自ドメイン shiny-on-cloud-run.terashim.com でアクセスできるよう設定しました。
図: システム構成

図: システム構成

以下このシステムの構築方法を詳しく見ていきます。

Shinyアプリケーションのコンテナ化

ソースコード

コンテナ化されたShinyアプリを作成するには、以下のようなソースコードを用意します。

Dockerfile:

FROM rocker/r-ver:4.3

WORKDIR /app

RUN R -e 'install.packages("shiny")'

COPY app.R entrypoint.R ./

EXPOSE 8080
CMD ["Rscript", "entrypoint.R"]

このファイルではShinyアプリのコンテナイメージをビルドする手順を記述しています。 shiny パッケージをインストールし、ソースコード app.Rentrypoint.R をコンテナ内にコピーします。 コンテナ起動時に8080ポートを公開して Rscript entrypoint.R が実行されるように設定しています。


entrypoint.R:

library(shiny)
runApp("app.R", host = "0.0.0.0", port = 8080)

これはコンテナ起動時に呼び出されるRスクリプトファイルです。 ポート8080でShinyアプリを起動します。


app.R:

library(shiny)

n <- 200

ui <- fluidPage(
  titlePanel("Shiny on Cloud Run"),
  numericInput('n', 'Number of obs', n),
  plotOutput('plot')
)

server <- function(input, output) {
  output$plot <- renderPlot({
    hist(runif(input$n))
  })
}

shinyApp(ui = ui, server = server)

これはShinyアプリ本体を定義しているコードです。 今回はShiny公式サイトのギャラリーから Single-file shiny app の内容を拝借しました。

ビルド

上のソースコードを適当なフォルダに置き、次のコマンドを実行すればShinyアプリのコンテナイメージをビルドできます:

docker build -t my-shiny-app:latest .

次のコマンドを実行するとローカル環境でコンテナを起動できます:

docker run --rm -p 8080:8080 my-shiny-app:latest

これを起動してブラウザで http://localhost:8080 を開くと、Shinyアプリの画面が表示されます。

コンテナ化されたアプリケーションのデプロイ

Shinyアプリがコンテナ化できたら、Cloud Runへのデプロイを進めます。

準備

まずは Google Cloud Platformでプロジェクトを作成 し、 Artifact Registryでリポジトリを作成 します。

またイメージを Artifact Registry へ push できるようにするため、 gcloud 認証ヘルパー で認証の設定をしておく必要があります。

イメージのプッシュ

準備ができたら

docker build -t リポジトリURL/my-shiny-app:latest .
docker push リポジトリURL/my-shiny-app:latest

を実行してコンテナイメージをビルドし、リポジトリへ push します。

ここで リポジトリURL には Artifact Registry で作成したリポジトリのURLを指定します。これは例えば asia-northeast1-docker.pkg.dev/shiny-on-cloud-run/demo のような形の文字列になります。

Cloud Run へのデプロイ

イメージが push できたら、 Cloud Run へのデプロイ を行ってサービスを作成します。このとき設定で

  • 未認証の呼び出しを許可する
  • リクエストタイムアウトを3600秒にする
  • セッションアフィニティを有効にする

とするのがポイントです。

サービスを作成すると run.app のサブドメインでURLが自動で発行されます。ブラウザでサービスURLを開くとデプロイされたShinyアプリにアクセスすることができます。

お好みで Cloud Run のドメインマッピング を利用してサービスに独自ドメインを割り当てることもできます(ただしこれはまだプレビュー版の機能です)。 実際、shiny-on-cloud-run.terashim.com はこれを利用してドメインを設定しています。

動作確認

ブラウザでサービスURLにアクセスすると、下のような画面が表示されます。 開発者ツールを使えば WebSocket 通信が行われている様子も見ることができます。

Shinyアプリ画面とWebSocket通信の様子

Shinyアプリ画面とWebSocket通信の様子

1時間経つとリクエストタイムアウトによりWebSocketの接続が切断され、画面がグレーアウトします。

発展

Cloud RunへのShinyアプリのデプロイは以上で完了です。 以下ではこの構成での開発に役立つテクニックをいくつか紹介します。

Cloud Run のデバッグ情報

Cloud Run にサービスをデプロイするたびに固有の リビジョン が作成されます。 コンテナ内からは環境変数 K_RIVISION でリビジョン情報にアクセスできます。Rでは

# サービスのリビジョンを取得する
Sys.getenv("K_REVISION")

のように書きます。

Cloud Run で起動したコンテナインスタンスには固有の インスタンスID が割り当てられます。 コンテナの中から メタデータサーバー へHTTPリクエストを送ることで自身のインスタンスIDを取得できます。 例えば httr2 パッケージを使って次のように書けます:

# コンテナのインスタンスIDを取得する
library(httr2)

instanceid <- NULL
try({
  # メタデータサーバーにHTTPリクエストを送る
  req <-
    request("http://metadata.google.internal/computeMetadata/v1/instance/id") |>
    req_headers(`Metadata-Flavor` = "Google")
  resp <- req |> req_perform()
  instanceid <- rawToChar(resp_body_raw(resp))
})

リビジョンやインスタンスIDの情報をShinyアプリの画面に表示しておくと、デプロイやWebSocket接続のトラブルシューティングに役立つことがあります。

参考:

WebSocketの再接続

サーバー関数で sessionオブジェクト を引数で受け取り session$allowReconnect("force") を呼び出すと、WebSocketが切断した時に自動で再接続されるようになります。

server <- function(input, output, session) {
  # WebSocker接続が切断したら自動的に再接続する
  session$allowReconnect("force")

  # ...
}

renvパッケージキャッシュによるビルドの効率化

依存関係を変更してイメージをビルドするたびに install.packages() を実行していると、繰り返し同じパッケージのダウンロードが発生してしまいます。

renvのパッケージキャッシュDockerのキャッシュマウント (--mount=type=cache) を活用すれば同じパッケージのダウンロードが繰り返されるのを避け、ビルドの効率化を図ることができます。 また同時にロックファイル renv.lock によってパッケージのバージョンを固定し、ビルドを安定させる効果も得られます。

この場合 Dockerfile は次のような内容になります:

FROM rocker/r-ver:4.3

WORKDIR /app

RUN R -e 'install.packages("renv", repos="https://packagemanager.posit.co/cran/__linux__/jammy/2024-02-09")'

COPY renv.lock renv.lock
RUN \
  --mount=type=cache,target=/root/.cache/R/renv \
  R -e 'renv::restore()'

COPY app.R entrypoint.R ./

EXPOSE 8080
CMD ["Rscript", "entrypoint.R"]

このときロックファイル renv.lock も作っておく必要があります。 このファイルは renv::snapshot() で生成します。

まとめ

  • Shiny アプリをコンテナ化して Cloud Run で動かす方法を紹介しました。
  • Cloud Run で WebSocket がサポートされて以降、特別な設定なしで普通にデプロイできるようになりました。