CloudRunでhttp/2とwebsocket起動を両立した話
経緯
ある日、開発中のシステムの検証環境を触っている時、websocket通信に失敗しているログが流れているのに気づきました。
慌ててローカル環境でも確認しましたが、ローカルは普通に動いています。
おかしいなと思ってあれこれ調べたところ、どうやら後から追加したCloudRunのhttp/2を有効にする設定がwebsocketと噛み合いが悪いことが発覚しました。
ということで、この状況を無事解決することができたので、どうやって解決したかを記事にまとめてみます。
対象者
- http/2有効化とwebsocket起動の両方が必須の欲張り仕様を実現したい方
- GoogleCloudの基本的な知識をある程度持っている方(この記事では本筋に関わらない設定内容などは解説しないため)
なぜhttp/2だとwebsocketが動かなくなるのか
公式ドキュメントには「http/2だとwebsocketは動かないよ」というのが明記してあります。
そもそも、http/2が有効になってるとwebsocketが動かないのはなぜなのでしょうか。
まず、従来のwebsocketはhttp/1.1のUpgradeハンドシェイクに依存しています。
つまり、Upgrade/Connectionのようなヘッダー情報が必要です。
Upgradeハンドシェイクとは
クライアントがHTTPプロトコルから別のプロトコルへ接続を切り替えるためのHTTPヘッダーフィールド。
Chromeならdev toolsのNetworkタブのheadersタブで確認できる。

一方で、http/2はUpgrade/Connectionを使うことができません。
よって、http/2ではwebsocket通信が成立しないということになります。
これが、http/2を有効化することでwebsocketが動かなくなる原因です。
本題
さて、ここからはhttp/2の有効化とwebsocket通信の両立をするためのリソース構成と、各リソースの設定について解説します。
前提
アプリケーションはNginxとPHPで動作する環境で、websocketライブラリはratchetを利用します。
リソース構成
以下のように、Load Balancerを1つ、Backend Service, Network Endpoint Group, Cloud Runをそれぞれ2つずつ用意しました。

Cloud Runは、一方はhttp/2を有効化し、もう一方は有効化しないようにします。
これで、有効化した方でアプリケーションを、有効化しなかった方でwebsocketをそれぞれ起動すれば、http/2通信とwebsocketを両立することができるわけです。
また、Load Balancerの設定でhttps://かwss://かによって送信先を分けるように設定することで、設置するLoad Balancerを1つで済ますことができます。
ちなみに、元々は図でいうところの下側にあるリソースしか作成してませんでした。
なので、シナリオとしては図の上側を後から追加する、という想定です。
各リソースの設定
ここからは、各リソースで行う必要のある設定を解説していきます。
※ 今回実現することに対して直接関係する部分だけまとめて、それ以外は特に解説しませんのでご了承ください
Cloud Run
前提として、Cloud Runでは以下のコンテナを立ち上げる想定です。
アプリケーションを起動する方
/
└ docker/
└ production/
├ nginx/
| ├ default.conf
| ├ Dockerfile
| ├ htpasswd
| └ nginx.conf
└ php/
├ conf.d/
| ├ opcache.ini
| └ php.ini
├ php-fpm.d/
| └ zz-docker.conf
├ docker-healthcheck.sh
└ Dockerfile
Dockerfileは以下のような感じ
といっても何か特殊なことをしてるとかはなく、普通のDockerfileって感じなので解説するところは特にない
他の設定ファイルについても同様
FROM nginx:stable-bullseye
COPY ./docker/production/nginx/nginx.conf /etc/nginx/
COPY ./docker/production/nginx/default.conf /etc/nginx/conf.d/
COPY ./docker/production/nginx/htpasswd /etc/nginx/.htpasswd
COPY ./app /var/app
# 省略:ライブラリのインストール、有効化など
WORKDIR /var/www
FROM php:8.3.6-fpm-bullseye
COPY ./docker/production/php/conf.d/php.ini /usr/local/etc/php/
COPY ./docker/production/php/conf.d/opcache.ini /usr/local/etc/php/conf.d/
COPY ./docker/production/php/php-fpm.d/zz-docker.conf /usr/local/etc/php-fpm.d/
COPY ./docker/production/php/docker-healthcheck.sh /usr/local/bin/docker-healthcheck
# 省略:ライブラリのインストール、有効化など
RUN groupadd -r www-user && \
useradd -r -g www-user -G www-data www-user
COPY ./app/.env.production.example /var/app/.env
RUN mkdir -p /home/www-user && \
mkdir -p /home/www-user/.cache && \
chown -R www-user:www-user /home/www-user
USER www-user
WORKDIR /var/app
websocketを起動する方
/
├ docker/
| └ production/
| └ ratchet/
| ├ conf.d/
| | ├ opcache.ini
| | └ php.ini
| └ Dockerfile
└ app/
└ websocket/
└ server.php
Dockerfileは以下のような感じ
アプリケーション側ではphp:8.3.6-fpm-bullseyeを選んだが、こちらではwebsocketサーバーのみを立ち上げたいのでfpmは不要
むしろ、起動コマンドが競合して予期せぬ挙動をすることがあるので、PHPのバージョンが同じでをfpmを起動しないイメージを選んだ
ライブラリのインストールも、PHPコンテナと合わせる必要はなく最低限で良い
FROM php:8.3.6-bullseye
COPY ./docker/production/ratchet/conf.d/php.ini /usr/local/etc/php/
COPY ./docker/production/ratchet/conf.d/opcache.ini /usr/local/etc/php/conf.d/
# 省略:ライブラリのインストール、有効化など
RUN groupadd -r www-user && \
useradd -r -g www-user -G www-data www-user
RUN mkdir -p /home/www-user && \
mkdir -p /home/www-user/.cache && \
chown -R www-user:www-user /home/www-user
USER www-user
CMD php /var/app/websocket/server.php
上記3つのコンテナイメージは、既にArtifact Registryにpushされているものとします。
続いて、Cloud Runの設定についてです。
まず、以下のコマンドでCloud Runにデプロイします。
# app
gcloud run deploy "cloud-run-app" \
--container="nginx" \
--image="nginx_image" \
--port=8080 \
--container="php" \
--image="php_image"
# websocket
gcloud run deploy "cloud-run-ws" \
--container="ratchet" \
--image="ratchet_image" \
--port=3000
デプロイが完了したら、Cloud Runの設定を確認します。
コンソールから編集を開いて、

