catalinaの備忘録

ソフトウェアやハードウェアの備忘録。後で逆引きできるように。

websocketとhttpの共存Webアプリを作る(nginx + uwsgi + flask)

そろそろWebSocketやってみようと思いました。かたりぃなです。

まずWebSocketを概念レベルで整理してから、技術的な実装をやっていきます。サンプルコードコピペしたい人は後半まで飛んでください。(未来の自分へのメッセージ)

WebSocketの概要

WebSocketはウェブアプリケーションでの双方向通信を実現するためのプロトコルです。

と一言で表現しても抽象的すぎます。このままでは何が美味しいのかわからないので幾つかの方向から分析してみます。

HTTPとの比較

HTTPは基本的にRequest-Responseという1組のメッセージのやり取りで構成されるシンプルなものです。

クライアントからのRequestがシーケンスの起点となっているため、クライアント→サーバのメッセージは任意のタイミングで実行できますが、サーバ→クライアントのメッセージは色々と難しいという問題があります。

クライアントがポーリングするとか、ロングポーリングという解決策もありますが、HTTPのヘッダを毎回送信するのとか、ロングポーリングの時間の問題とかいろいろあったりします。

んで、この問題を解決する方法の1つがWebSocketです。

WebSocket使わずにTCPレベルでやりとりすればいいのでは?

WebSocket以外の解決策を考えた時、実装レイヤを下げてトランスポート層(TCP,UDP)でやるのもテかと思います。

しかしそうしてしまうと面倒な問題が増えてしまいます。すぐ思いつくのは疎通できるかどうかの問題です。

疎通できるかどうかで一番大きなものはルータ越えの問題(NATトラバーサル)とかでしょうか。 昔のPCでやるMMORPGなんかで見かけた「ポートxxを通過できるようルータやファイアウォールを設定してください」とかいうやつですね。

最近だとルータのUPnPを叩いてポートマッピングを自動でやったりとかが多いようですが、Webアプリで双方向通信をするためにそういうのを使うのは大げさすぎます。

なので、そんな大げさなことはせず、せっかくWeb標準としての通信規格として存在しているWebSocketを利用するのが現時点では最良と考えます。

実際のプロトコル

誤解を恐れずにいうなら「HTTP上に構成されるソケット通信プロトコル」といったところでしょうか。

実際のシーケンスとしては

  1. クライアントはHTTPリクエストに「WebSocket使いたいよ!」というUpgrade要求を含めて、サーバへ要求
  2. サーバはアップグレード要求に対する応答を返す
  3. WebSocket通信開始
  4. クライアント/サーバとも任意のトリガでソケットをread/writeすればいい

これでNATトラバーサルの話はこの時点で既に半分はクリアしていることになります。 HTTP上に構成されるプロトコルなのでWebページを見る場合と何ら変わりません。

少々気を付けるポイントとしてKeepAlive的なところで、WebSocket接続を維持するために一定時間ごとにメッセージを送受信する必要があります。

ただし、いくつかのWebSocket対応ライブラリでは自動で面倒見てくれるようになっています。

今回利用するuwsgiも自動でやりますよと明記されています。

WebSocketを試してみる

実験環境はいつものです。

システム構成

nginxをリバースプロキシとして、HTTPページとWebSocketページに振り分けます。

HTTPとWebSocketのどちらもWebアプリケーションのフレームワークとしてflaskを使用しますが、実際にはルーティング程度しかやっていません。

flaskをむき出しで運用するのはよくないので、アプリケーションサーバとしてuwsgiを使用することにします。

uwsgiとアプリケーション間のプロトコルは色々選択できるようになっています。今回はHTTPでやっちゃいます。

nginxの設定

ホスト名を見て振り分けたいところですが、ホストOSのhosts設定とか面倒なのでポート番号で振り分けることにしました。

HTTPの場合は何もせずHTTPサーバへ転送します。

