🕌

GCP にインスタンスを立てて Docker Compose で動く TLS/SSL 対応の Websocket サーバーにしたい話

2024/08/22に公開

はじめに

こんにちは、hamaguchi です
今日は、趣味開発のプロジェクト上で Websocket が欲しくなったので、なるべく安く GCP にインスタンスを立てて TLS/SSL 対応もしてみたという試みの話をします
結果として、下記の図に示すような Docker Compose を利用した TLS/SSL 対応の Websocket サーバーを立てることができました

構成図

背景

趣味プロジェクトとその構成の話

なるべく安く済ませたいというモチベーションのもと、趣味でウェブアプリケーションをいくつか作ったりしています
インフラは Firebase に乗っかって構築することが多く、ちょっとしたものを作る際にほしい機能がまとまっているので結構便利です
よく使う機能は以下のような感じで、誰も使わなければほとんどお金のかからないウェブアプリが構築できる点が魅力的です

  • Firebase Hosting: Nuxt のアプリケーションを静的ファイルとしてホスティング
  • Firebase Authentication: ログイン、ユーザー管理
  • Firebase Functions: データの変更などをフックに処理を起動、バッチ処理の定期実行、API など
  • Firebase, Storage: フロントエンドからデータベース、ストレージを直接参照・操作
  • CI は GCB で行い、Hosting, Functions, Firestore, Storage などのデプロイを実施

ただし良いことばかりというわけではなく、Firebase 特有の仕組みに対して学習コストがかかる、Firebase に大きく依存しているため乗り換えようとするとつらいなどのデメリットもあります
Firebase の導入を検討する際には用途に合うのか・将来的にも使い続けれるかなど注意しましょう
特に Firestore は NoSQL のデータベースなためデータ構造自体が異なり、SQL 型のデータベースのような気持ちで使うと痛い目をみたりします

Websocket が欲しくなった

タイトルの通り趣味プロジェクトのなかで Websocket サーバーを使いたいものが出てきたわけです
異なる環境にあるブラウザ間でユーザーの状況を文字情報としてやり取りすることが目的で、少し違いますがチャットのような機能といえばとりあえずはイメージしやすいと思います
なるべくリアルタイムに情報の更新を取得する必要があり、最初はインフラ代をケチるために Firestore のリアルタイムアップデートを使っていました
初期段階から「本当は Websocket を使った方がパフォーマンスもいいんだけどちょっと高いんだよね」とは思っており、徐々にトラフィックが増加してきたことで Websocket インスタンスを立ても料金的にそれほど変わらない見込みがたったため Websocket の導入を検討しています
今回は新しく Websocket サーバーを追加し、静的ホスティングで動いている既存のウェブアプリケーションから利用します

既存の Firebase の料金は 1 ヶ月あたり 1000 〜 1500 円なため、Websocket の導入で許容できる利用料は同程度の月 1500 円以内にしたいところです
ただ、現状のデータの流量に対してすぐにボトルネックになるのであれば 1000 円でも意味はなく、ユーザー数が 100 倍になっても捌けるのであれば2000円でも許容できるかもしれないため、この辺は自己満足やユーザー数、コスト間の兼ね合いでバランスを見る必要があります

Firestore のリアルタイムアップデート と Websocket 通信の比較

更新頻度や流量がそこまで多くない場合には Firestore である程度の代用は可能ですが、双方にメリデメがあります
今回の用途の中で比較すると以下のような特徴があります

