0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rails ActionCableでWebSocket接続が切断される問題を解決した話

Posted at

はじめに

React + Rails APIのチャットアプリケーションでActionCableを使ったリアルタイム機能を実装していたところ、WebSocket接続が認証エラーで即座に切断される問題に遭遇しました。この記事では、問題の特定から解決までのプロセスを詳しく解説します。

環境

  • Backend: Ruby on Rails 7.1.5
  • Frontend: React 18.3.1 (TypeScript)
  • Infrastructure: Docker Compose
  • WebSocket: ActionCable + Redis
  • 認証: Devise + JWT (devise-jwt gem)
  • Reverse Proxy: Nginx (フロントエンドコンテナ内)

症状

フロントエンドからWebSocket接続を試みると、以下のような挙動が発生していました:

// ブラウザコンソール
WebSocket URL: ws://localhost:3001/cable
 WebSocket connected to ChatChannel
⚠️ WebSocket disconnected from ChatChannel  // すぐに切断される

メッセージを送信してもリアルタイム配信されず、ページをリロードして初めて表示される状態でした。

調査プロセス

  1. バックエンドログの確認

まず、バックエンドのログを確認したところ、重要な手がかりが見つかりました:

[ActionCable] Successfully upgraded to WebSocket (REQUEST_METHOD: GET, HTTP_CONNECTION: Upgrade, HTTP_UPGRADE: websocket)
[ActionCable] ActionCable JWT authentication error: Signature verification failed
[ActionCable] Token: eyJhbGciOiJIUzI1NiJ9....
[ActionCable] An unauthorized connection attempt was rejected

WebSocketアップグレードは成功しているが、JWT認証で失敗していることが判明しました。

問題1: WebSocket接続経路の設定ミス

問題の詳細

フロントエンドが ws://localhost:3001/cableに直接接続しようとしていました。Docker環境では、フロントエンドコンテナ(Nginx)からバックエンドコンテナへは backend:3000でアクセスする必要があります。

// frontend/src/services/cable.ts (修正前)
const WEBSOCKET_URL = process.env.REACT_APP_WS_URL;
// ws://localhost:3001/cable

解決方法

1. 環境変数の削除

ハードコーディングされたWebSocket URLを削除しました。

# frontend/.env (削除)
# REACT_APP_WS_URL=ws://localhost:3001/cable

2. 動的URL生成の実装

実行時にブラウザのホストから動的にWebSocket URLを生成するように変更:

// frontend/src/services/cable.ts
import { createConsumer } from '@rails/actioncable';

const getWebSocketURL = () => {
  // ビルド時の環境変数があればそれを使用
  const envWsUrl = process.env.REACT_APP_WS_URL;
  if (envWsUrl) return envWsUrl;

  // デフォルト: 相対パスで接続(Nginx経由)
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  return `${protocol}//${window.location.host}/cable`;
};

const WEBSOCKET_URL = getWebSocketURL();

console.log('WebSocket URL:', WEBSOCKET_URL);

export const createCableConnection = (token: string) => {
  return createConsumer(`${WEBSOCKET_URL}?token=${token}`);
};

3. Nginxプロキシ設定の追加

Dockerfileでビルド時にNginxの設定を追加し、/cable へのリクエストをバックエンドにプロキシするようにしました:

# frontend/Dockerfile
FROM nginx:alpine

COPY --from=build /app/build /usr/share/nginx/html

# Nginx設定WebSocket対応
RUN echo 'server { \
    listen 80; \
    location / { \
        root /usr/share/nginx/html; \
        try_files $uri /index.html; \
    } \
    location /api { \
        proxy_pass http://backend:3000; \
        proxy_set_header Host $host; \
        proxy_set_header X-Real-IP $remote_addr; \
    } \
    location /cable { \
        proxy_pass http://backend:3000/cable; \
        proxy_http_version 1.1; \
        proxy_set_header Upgrade $http_upgrade; \
        proxy_set_header Connection "upgrade"; \
        proxy_set_header Host $host; \
        proxy_read_timeout 86400; \
    } \
}' > /etc/nginx/conf.d/default.conf

これにより、ws://localhost/cable → Nginx → ws://backend:3000/cable という経路が確立されました。


問題2: ActionCableのRedis設定不足

問題の詳細

backend/config/cable.yml でRedisアダプターを指定していましたが、バックエンドコンテナの環境変数に REDIS_URL が設定されていませんでした。

# backend/config/cable.yml
development:
  adapter: redis
  url: redis://redis:6379/1  # この接続先が使えていなかった

解決方法

docker-compose.yml のbackendサービスに環境変数を追加:

# docker-compose.yml
services:
  backend:
    environment:
      RAILS_ENV: development
      DATABASE_URL: postgres://postgres:password@db:5432/sanzen_development
      REDIS_URL: redis://redis:6379/1  # 追加
      SECRET_KEY_BASE: "e3a32155cb6370d4e33409f417423efc..."

これでActionCableがRedis経由でメッセージをブロードキャストできるようになりました。


問題3: JWT秘密鍵の不一致(メインの原因)

問題の詳細

ここが最大の問題でした。Railsには複数の秘密鍵取得方法があります:

Rails.application.credentials.secret_key_base  # config/credentials.yml.enc から取得
Rails.application.secrets.secret_key_base      # config/secrets.yml から取得
Rails.application.secret_key_base              # ENV['SECRET_KEY_BASE'] から取得

実際にRailsコンソールで確認したところ:

