Pub/Subを使ってWebSocketサーバをスケールアウトさせる(クラウド版)

前回の記事の続き。前回はオンプレ(自分が管理するVM)でWebSocketサーバをスケールアウトする方法を書いたが、本稿はクラウド(AWS)のマネージドサービスを使った方法について書く。

記事の内容

  • WebSocketを使ったWebアプリケーションをAWS上に構築する。
  • AMI作成にPackerを利用し、AWS構築にはTerraformを使う。

前提

  • AWSのルートアカウント持っている。
  • インターネットで名前解決できるドメインを持っていて、Route53で管理している。
    • ドメインはトップレベルドメイン(.comや.netとったドメインの末尾)にこだわらなければ、お名前.comムームードメインで安価に購入できる。

システムアーキテクチャ

ロードバランサーにはApplication Load Balancer (ALB) を利用する。
ALBの後段にはアプリケーションサーバのEC2を二つ配置する。ALBでSSL終端するため、ALB-EC2間はHTTP/WebSocketで通信する。
EC2間の情報連携用のPub/SubにはAmazon ElastiCache for Redisを利用する。
認証局により証明されたSSL証明書を発行するためRoute53を使ってDNS認証する。

システムアーキテクチャ

Packer用IAMユーザ作成

IAMグループ作成

  • グループ名に「packer」と記入し、「次のステップ」をクリック
  • 「AmazonEC2FullAccess」ポリシーを選択し、「次のステップ」をクリック
  • 「グループの作成」をクリック

IAMユーザ作成

  • ユーザー名に「packer」と記入し、アクセスの種類「プログラムによるアクセス」を選択し、「次のステップ:アクセス権限」をクリック
  • グループ「packer」を選択し、「次のステップ:タグ」をクリック
  • キー「Name」、値「packer」を記入し、「次のステップ:確認」をクリック
  • 「ユーザーの作成」をクリック
  • 「アクセスキーID」と「シークレットアクセスキー」をメモしておく。

PackerでEC2のマシンイメージ(AMI)作成

Packerインストール

以下は執筆時点のコマンド。最新のインストール手順についてはこちらを参照。

# curl -O https://releases.hashicorp.com/packer/1.4.2/packer_1.4.2_linux_amd64.zip
# unzip packer_1.4.2_linux_amd64.zip
# mv packer /usr/local/sbin/
# packer version
Packer v1.4.2

認証情報

認証情報を環境変数に書き込む。
IAMユーザ作成時にメモしたpackerユーザのアクセスキーIDとシークレットアクセスキーを環境変数に書き込む。

export AWS_ACCESS_KEY_ID=xxxx
export AWS_SECRET_ACCESS_KEY=xxxx

AMI構成情報を作成

以下のjsonファイルを作成する。
DockerとDocker Composeのインストール、ソースコードのダウンロード、Dockerイメージの作成をAMIの中で済ませておく。

{
    "variables": {
        "aws_access_key": "{{env `AWS_ACCESS_KEY_ID`}}",
        "aws_secret_key": "{{env `AWS_SECRET_ACCESS_KEY`}}",
        "region":         "ap-northeast-1"
    },
    "builders": [
        {
            "access_key": "{{user `aws_access_key`}}",
            "ami_name": "packer-linux-aws-demo-{{timestamp}}",
            "instance_type": "t2.micro",
            "region": "{{user `region`}}",
            "secret_key": "{{user `aws_secret_key`}}",
            "source_ami_filter": {
              "filters": {
                  "name": "amzn2-ami-hvm-*-x86_64-gp2"
              },
              "owners": ["137112412989"],
              "most_recent": true
            },
            "ssh_username": "ec2-user",
            "type": "amazon-ebs"
        }
    ],
    "provisioners": [{
        "type": "shell",
        "inline": [
            "sleep 30",
            "sudo yum -y install docker git",
            "sudo systemctl start docker",
            "sudo systemctl enable docker",
            "sudo curl -L \"https://github.com/docker/compose/releases/download/1.24.0/docker-compose-$(uname -s)-$(uname -m)\" -o /usr/local/bin/docker-compose",
            "sudo chmod +x /usr/local/bin/docker-compose",
            "git clone https://github.com/tetsis/simple-chat.git",
            "sudo cp -r simple-chat /root/",
            "sudo /usr/local/bin/docker-compose -f /root/simple-chat/docker-compose.yml build"
        ]
    }]
}