Firestore

  • データは永続
  • 高頻度のデータ更新には向かない(1 つのドキュメントへの書き込みは 1 秒あたり 1 回まで
  • 従量課金で使った分だけ請求がくるため、誰も使わなければほとんど請求はない
  • ある程度の無料枠がある
  • TLS/SSL 対応は意識しなくてよい
  • スケールは意識しなくてよい

Websocket

  • 別途保存しない限りデータは揮発
  • 高頻度の書き込みも大丈夫
  • 誰も使わなくてもインスタンスを常時稼働するため料金が発生する
  • 処理は軽いため、小さいインスタンスでもまかなえそう
  • インスタンスをオレゴンに立てれば 1 台まで無料枠があるが、日本から使うと Ping が高いため要件によっては使えない
  • TLS/SSL 対応は自分でやる、 Load Balancer の証明書機能を使うなど対応が必要
  • 横スケールする場合はインスタンス間通信の仕組みを構築するか、通信は同インスタンス内のみというサービス設計にする必要がある

構成の検討

もともとバックエンドサーバーがある場合にはそこで Websocket も動かすことも検討できますが、今回はすでに静的ホスティングで稼働しているサービスから Websocket を使えるようにすることが目標です
どのような構成するかを考えていきましょう

考えることは

  • TLS/SSL 対応
  • インフラの構成
  • インスタンス内の構成

TLS/SSL 対応

現在ほとんどのまともなWebサイト・サービスでは HTTPS でアクセスできるようになっており、一部の HTTP しか対応していないサイトではブラウザが警告が表示してしまうため最近ではまともに開くこともできなくなってきています
HTTPS でアクセスできるようにするためには証明書ファイルをサーバー側に配置・設定し、ユーザーのブラウザが証明書を検証できるようにする必要があります
具体的な対応方法としては

  • GCP の Load Balancer などのプラットフォームで提供される証明書機能に任せる
  • Kubernetes の Ingress で証明書を設定する
  • Nginx や Apache で証明書を設定し、アプリケーションにリバースプロキシする
  • アプリケーション内で証明書を設定する

などがあり、許容できる予算や手間、インフラ構成によって選択肢が変わります
証明書の発行は

  • GCP の Load Balancer などのプラットフォームで提供される証明書機能に任せる
  • Let's Encrypt で無料で証明書を発行してがんばる
  • ドメインを買ったサービスで発行する
  • 証明書発行機関で発行する

などがあり、どの方法を選ぶかによって有効期間、料金、信頼性や保障などが異なります
GCP の Load Balancer に任せたら便利じゃん!となりますが、導入するだけで月額 4000 円近い請求が見込まれるため小さい趣味プロジェクトにはちょっと現実的な選択肢とは言えません
今回は無料で使える Let's Encrypt を利用し、定期的に証明書の更新を行うフローの構築も組み込みます

インフラの構成

どのような形で Websocket を構成するか、インフラの選択肢を考えてみましょう
0 から始める場合など縛りがなければ選択肢は他にも出てきますが、今回は Firebase 上に構築された既存のアプリケーションがあるため、請求やアカウントもろもろを GCP 内にまとめたいと思っています

  • Google Compute Engine(GCE) でインスタンスを立てる <= 今回はこれ選択
  • Google Compute Engine(GCE) のインスタンスグループでインスタンスを立てる
  • Google Kubernetes Engine(GKE) でクラスタを立てる
  • その他
    • Nuxt のサーバーサイドに Websocket を組み込む
    • そもそもサーバーを立てずに Supabase など Websocket のような機能が使える BaaS を使う
    • 自宅サーバーを立てて公開する

どれを選んだとしてもそれぞれクセがあるためそれなりの学習コストはかかりそうですね
前述の通り、今回は Load Balancer は使用しないため、TLS/SSL 対応はインスタンスやクラスタ内で行う必要があります
スケーリング前提であれば GKE を使いたいところですがなんとなく重いイメージがあるのとスケールは予定していないため無難に GCE で行ってみます
また、GCE でもインスタンスグループを使うとスケールしたり Artifact Registry のイメージからインスタンスを起動するなどもできますが、最小単位が GCE のマシンタイプなため性能面も請求面もオーバースペックとなり除外しました

フロントエンドに Nuxt を使っている場合、サーバーエンジンの Nitro がサポートする Websocket を組み込むことで幸せになれそうなドキュメントも見つかりましたが、実験段階のフェーズであることや、静的ホスティングで動いている既存のアプリケーションをサーバー上で動くように引っ越す必要があることから除外しました
Websocket がクラッシュしてページの一部機能が動作しない状況は許容できますが、ページ自体が動かないという状況は避けたいためフロントエンドは引き続き静的ファイルのホスティングで進めたいです

この間 Supabase がいいらしいという話も聞きましたが、既存のアプリケーションが Firebase で稼働中なため全部乗り換えるのは現実的ではないと判断して今回は除外しました
そのうち使ってみたいですね

インスタンス内の構成

次にインスタンス内をどのように構成するかを考えてみます
今回、Load Balancer などのプラットフォームで証明書機能を使用しないため、インスタンス内で証明書の更新と適用を行う必要があります
必要な機能は以下の3点です

  • TLS/SSL 対応
  • 証明書の更新と適用
  • Websocket アプリケーション本体の実行

上記の機能を持ったインスタンスをどう実現するか考えてみます

  • 機能別にコンテナを立てる <= 今回はこれ選択
  • 全部インスタンス内で直接行う

今回は機能別にコンテナを立てる方法で進めます

  • 普段開発環境で使用していることもあり、 Docker Compose でコンテナを管理すると楽
  • Nginx コンテナに TLS/SSL 関連を担当させて Websocket のアプリケーションをスッキリできる
  • certbot/dns-google というイメージをこの前見つけて使ってみたい
  • インスタンスにインストールするパッケージが少なくて済むため汚れにくい

全部インスタンス内で直接行う方法は CI との相性があまりよくないこと、インスタンスが吹き飛んだなどのアクシデント時に再構築が面倒なこと、長期間の運用でサーバーが汚れがちになるため今回は選択しませんでした
(サーバーに SSH で入って git pull してビルドして再起動してリリースするなどの構成のシステムも昔は触った思い出もありますが最近はほとんどみなくなりましたね)

できたもの

構成図

もともとあったのが黄色い部分で、新しく追加したかった Websocket サーバーが緑の部分です
そして、その他の部分が Websocket を追加するために追加したものです

イケてるものを作ろうとして走り始めましたが、ピタゴラスイッチを錬成してしまった感が否めません

結果として

こんな感じの Nuxt Component から
Connection.client.vue
<script setup>

const runtimeConfig = useRuntimeConfig();
const websocketUrl = ref(runtimeConfig.public.websocketUrl);
if (!websocketUrl) {
  throw new Error('Missing runtime config: public.websocketUrl');
}

const ws = new WebSocket(runtimeConfig.public.websocketUrl);

const isConnected = ref(false);
const pong = ref("");

ws.onopen = () => {
  console.log('Connected to WebSocket server');
  isConnected.value = true;
};

ws.onmessage = (event) => {
  const data = event.data
  console.log('Message from server: ', data);
  if (data.includes('pong')) {
    pong.value = data;
    setTimeout(() => { pong.value = "" }, 1000);
  }
};

ws.onclose = () => {
  console.log('Disconnected from WebSocket server');
  isConnected.value = false;
};

ws.onerror = (error) => {
  console.log('WebSocket Error: ', error);
};

function ping() {
  ws.send('ping');
  console.log('Sent: ping');
}
</script>

<template>
  <div>
    <p>websocketUrl: {{ websocketUrl }}</p>
    <p>Status: {{ isConnected ? "connected" : "disconnected" }}</p>
  <div>
    <button @click="ping()"> PING </button>
    <span> {{ pong }} </span>
  </div>
  </div>
</template>
こんな感じの Websocket サーバー
app.rb
# frozen_string_literal: true

require 'em-websocket'

puts 'Starting Websocket server'

EM.run do
  EM::WebSocket.run(host: '0.0.0.0', port: 3000) do |ws|
    ws.onopen do |handshake|
      puts "WebSocket opened #{{
        path: handshake.path,
        query: handshake.query,
        origin: handshake.origin
      }}"

      ws.send 'Hello Client!'.dup
    end
    ws.onclose { puts 'WebSocket closed' }
    ws.onerror { |e| puts "Error: #{e.message}" }
    ws.onmessage do |msg|
      puts "Recieved message: #{msg}"
      ws.send "pong msg: #{msg}" if msg == 'ping'
    end
  end

  puts 'Server started.'