$ docker exec backend rails runner "
  puts 'credentials.secret_key_base: ' + Rails.application.credentials.secret_key_base.to_s[0..50]
  puts 'secrets.secret_key_base: ' + Rails.application.secrets.secret_key_base.to_s[0..50]
  puts 'secret_key_base: ' + Rails.application.secret_key_base.to_s[0..50]
  puts 'ENV SECRET_KEY_BASE: ' + ENV['SECRET_KEY_BASE'].to_s[0..50]
"

出力結果

credentials.secret_key_base: hogehogehogehogehogehoge...
secrets.secret_key_base: buzbuzbuzbuzbuzbuz...
secret_key_base: buzbuzbuzbuzbuzbuz...
ENV SECRET_KEY_BASE: buzbuzbuzbuzbuz...

credentials.secret_key_base だけが異なる値を返していました!

原因の特定

Devise JWT設定とActionCable認証で、フォールバックチェーンを使っていました:

 # config/initializers/devise.rb (修正前)
 config.jwt do |jwt|
   jwt.secret = Rails.application.credentials.secret_key_base ||
                Rails.application.secrets.secret_key_base ||
                Rails.application.secret_key_base
   # ...
 end
  # app/channels/application_cable/connection.rb (修正前)
  def authenticate_user_from_token
    secret_key = Rails.application.credentials.secret_key_base ||
                 Rails.application.secrets.secret_key_base ||
                 Rails.application.secret_key_base

    decoded_token = JWT.decode(token, secret_key, true, { algorithm: 'HS256' })
    # ...
  end

このフォールバックチェーンの問題点:

  1. Deviseがトークンを発行する時: credentials.secret_key_base (値A) で署名
  2. ActionCableが検証する時: 同じく credentials.secret_key_base (値A) で検証しようとする
  3. しかし実際には: 環境によって credentials が異なる値を返すため、署名と検証で異なる鍵が使われていた

結果、JWT::DecodeError: Signature verification failed エラーが発生していました。


解決方法

credentials を使わず、直接 Rails.application.secret_key_base (環境変数から取得) を使うように統一しました:

  # config/initializers/devise.rb (修正後)
  config.jwt do |jwt|
    # credentialsは環境によって異なる値を返すため、直接secret_key_baseを使用
    jwt.secret = Rails.application.secret_key_base
    jwt.dispatch_requests = [
      ['POST', %r{^/api/v1/users/sign_in$}]
    ]
    jwt.revocation_requests = [
      ['DELETE', %r{^/api/v1/users/sign_out$}]
    ]
    jwt.expiration_time = 1.day.to_i
  end
  # app/channels/application_cable/connection.rb (修正後)
  def authenticate_user_from_token
    token = request.params[:token] || request.headers['Authorization']&.split(' ')&.last
    return nil unless token

    begin
      # credentialsは環境によって異なる値を返すため、直接secret_key_baseを使用
      secret_key = Rails.application.secret_key_base

      decoded_token = JWT.decode(
        token,
        secret_key,
        true,
        { algorithm: 'HS256' }
      )

      user_id = decoded_token[0]['sub'] || decoded_token[0]['user_id']
      User.find_by(id: user_id)
    rescue JWT::DecodeError, JWT::ExpiredSignature => e
      Rails.logger.error "ActionCable JWT authentication error: #{e.message}"
      Rails.logger.error "Token: #{token[0..20]}..." if token
      nil
    end
  end

再ログインが必要な理由

古いJWTトークンは credentials.secret_key_base (値A) で署名されていますが、修正後は Rails.application.secret_key_base (値B)
で検証するため、既存のトークンは無効になります。

そのため、修正後は必ず再ログインして新しいトークンを取得する必要があります。

動作確認

修正後、バックエンドを再起動してテストしました:

$ docker compose restart backend

期待される動作

  1. ユーザーが一度ログアウトして再ログイン
  2. チャットグループを開く
  3. ブラウザコンソールで以下が表示される:

WebSocket URL: ws://localhost/cable
✅ WebSocket connected to ChatChannel // 切断されない!

  1. バックエンドログで認証成功を確認:

[ActionCable] Started GET "/cable?token=[FILTERED]" [WebSocket] for 172.64.66.1
[ActionCable] Successfully upgraded to WebSocket
ChatChannel is streaming from chat:Z2lkOi8vYXBwL0NoYXRHcm91cC8zOA

  1. 2つのブラウザウィンドウで異なるユーザーがログインし、リアルタイムでメッセージが送受信できることを確認

まとめ

今回の問題は以下の3点が重なっていました:

  1. WebSocket接続経路: 環境変数で固定されたURLではなく、動的生成 + Nginxプロキシ設定が必要
  2. Redis設定: docker-compose.ymlに REDIS_URL 環境変数を追加
  3. JWT秘密鍵の不一致: credentials.secret_key_base は環境によって値が変わるため、Rails.application.secret_key_base で統一

特に3番目の秘密鍵の問題は、ログに「Signature verification
failed」と表示されるだけで原因特定に時間がかかりました。同様の問題に遭遇した方の参考になれば幸いです。

学び

  • ActionCableの認証エラーは、まずDeviseとActionCableで同じ秘密鍵を使っているか確認する
  • Rails.application.credentials.secret_key_base
    は環境ファイル(config/credentials.yml.enc)から読み込まれるため、本番/開発/テスト環境で異なる値になる可能性がある
  • Docker環境でのWebSocket接続は、Nginxでプロキシ設定(Upgrade、Connectionヘッダー)が必要
  • ActionCableは必ずRedis(または別のアダプター)が必要

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?