packer validate コマンドでjsonファイルの構文チェックをする。

# packer validate app-server.json
Template validated successfully.

Template validated successfully. が表示されればOK。

packer build コマンドでAMIを作成する。
最終行の ami-... をメモしておく。(ここでは xxxx としているが、実際は英数字の文字列)

# packer build app-server.json
...
==> Builds finished. The artifacts of successful builds are:
--> amazon-ebs: AMIs were created:
ap-northeast-1: ami-xxxx

EC2インスタンス用ロールを作成

EC2インスタンス内でawsコマンドを実行するため、ロールを作成する。

  • 「このロールを使用するサービスを選択」で「EC2」を選択し、「次のステップ:アクセス権限」をクリック
  • 「AmazonEC2ReadOnlyAccess」ポリシーを選択し、「次のステップ:タグ」をクリック
  • キー「Name」、値「ec2-role」を記入し、「次のステップ:確認」をクリック
  • ロール名「ec2-role」を記入し、「ロールの作成」をクリック

TerraformでAWSリソース作成

Terraform用IAMグループとIAMユーザ作成

Packer用にIAMグループとIAMユーザを作ったのと同様にTerraform用にも作成する。
作り方はPackerの時と同じなので詳細については省略する。
Packerとの違いはIAMグループにアタッチするポリシーで、今回の例では以下の5つのポリシーをアタッチする。

  • AmazonEC2FullAccess
  • AmazonElastiCacheFullAccess
  • AmazonRoute53FullAccess
  • AWSCertificateManagerFullAccess
  • IAMFullAccess

Terraformインストール

以下は執筆時点のコマンド。最新のインストール手順についてはこちらを参照。

# curl -O https://releases.hashicorp.com/terraform/0.12.5/terraform_0.12.3_linux_amd64.zip
# unzip terraform_0.12.3_linux_amd64.zip
# mv terraform /usr/local/sbin/
# terraform version
Terraform v0.12.3

AWSリソース作成

Terraformの.tfファイルはこちらを参照。
公開したくない情報は環境変数で設定してから terraform plan で正常性を確認して、 terraform apply でリソース作成を実行する。
(環境変数は ~/.bashrc 等に書いておいても良い)

# export TF_VAR_access_key=xxxx
# export TF_VAR_secret_key=xxxx
# export TF_VAR_allow_ip=x.x.x.x/32
# export TF_VAR_ami=ami-xxxx (Packerで作成したAMI)
# export TF_VAR_app_fqdn=simple-chat.xx.xx
# export TF_VAR_zone_id=xxxx (自分がホストしているドメインのZONE ID)
# cd /dir/to/terraform
# ls
aws_acm.tf  aws_alb.tf  aws_ec2.tf  aws_elasticache.tf  aws_r53.tf  aws_sg.tf  aws.tf  aws_tg.tf  aws_variables.tf  aws_vpc.tf terraform.tfvars
# terraform plan
...
Plan: 25 to add, 0 to change, 0 to destroy.
...
# terrafrom apply
...
  Enter a value: yes
...
(リソース作成。しばらく待つ)
Apply complete! Resources: 25 added, 0 changed, 0 destroyed. (「Apply complete!」が表示されたらリソース作成は正常に完了している。)

(参考)EC2初期処理用スクリプト

TerraformのEC2インスタンス作成ファイルで以下のような記述があるが、このuser_dataの作成方法について書いておく。

    user_data = <<EOF
IyE...
EOF

user_dataはEC2インスタンス起動時の処理をシェルスクリプト形式で書いたものをbase64エンコードしたものである。
シェルスクリプトの中身はEC2インスタンスを起動してからでないと決まらないパラメータをタグから取得してアプリケーションの設定ファイルに書き込む、という操作を行っている。
参考のためbase64変換前のシェルスクリプトを記載する。

# !/bin/sh

aws="/usr/bin/aws --region ap-northeast-1"
logger="logger -t $0"

get_instance_id()
{
    instance_id=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
}

get_redis_endpoint()
{
    redis_endpoint=$(${aws} ec2 describe-instances \
                  --instance-id ${instance_id} \
                  --query 'Reservations[].Instances[].Tags[?Key==`Redis_endpoint`].Value' \
                  --output text)
}