end

Gem は Websocket に関連して em-websocket のみ使用しています

# frozen_string_literal: true

source 'https://rubygems.org'

gem "em-websocket", "~> 0.5.3"

に接続できるようになりました

ping を送って pong が返ってくるというだけのサーバーですが、ボタンを押すと下の図のような表示になり接続できていることが確認できました

ping_pong

構成

いくつかの視点からどのような構成になっているかをまとめます

IAM 設定や各種実装のコード、気をつけたところなど

各種サービスアカウントと権限

GCP ではサービスアカウントに対して権限を付与し、各種操作を行います
今回使用するのは以下の 3 つのサービスアカウントです

  • インスタンス実行用
    • Artifact Registry 読み込み
    • モニタリング指標の書き込み
    • ログ書き込み
  • GCB でのビルド用
    • GCBサービスアカウント
    • Artifact Registry 書き込み
    • Compute 閲覧者: Compute のリソースを閲覧し、インスタンスを見つけるのに必要
    • 対象のインスタンス固有の権限
      • Compute インスタンス管理者: インスタンスに SSH で接続するのに必要
  • Certbot 実行用
    • DNS の読み取り
    • 該当の DNS ゾーン固有の権限
      • DNS 管理者

開発者目線

  1. Github に変更をプッシュ
  2. GCB のトリガーにより以下を実行
    1. イメージのビルド
    2. Artifact Registry にイメージをプッシュ
    3. インスタンスに SSH 接続し、Websocket コンテナのイメージの更新チェックと更新があれば更新するスクリプトを実行