user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                  '$status $body_bytes_sent "$http_referer" '
                  '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    upstream uwsgi {
        server uwsgi:3031;
    }

    # FlaskによるHTTPサーバ
    server {
        listen 8080;
        charset utf-8;

        location / {
            include uwsgi_params;
            uwsgi_pass uwsgi;
        }
    }

    upstream websocket {
        server uwsgi-ws:3032;
    }

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

    # WebSocket
    server {
        listen 8081;
        charset utf-8;

        location / {
            proxy_pass http://websocket;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
        }
    }
}

一個だけポイントがあって、WebSocketホスト側の転送設定でproxy_set_headerしています。

これはnginxをWebSocketのリバースプロキシとして使うとき、upgradeメッセージを適切にバックエンドに渡してあげる必要があるためです。

詳細はnginxのマニュアルを参照。

https://www.nginx.com/blog/websocket-nginx/

UWSGI

uwsgiはアプリケーションサーバです。cgi関連の実装にWSGIというのがありますが、それとは別物です。

初めてuwsgiの名前を知った時、u=μ(マイクロ)だと思って「軽量なwsgiプロトコルかな?」とか思ってしまいましたが、そうではなかったようです。

今回のuwsgiの役割はpythonアプリケーションのホスティングです。

今までflaskむき出しでテストしていましたが、これからはflaskむき出しではなく、サービス化も目指してこいつを使っていこうと思います。(実際、flaskのビルトインサーバは実運用では非推奨なので。)

uwsgi環境を用意するdockerfileはこうなりました。(後述の参考URLの内容ほぼそのままです)

# ベースイメージ
FROM python:3.6

RUN mkdir /var/www
# workdirの指定
WORKDIR /var/www

# 依存Pythonライブラリ一覧コピー
COPY requirements.txt ./

# 依存Pythonライブラリインストール
RUN pip install --no-cache-dir -r requirements.txt

# ログ出力用ディレクトリ
RUN mkdir /var/log/uwsgi/

CMD ["uwsgi","--ini","/var/www/uwsgi.ini"]

依存ライブラリを指定するrequirements.txtには

Flask
uwsgi

を書いておけばOKです。

UWSGIを構成する

今回は1つのuwsgiで1つのアプリケーションをホスティングします。実質、2つのuwsgiを立てます(http用,websoket用)。

1つのuwsgiで複数のWebアプリを管理する方法がまだわからない&dockerコンテナなら1コンテナ1役割のほうがいいだろうという考えです。

コンテナならこういうのを軽く実現できるから良いですね。

コンテナ起動時にuwsgi.iniを与えて、それに従って動作します。

たとえばwebsocket側のuwsgiの設定はこうなりました

[uwsgi]
wsgi-file = /var/www/src/uwsgi_test.py
callable = app
master = true
processes = 1
http=0.0.0.0:3032
vacuum = true
die-on-term = true
py-autoreload = 1
http-websockets = true
log-5xx = true
async = 100
ugreen = true

HTTP側はこんな感じです。

[uwsgi]
wsgi-file = /var/www/src/run.py
callable = app
master = true
processes = 1
http=0.0.0.0:3031
chmod-socket = 666
vacuum = true
die-on-term = true
py-autoreload = 1

注意点として、このdockerfileでインストールされるuwsgiはasyncとupgreenパラメータの設定が必要でした。 バージョンごとにasync対応かどうかみたいなのがあるみたいですね。

flask(HTTP)

単純な仕組みなのでコードだけ記載して説明は省略します。 hello worldを返すだけのアプリケーションです。

from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello World!"

flask(WebSocket)

こちらも単純な仕組みなのでコードだけ記載して説明は省略します。 これで一旦動くとこまで来ましたが、なんか違う気がしていてちょっと自信ないです。

