はじめに
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 // すぐに切断される
メッセージを送信してもリアルタイム配信されず、ページをリロードして初めて表示される状態でした。
調査プロセス
- バックエンドログの確認
まず、バックエンドのログを確認したところ、重要な手がかりが見つかりました:
[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
このフォールバックチェーンの問題点:
- Deviseがトークンを発行する時: credentials.secret_key_base (値A) で署名
- ActionCableが検証する時: 同じく credentials.secret_key_base (値A) で検証しようとする
- しかし実際には: 環境によって 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
期待される動作
- ユーザーが一度ログアウトして再ログイン
- チャットグループを開く
- ブラウザコンソールで以下が表示される:
WebSocket URL: ws://localhost/cable
✅ WebSocket connected to ChatChannel // 切断されない!
- バックエンドログで認証成功を確認:
[ActionCable] Started GET "/cable?token=[FILTERED]" [WebSocket] for 172.64.66.1
[ActionCable] Successfully upgraded to WebSocket
ChatChannel is streaming from chat:Z2lkOi8vYXBwL0NoYXRHcm91cC8zOA
- 2つのブラウザウィンドウで異なるユーザーがログインし、リアルタイムでメッセージが送受信できることを確認
まとめ
今回の問題は以下の3点が重なっていました:
- WebSocket接続経路: 環境変数で固定されたURLではなく、動的生成 + Nginxプロキシ設定が必要
- Redis設定: docker-compose.ymlに REDIS_URL 環境変数を追加
- 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(または別のアダプター)が必要
参考