関連するコード

GCB でビルドするための設定ファイル

ビルドしたイメージは Artifact Registry へプッシュする
インスタンス内からは crontab で定期的にプルを実施し、新しいイメージがあるときのみ更新・再起動を行う

crontab の実行を待たずにビルドステップで直後インスタンス内のコンテナを更新したい場合は追加の権限とステップの追加

cloudbuild.yaml
steps:
  # イメージのビルド
  - id: 'build image'
    name: 'gcr.io/cloud-builders/docker'
    dir: ./ws
    args:
      - build
      - -t=${_ARTIFACT_HOST}/${PROJECT_ID}/${_ARTIFACT_REGISTRY}/${_ARTIFACT_IMAGE}:latest
      - .
  # ビルドしたイメージを Artifact Registry にプッシュ
  - id: 'push image with latest tag'
    name: 'gcr.io/cloud-builders/docker'
    args:
      - push
      - ${_ARTIFACT_HOST}/${PROJECT_ID}/${_ARTIFACT_REGISTRY}/${_ARTIFACT_IMAGE}:latest
  # インスタンスに SSH 接続し、Websocket コンテナのイメージの更新チェックと更新があれば更新する
  - id: apply to docker compose
    name: "gcr.io/cloud-builders/gcloud"
    entrypoint: "bash"
    args:
      - "-c"
      - |
        gcloud compute ssh \
          gcb-ws-builder@${_INSTANCE_NAME} \
          --project=${PROJECT_ID} --zone=${_INSTANCE_ZONE}  --ssh-flag="-p ${_INSTANCE_SSH_PORT}" \
          --command='sudo sh /app/update_containers.sh'
options:
  logging: CLOUD_LOGGING_ONLY
Dockerfile
FROM ruby:3.2.4

# 作業ディレクトリを設定
RUN mkdir /app
WORKDIR /app

# GemfileとGemfile.lockをコピー
COPY ./Gemfile ./Gemfile.lock ./

# 必要なgemをインストール
RUN bundle install

# アプリケーションのソースコードをコピー
COPY . ./

# コンテナ起動時のコマンド設定
CMD ["/usr/local/bin/ruby", "app.rb"]

Websocket コンテナのイメージの更新チェックと更新を行うスクリプト

update_containers.sh
cd /app
docker compose pull # イメージのプル
docker compose up -d ws # 更新があれば適用
docker image prune -f # 不要なイメージの削除

インスタンス

  • インスタンス設定
    • タイプ: e2-micro
    • リージョン: asia-northeast1(東京)
    • VM プロビジョニングモデル: スポット
    • ファイヤーウォール
      • HTTP トラフィック: オフ
      • HTTPS トラフィック: オン
      • SSH: ポート変更を22から変更
    • 追加でインストールしたパッケージ
  • ホストとしての役割
    • Docker Compose でコンテナを管理
    • 各コンテナに必要なファイルをマウント
    • Certbot コンテナを定期実行
    • 証明書ファイルの更新の監視を行い、更新があれば Nginx を再起動
    • Websocket イメージの更新を定期的に確認し、更新があれば適用・再起動