アプリケーションを起動する方は「HTTP/2 エンドツーエンドを使用する」にチェックを入れ、websocketの方はチェックを外してください。

Network Endpoint Group
作成するNEGのタイプは「サーバーレス」です。
サーバーレスNEGは、コンソール上だとHTTP(S)ロードバランサを作成するときにしか作成できないようです。
なので、今回のように元々あるロードバランサに対して別の送信先として作成する場合はCLIを使う必要があります。
gcloud compute network-endpoint-groups create "neg-app" \
--region="asia-northeast1" \
--network-endpoint-type=serverless \
--cloud-run-service="cloud-run-app"
gcloud compute network-endpoint-groups create "neg-ws" \
--region="asia-northeast1" \
--network-endpoint-type=serverless \
--cloud-run-service="cloud-run-ws"
既にアプリケーション側のサーバーレスNEGが作成済みの場合は、neg-appの方は実行しなくていいです。
Backend Service
Backend ServiceもCLIで作成します。
こちらはコンソールからでも作れそうなんですが、CLIの方が簡単そうなので、、
gcloud compute backend-services create "backend-app" \
--global \
--protocol=HTTP \
--load-balancing-scheme=EXTERNAL
gcloud compute backend-services create "backend-ws" \
--global \
--protocol=HTTP \
--load-balancing-scheme=EXTERNAL
作成できたら、サーバーレスNEGと繋げます。
gcloud compute backend-services add-backend "backend-app" \
--global \
--network-endpoint-group="neg-app" \
--network-endpoint-group-region="asia-northeast1"
gcloud compute backend-services add-backend "backend-ws" \
--global \
--network-endpoint-group="neg-ws" \
--network-endpoint-group-region="asia-northeast1"
既にアプリケーション側のBackend Serviceが作成済みの場合は、backend-appの方は実行しなくていいです。
Load Balancer
既存のロードバランサ(url-map)の編集画面を開きます。
「ホストとパスのルール」で、パスが/*ならアプリケーションを起動するCloud Runに繋がるBackend Service、/wsだったらwebsocketを起動するCloud Runに繋がるBackend Serviceが来るように編集します。

これで、パスに応じて別々のCloudRunにリクエストが送信されるようになりました!
最後に
http/2だとwebsocketが動かないなんて思いもしてなくて、動いてないのに気づいた時はめっちゃ焦りました。。
websocketをやめてgRPCとかに切り替えることも検討してたんですが、最終的にこの方法が成功してwebsocketをそのまま使うことができたので本当によかったです。
参考文献
株式会社 カラビナテクノロジーは「命綱や支点を素早く確実に繋ぐカラビナ。そんなカラビナのような役割をテクノロジーで実現したい」という想いのもと、福岡で設立。 主にシステム開発・アプリ開発・ Webサイト制作を行っています。採用情報→karabiner.tech/career
Discussion