set_redis_endpoint()
{
    sed -i -e "s/REDIS_HOST.*$/REDIS_HOST: '${redis_endpoint}'/g" docker-compose.yml
}

${logger} "start $0"
sleep 60

${logger} "get info"
get_instance_id
get_redis_endpoint

${logger} "set environment"
cd /root/simple-chat
set_redis_endpoint

${logger} "run app"
docker-compose up -d

${logger} "finished $0"

exit 0

確認

Webブラウザで https://(環境変数TF_VAR_app_fqdnに設定したFQDN) にアクセスする。
simple-chatの画面が表示され、テキストボックスに入力した結果がテキストエリアに表示されたら正常に動作している。

後始末

検証が終わったら忘れずにリソースを削除する。

# terraform destroy
...
  Enter a value: yes
...

Pub/Subを使ってWebSocketサーバをスケールアウトさせる(オンプレ版)

スケーラブルなWebサービスのよくある設計はWebサーバの前段にロードバランサーを設置し後段にWebサーバを複数台配置するというものである。通常のWebサービスはステートレスなHTTP(もしくはHTTPS)通信のためこの方式で問題なく稼働する。
しかし、ステートフルなWebSocket通信においては以下の課題が存在する。

  1. ロードバランサーがWebSocket通信を判別しセッションを保持しているバックエンドサーバに転送できなければならない。
  2. 自分以外のWebSocketサーバが管理しているクライアントにはWebSocket通信を行うことができない。

そこで本稿では以下の方法でこの2つの課題の解決を試みる。

  1. WebSocket対応のロードバランサーを使う。
  2. Pub/Subを使う。全バックエンドサーバはsubscribeしておき、情報を送信したいサーバはpublishする。

この方法をオンプレミスで実現する方法を作ってみたので備忘録で残しておく。ソースコードはこちら
別記事でクラウド(AWS)で実装したときの記事も書く予定。

アプリの概要

今回は簡単なチャットアプリを作る。アプリの動作イメージは以下の通り。

  1. クライアントはWebブラウザを使ってアプリページを表示する。
  2. WebSocketでアプリと接続する。
  3. チャット入力欄に文章を入力し「Submit」ボタンを押すと、WebSocketを使ってアプリにメッセージを送信する。
  4. アプリはチャット画面を開いている(WebSocket接続を確立している)クライアントに向けて一斉にWebSocketでメッセージを送信。
  5. メッセージを受信したクライアントはメッセージ表示エリアにメッセージを表示する。
チャットアプリの動作イメージ

システムアーキテクチャ

ロードバランサーにはHAProxyを利用する。特別な設定をしなくてもWebSocketのセッションを認識してルーティングしてくれるみたい。(ちゃんと設定しようとするとこちらの記事にあるようにタイムアウトを個別に設定する必要がありそう。本稿ではデフォルト設定) また、SSLオフロードによりロードバランサーへのアクセスにはHTTPS/WebSocket over TLSを使用し、後段のサーバとはHTTP/WebSocketで通信する。
Pub/SubにはRedisを利用する。
アプリサーバにはWebSocketにも対応したTornadoを採用した。Webサーバプロセスはクライアントからメッセージを受信するとPub/Subにpublishする。また、Pub/Subをsubscribeするプロセスも動かしておいてsubscribeの結果をHTTP経由で自サーバのWebサーバプロセスに伝える。

システムアーキテクチャ図

インフラ設計

同一サブネットにロードバランサー、アプリケーションサーバ2台、Pub/Subサーバの合計4台のサーバを配置する。今回の検証ではサーバはVirtualBoxのVMを利用し、vagrantで管理している。ゲストOSはCentOS。

インフラ設計

実装

ロードバランサー (load-balancer)

HAProxyをインストール

yum -y install haproxy

/etc/haproxy/haproxy.cfg を編集

frontend ssl_proxy
    mode http
    bind *:443 ssl crt /etc/haproxy/server.pem
    server  app1 192.168.33.201:80 check
    server  app2 192.168.33.202:80 check

サーバ証明書 /etc/haproxy/server.pem を作成

普通は秘密鍵と証明書は別ファイルにすることが多いが、HAProxyでは一つのファイルにまとめる必要がある。
以下の例では秘密鍵(server.key)と自己証明書(server.crt)を作り、この2つのファイルをくっつけたserver.pemを作っている。