関連するコード

docker compose でコンテナを立ち上げる際に実行環境の環境変数や .env が読み込まれるため、以下のようなファイルを用意

.env
# GCP のプロジェクト ID
PROJECT_ID=
# Artifact Registry 保存先の指定
ARTIFACT_HOST=
ARTIFACT_REGISTRY=
ARTIFACT_IMAGE=

# 証明書の更新に使用するメールアドレス、ドメイン
EMAIL=
DOMAIN=

Certbot, Nginx, Websocket のコンテナの設定を記述

  • Nginx: コンテナで 443 ポートを使用し、外部からの HTTPS リクエストを受け取る
  • Websocket: ポートを公開せず、Nginx 経由でアクセスを受ける
  • restart always
    • コンテナが停止した際に自動で再起動するように設定
  • depends_on: ws
    • Websocket コンテナが起動してから Nginx コンテナを起動する
compose.yaml
services:
  # 証明書の発行を担当
  certbot:
    image: certbot/dns-google
    volumes:
      - ./credentials/letsencrypt:/etc/letsencrypt
      - ./credentials/cloud_dns/dns-service-account.json:/etc/cloud-dns/dns-service-account.json
      - ./certbot/renew_certs.sh:/usr/local/bin/renew_certs.sh
    environment:
      - DOMAIN=${DOMAIN}
    entrypoint: /bin/sh
    command: ["-c", "renew_certs.sh"]
  # TLS/SSL 対応とアプリケーションへのリバースプロキシを担当
  nginx:
    image: nginx:1.27
    volumes:
      - ./nginx/nginx.prd.conf:/etc/nginx/nginx.conf
      - ./credentials/letsencrypt/live:/etc/letsencrypt/live
      - ./credentials/letsencrypt/archive:/etc/letsencrypt/archive
    ports:
      - "443:443"
    restart: always
    depends_on:
      - ws
  # Websocket アプリケーション本体
  ws:
    image: ${ARTIFACT_HOST}/${PROJECT_ID}/${ARTIFACT_REGISTRY}/${ARTIFACT_IMAGE}:latest
    restart: always

いくつか方法はあるようですが、inotify-tools を使用して証明書の更新を監視し、更新があれば nginx を再起動するスクリプトを作成しました
sh monitor-certs.sh & で常にバックグラウンドで実行しておき、crontab からの証明書更新からの nginx 再起動を行います

momitor-certs.sh
# 証明書ディレクトリを監視
inotifywait -m -e create /app/credentials/letsencrypt/live/${DOMAIN} |
while read path action file; do
  if [ "${file}" = "fullchain.pem" ]; then
    cd /app && docker compose restart nginx
  fi
done

crontab で実施する定期実行の設定ファイル(例)

  • 毎0時0分(9時0分 JST)に certbot を実行
    • 証明書の更新が完了後、/app/credentials/letsencrypt/live/${DOMAIN} に証明書が保存される
  • 毎0時0分(9時0分 JST)に Websocket の更新を確認
    • Websocket のイメージのプルを試み、更新がなければ更新があれば適用し
    • 不要なイメージの削除
crontab(例)
0 0 * * * cd /app && docker compose up -d certbot

関係ないですが、時差とかサマータイムとか早く滅びてほしいです
全員 UTC で生活すれば最初困るだけで将来的には幸せになれるのではないでしょうか

Certbot コンテナ

  • コンテナの役割
    • 証明書の有効期限が 30 日未満の場合に更新
    • ホストから定期実行され、終了後に停止
    • 指定したサービスアカウントを使用して DNS-01 の認証を実施
    • 更新が完了したらホストからマウントしている letsencrypt ディレクトリに証明書を保存
  • ホストから各コンテナへのファイル・ディレクトリのマウント
    • letsencrypt 関連ディレクトリ全体
    • DNS 管理者の権限を持つサービスアカウント JSON ファイル
    • 証明書更新用スクリプトファイル