import uwsgi
import time
import logging
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello():
    app.logger.setLevel(logging.INFO)
    app.logger.info('flask document root accessed')
    uwsgi.websocket_handshake()
    while True:
        msg = uwsgi.websocket_recv()
        app.logger.info('uwsgi.websocket_recv result = {}'.format(msg) )
        uwsgi.websocket_send(msg)
        app.logger.info('uwsgi.websocket_send finish.')

if __name__ == '__main__':
    app.run(debug=True)

docker-compose

ここまでに作成した3つのコンテナを立てたり落としたりするdocker-composeです。

今回は実験用なので、nginx, uwsgi(websocket), uwsgi(http)の3つのホストすべてを同じネットワークに接続しています。

各サービスでnetworksを記述することで、そこに接続されたホストと通信できるようなネットワーク構成ができます。

version: "2"
services:

  # HTTPのWebアプリケーション
  uwsgi:
    build: ./app
    volumes:
      - ./app:/var/www/
    networks:
      - python-website
    ports:
      - "3031:3031"
    environment:
      TZ: "Asia/Tokyo"

  # WebSocketのWebアプリケーション
  uwsgi-ws:
    build: ./appws
    volumes:
      - ./appws:/var/www/
    networks:
      - python-website
    ports:
      - "3032:3032"
    environment:
      TZ: "Asia/Tokyo"

  # WebFront
  nginx:
    build: ./nginx
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
    networks:
      - python-website
    ports:
      - "8080:8080"
      - "8081:8081"
    environment:
      TZ: "Asia/Tokyo"

networks:
  python-website:

client

WebSocketのサーバを叩くのはjsからやることにしました。ほとんど書いたことありませんが。

本当は使い慣れた言語(C++とか)から叩きたかったのですが、仕様とか調べるのが面倒だったので。

jsのコードはこちらのページを参考にさせていただきました。

https://qiita.com/hiroykam/items/c3e3d20c223b01d9f0e8

<!DOCTYPE HTML>
<html>
  <head>
    <script type="text/javascript">
      function WebSocketTest()
      {
        if ("WebSocket" in window) {
          alert("WebSocket is supported by your Browser!");
          var ws = new WebSocket("ws://localhost:8081/");
          ws.onopen = function() {
            ws.send("Hello from client");
            alert("Message is sent...");
          };
          ws.onmessage = function (evt) {
            var received_msg = evt.data;
            alert("Message is received...");
            alert(received_msg);
          };
          ws.onclose = function() {
            alert("Connection is closed...");
          };
        } else {
          alert("WebSocket NOT supported by your Browser!");
        }
      }
    </script>
  </head>
  <body>
    <input type="button" onClick="WebSocketTest();" value="WebSocket Test">
  </body>
</html>

動作確認

上記htmlを開いてボタンを押せば順にアラートが表示されます。

サーバ側のログを見たい場合はdocker-compose logsとかdocker log コンテナ名で確認できます。

websocket側のログにwebsocketで接続受けたとかメッセージ受けたとか残っています。

感想と今後の展望

ひとまずWebSocketの最低限の基盤が整いました。

今後試していきたい内容は

  • 具体的なデータ送受信(たとえばバイナリデータや、圧縮された映像、音声データのようなフレーム単位での時系列データなど)
  • ポートでHTTP/WebSocket分岐ではなく、flaskのルーティングで分岐したい
  • もう一歩進んでWebRTC

などでしょうか。

特にARでの利用を考えたときに物体検出などは時系列データである動画のほうが有利です。(空間方向だけでなく時間方向も推定する)

ただし、動画にしてしまうとエンコード時に多少なりとも情報が落ちる&処理の負荷があがるので、どうするのが最適かとか考えていきたいですね。

(よくある動画フォーマットならデコードせずに周波数空間で物体検出できるのでは?とか思っていますが、コード書くの面倒すぎるのでたぶんやらないと思います。)

サーバースペックとの兼ね合いもあると思うので、

それでは今回はこれくらいで。