openssl genrsa 2048 > server.key
openssl req -new -key server.key > server.csr
openssl x509 -days 3650 -req -signkey server.key < server.csr > server.crt
cat server.key > /etc/haproxy/server.pem
cat server.crt >> /etc/haproxy/server.pem

サーバプロセス起動

systemctl start haproxy
systemctl enable haproxy

Pub/Subサーバ (pub-sub)

Redisをインストール

yum -y install epel-release
yum -y install redis

/etc/redis.conf を編集

bind 192.168.33.203

サーバプロセス起動

systemctl start redis
systemctl enable redis

アプリケーションサーバ (app1, app2)

まずはソースコードをダウンロード

git pull https://github.com/tetsis/simple-chat.git
cd simple-chat

Pythonを直接実行する場合

諸々インストール

yum install -y https://centos7.iuscommunity.org/ius-release.rpm
yum install python36u python36u-libs python36u-devel python36u-pip
pip3.6 install -r requirements.txt

サーバプロセス起動

python3.6 src/app.py | python3.6 src/subscribe.py

コンテナで動かす場合

諸々インストール

yum install -y yum-utils device-mapper-persistent-data lvm2
yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
yum -y install docker-ce docker-ce-cli containerd.io
systemctl start docker
systemctl enable docker
curl -L "https://github.com/docker/compose/releases/download/1.24.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose

サーバプロセス起動

docker-compose build --no-cache
docker-compose up -d

動作確認

2つのブラウザでhttps://192.168.33.200にアクセスするとチャット画面が表示される。(192.168.33.200はロードバランサーのIPアドレス)

片方のブラウザからメッセージを送信すると、両方のボックスにメッセージが表示される。(下図では左のブラウザで「Hello」と入力して「送信」ボタンを押すと、もう一つのブラウザにも「Hello」と表示される)

WebSocketを使ったtornadoサーバの前段にnginxリバースプロキシを置いてみた

nginxをリバースプロキシにして、バックエンドにtornadoで作成したWebSocketアプリケーションを動作させた時にハマりポイントがあったので備忘録として記録。

環境

  • CentOS 7.4
  • nginx 1.12.2
  • Python 3.6.5
  • tornado 5.0.2

check_origin関数が必要

結論から書くと、WebSocketHandlerクラスを継承したクラス内にcheck_origin関数を定義しないと動作しない。

class WebSocketHandler(tornado.websocket.WebSocketHandler):
def check_origin(self, origin):
return True

def open(self):
...

バージョン4.0から、この関数が必要になったみたい。

WebSocket connections from other origin sites are now rejected by default. To accept cross-origin websocket connections, override the new method WebSocketHandler.check_origin.(出典)

(和訳)Webページを取得したドメイン以外のサイトからのWebSocket接続は、デフォルトで拒否させるようになりました。異なるドメインのWebページからWebSocket接続を受け付けるにはcheck_origin関数をオーバーライドします。

check_origin関数がないと403エラーになる。

WARNING:tornado.access:403 GET /ws (::1) 0.51ms

セキュリティに注意

check_originをオーバーライドすることで、セキュリティ上のリスクを抱えることになる。そのため、オーバーライドする際には自身で対策を実装する必要がある。

This is an important security measure; don’t disable it without understanding the security implications. In particular, if your authentication is cookie-based, you must either restrict the origins allowed by check_origin() or implement your own XSRF-like protection for websocket connections. See these articles for more.(出典)

(和訳)これは重要なセキュリティ対策です。 セキュリティの意味を理解することなく無効にしないでください。 特に、認証がCookieベースの場合は、check_origin関数によって許可されたドメインを制限するか、websocket接続に対して独自のXSRFのような保護を実装する必要があります。 詳細については、これらの記事を参照してください。

実装例1: サブドメインは許可(公式サイトより

def check_origin(self, origin):
parsed_origin = urllib.parse.urlparse(origin)
return parsed_origin.netloc.endswith(".mydomain.com")

実装例2: 指定したドメインのみ許可

def check_origin(self, origin):
parsed_origin = urllib.parse.urlparse(origin)
return parsed_origin.netloc == "mydomain.com"

(おまけ)nginxの設定ファイル

とくになんの変哲もない普通のWebSocketプロキシ設定。

server {

(中略)

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_max_temp_file_size 0;

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

location / {
proxy_pass http://localhost:8080;
}
}