関連するコード

DNS を操作する権限を付与したサービスアカウントの JSON ファイル
GCP > IAM と管理 > サービスアカウント で該当のサービスアカウントのキーを作成したもの
certbot/dns-google イメージへマウントし、DNS-01 の認証に使用する

dns-service-account.json
{
  "type": "service_account",
  "project_id": "_project_name_",
  "private_key_id": "**************",
  "private_key": "-----BEGIN PRIVATE KEY-----\n***\n-----END PRIVATE KEY-----\n",
  "client_email": "_service_account_name_@_project_name_.iam.gserviceaccount.com",
  "client_id": "**************",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/_service_account_name_%40_project_name_.iam.gserviceaccount.com",
  "universe_domain": "googleapis.com"
}

初回の証明書発行時に使用するコマンド
certbot コンテナ内で以下を実行

certbot certonly \
  --dns-google \
  --dns-google-credentials /etc/cloud-dns/dns-service-account.json \
  --dns-google-propagation-seconds 90 \
  --agree-tos \
  -m ${EMAIL} \
  --non-interactive \
  -d ${DOMAIN}

証明書更新用スクリプト
certbot renew コマンドを実行する前に設定や有効期限の確認を行なっています

renew_certs.sh
# 環境変数にドメインが設定されていない場合は終了
if [ -z "${DOMAIN}" ]; then
  echo "[$(date "+%Y-%m-%d %H:%M:%S")] DOMAIN is not set, exiting."
  exit 0
fi

# Let's Encryptの証明書ディレクトリ、存在しない場合は終了
cert_dir="/etc/letsencrypt/live/${DOMAIN}"

if [ ! -d ${cert_dir} ]; then
  echo "[$(date "+%Y-%m-%d %H:%M:%S")] Certificate directory not found, exiting."
  exit 0
fi

# 有効期限の取得
expiration_date=$(openssl x509 -in ${cert_dir}/cert.pem -noout -enddate | cut -d= -f2 | sed 's/ GMT//')
expiration_epoch=$(LC_TIME=C date -d "$expiration_date" +%s)

# 有効期限までの日数を計算
current_date=$(date +%s)
days_left=$(((expiration_epoch - current_date) / 86400))

# 30日未満の場合に更新
if [ $days_left -lt 30 ]; then
  certbot renew --non-interactive
else
  echo "[$(date "+%Y-%m-%d %H:%M:%S")] No need to renew, certificate is valid for another $days_left days."
fi

Nginx コンテナ

  • コンテナの役割
    • TLS/SSL 対応を担当
    • Websocket へのリバースプロキシを行う
  • ホストから各コンテナへのファイル・ディレクトリのマウント
    • letsencrypt ディレクトリのうち live, archive のみ(live から archive の実ファイルへシンボリックリンクが貼られているため)
    • config ファイル
関連するコード

Nginx の設定ファイル

  • server_tokens off;
    • デフォルトでエラー時に Nginx のバージョンが表示されるためバージョン情報を非表示にする
  • default_server
    • デフォルトサーバーの設定を行い、server_name にマッチしない場合は 444 を返すように設定
  • Websocket 用 Proxy 設定
    • proxy_set_header で Upgrade, Connection を追加
  • proxy_pass http://ws:3000;
    • 別コンテナの Websocket へリクエストを転送
    • compose.yaml で設定した Websocket のサービスの名前とコンテナ内で使用しているポート番号を指定する
    • コンテナ間通信には compose.yaml の ports の指定は不要
nginx.conf
events {}

http {
    # サーバーの情報を隠す
    server_tokens off;

    # WebSocketの設定
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    # デフォルトサーバーの設定
    server {
        listen 443 ssl default_server;
        server_name _;

        ssl_certificate /etc/letsencrypt/live/_domain_name_/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/_domain_name_/privkey.pem;

        return 444; # 未定義のリクエストを切断 No Response
    }

    # WebSocket へのプロキシ設定
    server {
        listen 443 ssl;
        server_name _domain_name_;

        ssl_certificate /etc/letsencrypt/live/_domain_name_/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/_domain_name_/privkey.pem;

        location / {
            proxy_pass http://ws:3000;  # 後続のアプリケーション
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
        }
    }
}

