🚀

nginx で実現する WebSocket サーバの無切断デプロイ

2025/01/12に公開

はじめに

WebSocket プロトコルは通常の HTTP 通信などと異なり、接続が長時間になるため、サーバのアップデートなどで接続を切断することなく切り替えることが難しいです。この記事では、nginx を使って WebSocket サーバの 切り替えを切断を出来るだけ避けて実現する方法を紹介します。技術的には出来るだろうという見込みがあったものの、具体的な方法が分からず調べてみたのでその結果のまとめです。

前提

docker-compose で説明します。k8s やクラウドのサービスを使っている場合はより良い方法があるかもしれません。

実現方法

Blue/Green デプロイメントのように、blue と green の 2 つの WebSocket サーバを用意します。nginx で WebSocket のリバースプロキシを設定し、接続を切り替える際には、nginx の設定を変更しリロードします。
これにより、blue → green と切り替えたとしても、切り替え前に接続してきた人は blue に接続したまま、新たに接続してきた人は green に接続するようになります。

WebSocket サンプルアプリ

適当に GitHub Copilot に書いてもらいました。環境変数 ENV で blue か green かを1秒ごとに表示します。

index.js
const WebSocket = require("ws")

const server = new WebSocket.Server({ port: 8080 })

server.on("connection", (socket) => {
  console.log("クライアントが接続しました")
  socket.send(`こんにちは、クライアント 環境:${process.env.ENV}`)
  const ret = setInterval(() => {
    console.log(`${process.env.ENV}を送信します`)
    socket.send(process.env.ENV)
  }, 1000)

  socket.on("message", (message) => {
    console.log(`受信メッセージ: ${message}`)
    socket.send(`サーバーからの応答: ${message}`)
  })

  socket.on("close", () => {
    console.log("クライアントが切断しました")
    clearInterval(ret)
  })
})

console.log("WebSocketサーバーがポート8080で起動しました")
FROM node:latest
ENV ENV=blue

WORKDIR /app
COPY package.json package-lock.json /app/
RUN npm ci
COPY . /app

CMD ["node", "index.js"]

クライアント

こちらも適当に GitHub Copilot に書いてもらいました。

client.js
const WebSocket = require("ws")

const socket = new WebSocket("ws://localhost:8080")

socket.on("open", () => {
  console.log("サーバーに接続しました")
  socket.send("こんにちは、サーバー")
})

socket.on("message", (data) => {
  console.log(`サーバーからのメッセージ: ${data}`)
})

socket.on("close", () => {
  console.log("サーバーとの接続が切断されました")
})

socket.on("error", (error) => {
  console.error(`エラーが発生しました: ${error.message}`)
})

nginx の設定

ここがキモです。WebSocket の upstream を定義しますが、blue の設定では green は down にしておきます。

blue.conf
events {}

http {
    map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
    }

    upstream app {
        server app-blue:8080;
        server app-green:8080 down;
    }

    server {
        listen 80;

        location / {
            proxy_pass http://app;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade; 
            proxy_set_header Connection $connection_upgrade;
        }
    }
}
green.conf
events {}

http {
    map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
    }

    upstream app {
        server app-blue:8080 down;
        server app-green:8080;
    }

    server {
        listen 80;

        location / {
            proxy_pass http://app;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade; 
            proxy_set_header Connection $connection_upgrade;
        }
    }
}

この2つの設定ファイルをシンボリックリンクで切り替えることにします。シンボリックリンクを切り替えた後は、nginx を nginx -s reload でリロードします。正確には docker compose を使っているので、docker compose exec nginx nginx -s reload になります。

docker-compose

compose.yml
services:
  app-blue:
    build: ./app
    environment:
      - ENV=blue
  app-green:
    build: ./app
    environment:
      - ENV=green
  nginx:
    image: nginx:alpine
    volumes:
      - ./nginx-conf:/etc/nginx:ro
    ports:
      - "8080:80"

やってみる

クライアントで接続 (blue) → nginx の設定を green に変更 → クライアントで接続 (green) としてみると、blue に接続したクライアントはそのまま、新たに接続したクライアントは green に接続されることが確認できます。

実際は git pull してソースを更新、docker-compose up -d --build green で(例えば)green を新バージョンに更新、nginx の設定を green に変更、nginx をリロードするという手順を踏むことになります。
まあオペミスしやすそうなので何らかの工夫があると良さそうですね。

欠点

  • サーバを2つ起動しておくためリソースを余分に消費する(合計の接続数は変わらないはずなので、丸々2倍というわけではないと思いますが)
  • オペミスしやすそう
  • WebSocket の接続が長時間で blue → green → blue と切り替えても残っていると結局切断されてしまう

Discussion