Websocket コンテナ

  • コンテナの役割
    • アプリケーション本体
  • ホストから各コンテナへのファイル・ディレクトリのマウント
    • なし
関連するコード
app.rb
# frozen_string_literal: true

require 'em-websocket'

puts 'Starting Websocket server'

EM.run do
  EM::WebSocket.run(host: '0.0.0.0', port: 3000) do |ws|
    ws.onopen do |handshake|
      puts "WebSocket opened #{{
        path: handshake.path,
        query: handshake.query,
        origin: handshake.origin
      }}"

      ws.send 'Hello Client!'.dup
    end
    ws.onclose { puts 'WebSocket closed' }
    ws.onerror { |e| puts "Error: #{e.message}" }
    ws.onmessage do |msg|
      puts "Recieved message: #{msg}"
      ws.send "pong msg: #{msg}" if msg == 'ping'
    end
  end

  puts 'Server started.'
end

今後必要になるものとしては、ユーザーの認証や必要であればデータの永続化、必要な情報をやり取りするためのフローの追加などが挙げられます

ユーザー目線

  1. ブラウザから既存のウェブアプリケーションにアクセス
  2. Websocket サーバーに Nginx 経由で通信
関連するコード
Connection.client.vue
<script setup>

const runtimeConfig = useRuntimeConfig();
const websocketUrl = ref(runtimeConfig.public.websocketUrl);
if (!websocketUrl) {
  throw new Error('Missing runtime config: public.websocketUrl');
}

const ws = new WebSocket(runtimeConfig.public.websocketUrl);

const isConnected = ref(false);
const pong = ref("");

ws.onopen = () => {
  console.log('Connected to WebSocket server');
  isConnected.value = true;
};

ws.onmessage = (event) => {
  const data = event.data
  console.log('Message from server: ', data);
  if (data.includes('pong')) {
    pong.value = data;
    setTimeout(() => { pong.value = "" }, 1000);
  }
};

ws.onclose = () => {
  console.log('Disconnected from WebSocket server');
  isConnected.value = false;
};

ws.onerror = (error) => {
  console.log('WebSocket Error: ', error);
};

function ping() {
  ws.send('ping');
  console.log('Sent: ping');
}
</script>

<template>
  <div>
    <p>websocketUrl: {{ websocketUrl }}</p>
    <p>Status: {{ isConnected ? "connected" : "disconnected" }}</p>
  <div>
    <button @click="ping()"> PING </button>
    <span> {{ pong }} </span>
  </div>
  </div>
</template>

負荷

  • CPU使用率: ベースライン 10% 程度、OS 関連のタスク実行時に 30~50% 程度
  • メモリ使用率: 50% 程度
  • Websocket に接続して少し遊んでみた程度では CPU メモリともに有意な変化はなし
  • 実際にどの程度の流量で限界が来るかは未検証

コスト

トータルで 1000 円/月 程度で運用できそうです

  • インスタンス
    • リージョン: Tokyo(asia_northeast1)
    • タイプ: e2-micro x 1 台(スポット)
    • 約 700 円/月
  • 静的 IP アドレス
    • 約 300 円/月
  • DNS
    • 約 30 円/月
  • Artifact Registry
    • 約 10 円/月

今回 VM プロビジョニングモデルはスポットを使用しており、GCP 側の余剰リソースを使用するため少し安く使える特徴があります
スポットインスタンスでは、24 時間以内(いつかとまるかはわからない)に自動的に停止されるという制約があり、GCP 側に余剰リソースによっては起動させてもらえない場合もあるようです
使ってみたところ 24 時間以内に終了されると聞いていたものの数日稼働していたりもするためどうなるかは GCP の気持ち次第なようでした
実験的に使う分には起動しなおせばいいだけだし安いしで申し分ないですが、本番環境として使用する際には注意が必要です

標準のプロビジョニングモデルを使用する場合の料金は、主にインスタンス代と IP 代が影響され、概算で以下のようになりそうです
時期や為替にもよりますが、2000 円は少し厳しいですね

  • インスタンス
    • リージョン: Tokyo(asia_northeast1)
    • タイプ: e2-micro x 1 台(標準)
    • 約 1400 円/月
  • 静的 IP アドレス
    • 約 500 円/月

あとは、リアルタイム性がそれほど必要なく、往復 100 ~ 200 ms の遅延が気にならない場合にはオレゴンにインスタンスを立てることで無料枠を使ったり安く使用することができます
このあたりはサービスの要件次第で許されるのかが変わってきますね

おまけ、すぐに Websocket コンテナに反映しなくてもいい場合

上述の CI フローからの Websocket コンテナの更新では必要な権限も多く、趣味のプロジェクトでは別にすぐに更新しなくてもいいかなと思いました
その場合、以下の権限、ビルドステップを削除し、crontab で定期的に更新しても良さそうです

ビルドステップから Websocket コンテナの更新を行う

権限

  - GCB でのビルド用
    - GCBサービスアカウント
    - Artifact Registry 書き込み
-   - Compute 閲覧者: Compute のリソースを閲覧し、インスタンスを見つけるのに必要
-   - 対象のインスタンス固有の権限
-     - Compute インスタンス管理者: インスタンスに SSH で接続するのに必要

ビルドステップの削除

cloudbuild.yaml
-  # インスタンスに SSH 接続し、Websocket コンテナのイメージの更新チェックと更新があれば更新する
-  - id: apply to docker compose
-    name: "gcr.io/cloud-builders/gcloud"
-    entrypoint: "bash"
-    args:
-      - "-c"
-      - |
-        gcloud compute ssh \
-          gcb-ws-builder@${_INSTANCE_NAME} \
-          --project=${PROJECT_ID} --zone=${_INSTANCE_ZONE}  --ssh-flag="-p ${_INSTANCE_SSH_PORT}" \
-          --command='sudo sh /app/update_containers.sh'

crontab によるイメージの更新チェックと更新

crontab(例)
0 0 * * * cd /app && docker compose up -d certbot
+0 0 * * * cd /app && docker compose pull && docker compose up -d ws && docker image prune -f

TODO

  • Docker Compose を Rootless で実行する
  • ログ出力を Google Cloud Logging に送る
  • VM プロビジョニングモデルの検討
    • スポットのまま: 24時間でインスタンスが自動で停止するため、停止後の起動フローの構築が必要
    • 標準に変更: インスタンスは停止されないが、コストの増加が許容できるか検討

まとめ

  • TLS/SSL 対応や証明書の自動更新を含む Websocket サーバーの構築ができた(ものの複雑になってしまってちょっとつらい)
  • スポットインスタンスで月 1000 円程度の運用コストに抑えることができたが、快適性を考えると標準インスタンス(月2000円)も検討したい
  • 各機能ごとにコンテナを分けたのは管理がしやすくてよさそう
  • デプロイや証明書更新時にいち早く更新するための仕組みのせいで複雑さが増しており、IAM に付与する権限の強さも気になる
  • インフラの構築に労力が割かれることでアプリケーションの開発に集中できなくなると本末転倒なためほどほどにしたい
  • Docker Compose で構築したが、スケールが必要になる規模では素直に Kubernetes を使った方がいいかもしれない

今回は使用しませんでしたが、Watchtower という面白そうな Docker イメージがありました
イメージをプッシュしたらいい感じに更新してくれそうなため、「お!!」となったものの、内容としてはデフォルトで1日1回イメージの更新がないか確認するというもののようです
これを見つけた時には「すぐ適用されてほしいんだよ!」と思って導入を見送りましたが、趣味レベルであれば十分な気もします
ただ、「本格的なことをやる場合は Kubernetes とか使えよ」という説明がありました(だよね)

(機械翻訳)

Watchtower は、ホームラボ、メディア センター、ローカル開発環境などでの使用を目的としています。商用環境や実稼働環境で Watchtower を使用することはお勧めしません。そのような環境の場合は、Kubernetes の使用を検討してください。

同様に、RTMPS などでも同じようにできるかもしれないですね

GitHubで編集を提案
SocialPLUS Tech Blog

Discussion