Redash の複数台構成化その3(worker を別インスタンスにする)

以下の記事の続きです。

ariarijp.hatenablog.com

前置き

前の記事の手順を実行し、Redash と PostgreSQL、Redis をそれぞれ別インスタンスで動作させる環境ができていることを前提とします。

worker を別インスタンスにする

worker のインスタンスをセットアップする

worker インスタンスは server インスタンスと同様、Redash の Ubuntu 用 bootstrap スクリプトで Redash を導入した状態になっているものとし、PostgreSQL、Redis インスタンスにそれぞれ接続できるようになっていることを前提とします

  • プライベート IP 192.168.33.11

Redash のバージョンについて

worker を別インスタンス化するには、server と同じバージョンの Redash をインストールする必要があります。

初期構築時に server と worker を分ける場合はそれほど問題にならないと思いますが、ある程度一台構成で Redash を稼働させた状態から複数台にする場合は、最新バージョンとの差異が問題になることもあります。

その場合、既存環境を最新にアップグレードしてから複数台構成にするなどの対応が必要となりますが、この記事ではアップグレード手順などについては割愛します。

不要なサービスを停止する

nginx, PosgreSQL, Redis は使用しないので、以下のコマンドで停止し、自動起動も無効にします。

$ sudo systemctl stop postgresql && sudo systemctl disable postgresql
$ sudo systemctl stop redis && sudo systemctl disable redis
$ sudo systemctl stop nginx && sudo systemctl disable nginx

Redash の設定を変更する

server インスタンスと同様に、worker インスタンス/opt/redash/.envREDASH_REDIS_URLREDASH_DATABASE_URL を以下のように変更します。

export REDASH_REDIS_URL=redis://192.168.33.101:6379/0
export REDASH_DATABASE_URL="postgresql://redash:redash@192.168.33.100/redash"

supervisor の設定を変更する

最後に、supervisor の設定を変更します。

worker インスタンスでは、server を起動する必要がないので、 /etc/supervisor/conf.d/redash.confredash_server の設定の中の autostartautorestart をそれぞれ false にします。

[program:redash_server]
command=/opt/redash/current/bin/run gunicorn -b 127.0.0.1:5000 --name redash -w 4 --max-requests 1000 redash.wsgi:app
directory=/opt/redash/current
process_name=redash_server
user=redash
numprocs=1
autostart=false
autorestart=false

/etc/supervisor/conf.d/redash.conf を変更したら、Redash と supervisor の設定を適用するため、supervisord を再起動します。

$ sudo systemctl restart supervisor

再起動を完了したら、 ps コマンドなどで Redash 関連のプロセスが Celery のみ起動されていることを確認しましょう。

これで worker の設定は完了です。

server のインスタンスの設定を変更する

worker インスタンスCelery が動作するようになったので、server インスタンスでは Celery を動作させないようにします。

/etc/supervisor/conf.d/redash.confredash_celeryredash_celery_scheduled の設定の中の autostartautorestart をそれぞれ false にします。 、

[program:redash_celery]
command=/opt/redash/current/bin/run celery worker --app=redash.worker --beat -c2 -Qqueries,celery --maxtasksperchild=10 -Ofair
directory=/opt/redash/current
process_name=redash_celery
user=redash
numprocs=1
autostart=false
autorestart=false

[program:redash_celery_scheduled]
command=/opt/redash/current/bin/run celery worker --app=redash.worker -c2 -Qscheduled_queries --maxtasksperchild=10 -Ofair
directory=/opt/redash/current
process_name=redash_celery_scheduled
user=redash
numprocs=1
autostart=false
autorestart=false

/etc/supervisor/conf.d/redash.conf を変更したら、設定を適用するため、supervisord を再起動します。

再起動を完了したら、 ps コマンドなどで Celery のプロセスが起動されていないことを確認しましょう。

動作確認

ここまでの設定が完了したら、 Redash の画面上でクエリを実行してみます。

クエリが実行できれば、worker の別インスタンス化は完了です。

まとめ

Redash の worker プロセスを別インスタンスで実行する方法を紹介しました。

Redash のミドルウェア構成を把握することができればそれほど難しくない対応ですが、大規模なデータを扱いたい場合や、クエリのキューがつまりがちな環境では、今回紹介したような方法で worker を別インスタンス化することで問題を解消できることがあると思います。

しかし、サーバ管理の手間は増えるため、ひとつのインスタンスに全部のせ担っている標準的な構成でスケールアップやミドルウェアや Redash のチューニングに限界を感じた場合の選択肢の一つ。として考えるぐらいが個人的には良いと思います。

次回は、この記事から少し派生した設定として、 redash_celery_scheduled のプロセスをさらに別インスタンスで起動し、詰まりがちなスケジュール実行をスケールアウトする方法を試してみる予定です。

Redash の複数台構成化その2(Redis を別インスタンスにする)

以下の記事の続きです。

ariarijp.hatenablog.com

前置き

前の記事で Redash と PostgreSQL を別インスタンスで動作させる環境ができていることを前提とします。

Redis を別インスタンスにする

PostgreSQL に比べ、Redis を別インスタンスにすることは性能に大きく影響を与えるものではないと思いますが、Redash に全部入り状態になっているミドルウェアから別インスタンスに切り出しやすいこともあり、この記事では Redis を別インスタンスにする方法を紹介します。

Redis のインスタンスを Redash インスタンスから参照できるネットワーク内に配置し、Redash の設定を変えていきます。

Redis インスタンスの設定は割愛しますが、Redash インスタンスからアクセスできるようになっていることを前提とします。

  • プライベート IP 192.168.33.101

Redis のバージョンは デフォルトの Ubuntu 16.04 でインストールできる 3.0.6 としています。

設定を書き換える

/opt/redash/.env で定義されている REDASH_REDIS_URL を、以下のように書き換えます。

export REDASH_REDIS_URL=redis://192.168.33.101:6379/0

これで Redis が準備できました。

Redash を再起動して、Redash にアクセスし、何かクエリを実行してみましょう。

クエリが実行できたら、Redash インスタンス上の Redis は停止しても問題ありません。

既存環境を使用する場合の注意点

既存の Redash の Redis を別インスタンスにする場合は、実行中またはキューに入っているクエリが実行されない、正常終了しないなどの問題が起きる可能性が考えられます。

作業前にキューに何も入っていない、または再実行などで問題を回避できることを確認したうえで対応するのがよいでしょう。

まとめ

簡単でしたが、Redash で使用する Redis を別インスタンスにする手順を紹介しました。

次回は Redash の worker プロセスを別インスタンスで実行する方法を紹介する予定です。

Redash の複数台構成化その1(PostgreSQL を別インスタンスにする)

Redash はある程度スペックに余裕を持たせておけば、気を使わなくても1インスタンスで十分運用できるのが良いところではありますが、大量のデータを扱ったり、大量のクエリ実行を受け付けたりするような場合は、複数台での運用を考えたくなる時もあるでしょう。

この記事では、Redash の複数台構成について考えてみます。

前置き

この記事で紹介する方法は私個人の意見や考えに基づいて書いています。

業務上実績があったり、Redash が公式にこのような構成を推奨しているというものではないので、その点を踏まえてお読みください。

また、Redash を構成するミドルウェアなどについての説明は割愛します。

Redash の構成については、先日 Redash Meetup #2 で発表したスライドにも一部記載がありますので、必要に応じで参照してください。

speakerdeck.com

環境

この記事では Vagrant で構築した VM で動作検証しています。

  • Ubuntu 16.04
  • Redash 4.0.1
  • プライベート IP 192.168.33.10

また、この VM は新規に Redash をインストールしているため、既存環境からの移行については考慮していません(既存環境からの移行の場合に影響がありそうなところは適宜コメントをいれています)

PostgreSQL を別インスタンスにする

Redash を1台で運用する場合、クエリ結果を読み書きするため PostgreSQL の負荷が高くなりがちなので、PostgreSQL を別のインスタンスにすることで負荷分散が期待できるでしょう。

PostgreSQLインスタンスを Redash インスタンスから参照できるネットワーク内に配置し、Redash の設定を変えていきます。

PostgreSQL インスタンスの設定は割愛しますが、Redash インスタンスからアクセスできるようになっていることを前提とします。

  • プライベート IP 192.168.33.100
  • ユーザ名: redash
  • パスワード: redash
  • データベース名: redash

PostgreSQL のバージョンは デフォルトの Ubuntu 16.04 でインストールできる 9.5.13 としています。

設定を書き換える

/opt/redash/.env で定義されている REDASH_DATABASE_URL を、以下のように書き換えます。

export REDASH_DATABASE_URL="postgresql://redash:redash@192.168.33.100/redash"

テーブルを生成する

以下のコマンドで、 PostgreSQL インスタンス上にテーブルを作成します。

ubuntu@ubuntu-xenial:~/$ cd /opt/redash/current
ubuntu@ubuntu-xenial:/opt/redash/current$ ./bin/run ./manage.py database create_tables

これで Redash に必要なデータベースが準備できました。

Redash を再起動して、Redash にブラウザからアクセスすると、セットアップ画面が表示されるはずです。

セットアップが完了し、Redash にログインできるようになったら、Redash インスタンス上の PostgreSQL は停止しても問題ありません。

既存環境を使用する場合の注意点

既存の Redash の PostgreSQL を別インスタンスにする場合は、上記の手順で作業してしまうと、クエリなどをイチから作り直しになってしまいます。

この記事では紹介しませんが、既存環境を使用する場合はテーブル生成の代わりに、既存環境からのダンプ、PostgreSQL インスタンスでのリストアが必要になるため注意してください。

まとめ

Redash で使用する PostgreSQL を別インスタンスにする手順を紹介しました。

次回は Redis を別インスタンスにする手順を紹介します。

Redash のクエリ定期実行はどのように実現されているのか

内容は本記事を書いた2018/06/03時点の理解なので、間違っていたら直す。

対象のバージョン

Redash 4.0.1 c86423a で確認した。

ざっくり言うと

30秒ごとにクエリの最終取得日時と定期実行の設定を比較し、再実行が必要なものを scheuled_queries キューで実行する。

クエリ定期実行の流れ

  • 30秒に1度、redash.tasks.queries.refresh_queries が実行される
    • celery beat を使って定期実行されている
    • デフォルトでは定期実行の最小値が毎時1分になっているのは、celery beat が30秒に1度動くためかもしれない
      • 「30秒」というところは今のところベタ書きされている
  • 各クエリの retrieved_at や、そのクエリのスケジュール実行設定をみて、再実行が必要かどうかを判断する
    • FEATURE_DISABLE_REFRESH_QUERIES が有効になっているとスケジュール実行はすべて無視される
  • クエリパラメータを使用している場合は、 queries テーブルに保存されているデフォルトのパラメータをクエリにバインドする。クエリパラメータを使用していない場合クエリ文字列を取得する
  • キューにタスクを追加する
    • Redash では画面からのクエリ実行と定期実行のキューが別れていて、定期実行は scheuled_queries キューを使用する
  • 全クエリに対して上記の処理をする
  • 再実行対象数と、再実行されたクエリIDのリスト、実行日時を Redis の redash:status にハッシュとして書き込む
    • Redash のステータスページでみられる項目の一部はこの情報を使っているはず(そこまでは追っていない)

まとめ

Redis を FLUSHALL しても、定期実行が動き続ける理由が知れてよかった。

Celery を試してみる

Redash を介してお世話になることはあれど、単体で使ったことがなかったので改めて Celery を試してみる。

Homepage | Celery: Distributed Task Queue

Python のバージョンは 3.6.5。Celery のバージョンは 4.1.1 を使用した。

Celery とは

Celery: Distributed Task Queue

分散タスクキュー。とのこと。

Celery で使えるメッセージブローカ

Brokers — Celery 4.1.0 documentation

RabbitMQ, Redis, Amazon SQS あたりが使えるとのこと。今回は Redash の理解を深めることも目的のひとつなので、Redis を使うことにする。

結果を取得するための Result Backend

First Steps with Celery — Celery 4.1.0 documentation

実行されたタスクの結果を利用したい場合、結果の保存には Result Backend という仕組みをつかうらしい。

保存先は SQLAlchemy, Memcached, Redis などが使用できるようだが、今回は Result Backend も Redash にあわせて Redis を使う。

試してみる

worker 上で動作するタスクの実装

非同期で動くことを確認するため、与えられたメッセージ文字列を0-2秒待ったあとに、そのまま結果として返す簡単なタスクを用意した。

from celery import Celery
from time import sleep
from random import randint

app = Celery('tasks', backend='redis://localhost', broker='redis://localhost')

@app.task
def hello(m):
    sleep(randint(0, 2))
    return m

非同期タスクを呼び出すアプリケーションの実装

FizzBuzz ループの中で 3 Fizz のような文字列を先程のタスクで実行する。

すべて非同期実行をしたあとに、処理結果を確認、表示する。

from collections import deque

import tasks

results = deque([])

for i in range(1, 42):
    if i % 15 == 0:
        results.append(tasks.hello.delay('{} FizzBuzz'.format(i)))
    elif i % 3 == 0:
        results.append(tasks.hello.delay('{} Fizz'.format(i)))
    elif i % 5 == 0:
        results.append(tasks.hello.delay('{} Buzz'.format(i)))
    else:
        results.append(tasks.hello.delay('{}'.format(i)))

print('すべてのタスクがキューに入りました')

while len(results) > 0:
    result = results.popleft()
    if result.ready():
        print(result.get())
        continue

    results.append(result)

実行してみる

まずは worker を起動する。

$ celery --app=tasks worker --loglevel=info -c 2

--app で非同期実行されるタスクが書かれたスクリプト名を渡し、 --loglevel は文字通りログレベル、 -c で並列実行のための子プロセス数を指定する。

次に、アプリケーションを実行する。

これはただの Python スクリプトなので、 python コマンドで実行するだけ。

$ python main.py
すべてのタスクがキューに入りました
1
2
4
3 Fizz
5 Buzz

...省略...

39 Fizz
40 Buzz
41

実行すると、「すべてのタスクがキューに入りました」というメッセージのあとに、1から41までの数字と、条件にあった数値は「Fizz」などの文字列を付加した結果が表示される。

上の例では、 2 の次の結果が 4 になっているが、これはタスク側でランダムに待ちをいれているためなので問題なし。

一方、アプリケーション実行時の worker 側の出力はこんな感じ。

[2018-05-28 20:08:15,640: INFO/MainProcess] Received task: tasks.hello[cbf77878-c2b4-48ec-8214-491155479188]
[2018-05-28 20:08:15,643: INFO/MainProcess] Received task: tasks.hello[f1808ca7-f663-4c9e-8343-be5581b464da]
[2018-05-28 20:08:15,647: INFO/MainProcess] Received task: tasks.hello[74ade9ad-8136-4ed9-8ee1-029cb9a003a9]
[2018-05-28 20:08:15,652: INFO/MainProcess] Received task: tasks.hello[02925313-3346-41a8-bab8-9b4506151fc4]
[2018-05-28 20:08:15,657: INFO/MainProcess] Received task: tasks.hello[e0558aef-3206-4feb-a09e-97bc48e9496d]
[2018-05-28 20:08:15,661: INFO/MainProcess] Received task: tasks.hello[3dae0719-daa8-4c42-b703-1a2bf7293bae]
[2018-05-28 20:08:15,667: INFO/MainProcess] Received task: tasks.hello[94a9a252-2794-413f-8aac-fd781a893cab]
[2018-05-28 20:08:15,674: INFO/MainProcess] Received task: tasks.hello[84d9a82a-571a-477b-8911-4010083cc0b6]

...省略...

[2018-05-28 20:08:35,937: INFO/ForkPoolWorker-2] Task tasks.hello[7c86fa11-a1c4-4261-900a-bcd6f261d76d] succeeded in 0.0011464759991213214s: '36 Fizz'
[2018-05-28 20:08:36,940: INFO/ForkPoolWorker-2] Task tasks.hello[8f6658a9-2fcc-47a7-98e8-cae9a8096546] succeeded in 1.0012103780027246s: '37'
[2018-05-28 20:08:37,813: INFO/ForkPoolWorker-1] Task tasks.hello[af3513f5-c6af-4e64-b324-7c4b1f8634bc] succeeded in 2.0011870139969687s: '35 Buzz'
[2018-05-28 20:08:37,945: INFO/ForkPoolWorker-2] Task tasks.hello[38ecd7d3-2897-43ba-8e0c-1f36f24d7acb] succeeded in 1.0030830889991194s: '38'
[2018-05-28 20:08:38,836: INFO/ForkPoolWorker-1] Task tasks.hello[f58921ba-5322-419a-b3d9-5d6f6d27e2ba] succeeded in 1.0212826329989184s: '39 Fizz'
[2018-05-28 20:08:38,947: INFO/ForkPoolWorker-2] Task tasks.hello[1fff8e54-ded8-4a85-86f5-9f68fd9f2aa5] succeeded in 1.0010614519997034s: '40 Buzz'
[2018-05-28 20:08:40,861: INFO/ForkPoolWorker-1] Task tasks.hello[2ac6cfeb-e204-427b-9ea6-7ddbd6fcfc6f] succeeded in 2.0224582139999256s: '41'

キューにタスクが積まれて、その後徐々に処理されていく様子がわからなくもない。

タスクには一意の ID が振られていて、ぱっと見で UUID っぽく見える。

worker を2つにしてみる

コードは変えず、 Celery の worker プロセスをもう一つ起動してみる。

$ celery --app=tasks worker --loglevel=info -c 2

この状態でアプリケーションを実行すると。

$ python main.py
すべてのタスクがキューに入りました
3 Fizz
1
2
4
5 Buzz

...省略...

31
36 Fizz
38
34
40 Buzz

worker が増えたため、先程の実行結果よりさらに処理順序にばらつきが出ている。

worker 側の出力は割愛するが、 どちらの worker でも実行ログが出ているので、おそらく均一に処理されているものと思われる。

まとめ

タスクキューってだいたいこんな感じだよね。という使い心地だった。

Redash のコードを追う際の基礎知識として、触ってみてよかったと思う。

今度は Redash のクエリワーカを複数にしても問題なく動作するのか?という検証をしてみるつもり。

余談: Celery のタスクはどのような形で メッセージブローカ(Redis)に保存されているのか?

気になったので redis-cli で見てみる。

$ redis-cli
127.0.0.1:6379> keys *

...省略...

231) "celery-task-meta-6dae9f06-bfb5-497a-8926-2b03dfb8a1a3"
232) "celery"
233) "celery-task-meta-68ab5751-0fa7-45d6-9c97-9aee871b410b"

...省略...

celery というキーがあったので型を確認したら list 型だった。

127.0.0.1:6379> type celery
list

1件取り出してみる。

127.0.0.1:6379> LRANGE celery 0 0
1) "{\"body\": \"W1siNDEiXSwge30sIHsiY2FsbGJhY2tzIjogbnVsbCwgImVycmJhY2tzIjogbnVsbCwgImNoYWluIjogbnVsbCwgImNob3JkIjogbnVsbH1d\", \"content-encoding\": \"utf-8\", \"content-type\": \"application/json\", \"headers\": {\"lang\": \"py\", \"task\": \"tasks.hello\", \"id\": \"b69ad207-308f-4c73-988a-8321a640dbdc\", \"eta\": null, \"expires\": null, \"group\": null, \"retries\": 0, \"timelimit\": [null, null], \"root_id\": \"b69ad207-308f-4c73-988a-8321a640dbdc\", \"parent_id\": null, \"argsrepr\": \"('41',)\", \"kwargsrepr\": \"{}\", \"origin\": \"gen14561@ariarijp.local\"}, \"properties\": {\"correlation_id\": \"b69ad207-308f-4c73-988a-8321a640dbdc\", \"reply_to\": \"e3c01706-26cb-3330-98aa-b5551bd6e56b\", \"delivery_mode\": 2, \"delivery_info\": {\"exchange\": \"\", \"routing_key\": \"celery\"}, \"priority\": 0, \"body_encoding\": \"base64\", \"delivery_tag\": \"0c5d61f8-ae4a-492e-904f-cbd4b551c3ca\"}}"

JSON。ちょっと見にくいので整形するとこんな感じ。

{
    "body": "W1siNDEiXSwge30sIHsiY2FsbGJhY2tzIjogbnVsbCwgImVycmJhY2tzIjogbnVsbCwgImNoYWluIjogbnVsbCwgImNob3JkIjogbnVsbH1d",
    "content-encoding": "utf-8",
    "content-type": "application/json",
    "headers": {
        "lang": "py",
        "task": "tasks.hello",
        "id": "b69ad207-308f-4c73-988a-8321a640dbdc",
        "eta": null,
        "expires": null,
        "group": null,
        "retries": 0,
        "timelimit": [
            null,
            null
        ],
        "root_id": "b69ad207-308f-4c73-988a-8321a640dbdc",
        "parent_id": null,
        "argsrepr": "('41',)",
        "kwargsrepr": "{}",
        "origin": "gen14561@ariarijp.local"
    },
    "properties": {
        "correlation_id": "b69ad207-308f-4c73-988a-8321a640dbdc",
        "reply_to": "e3c01706-26cb-3330-98aa-b5551bd6e56b",
        "delivery_mode": 2,
        "delivery_info": {
            "exchange": "",
            "routing_key": "celery"
        },
        "priority": 0,
        "body_encoding": "base64",
        "delivery_tag": "0c5d61f8-ae4a-492e-904f-cbd4b551c3ca"
    }
}

body の中身は Base64 エンコードしてるように見えるのでデコードしてみた。

[["41"], {}, {"callbacks": null, "errbacks": null, "chain": null, "chord": null}]

celery-task-meta- * みたいなのも気になるので見てみる。

127.0.0.1:6379> type celery-task-meta-e6f5f414-1d8f-42c2-b614-75f9615ec61f
string

文字列型。JSON かな。

127.0.0.1:6379> get celery-task-meta-e6f5f414-1d8f-42c2-b614-75f9615ec61f
"{\"status\": \"SUCCESS\", \"result\": \"41\", \"traceback\": null, \"children\": [], \"task_id\": \"e6f5f414-1d8f-42c2-b614-75f9615ec61f\"}"

JSON だった。Result Backend を使用すると、きっと celery-task-meta-* に結果を書き出すのだろう。

例外を起こしたらどうなるのか?

気になったので、例外が起きるように書き換え、その結果を見てみた。

127.0.0.1:6379> get celery-task-meta-d5024461-52b3-429b-ab71-57c8e76522b3
"{\"status\": \"FAILURE\", \"result\": {\"exc_type\": \"Exception\", \"exc_message\": \"\"}, \"traceback\": \"Traceback (most recent call last):\\n  File \\\"/Users/ariarijp/.local/share/virtualenvs/celerytutorial-nzo1Q02k/lib/python3.6/site-packages/celery/app/trace.py\\\", line 375, in trace_task\\n    R = retval = fun(*args, **kwargs)\\n  File \\\"/Users/ariarijp/.local/share/virtualenvs/celerytutorial-nzo1Q02k/lib/python3.6/site-packages/celery/app/trace.py\\\", line 632, in __protected_call__\\n    return self.run(*args, **kwargs)\\n  File \\\"/Users/ariarijp/workspace/python/celerytutorial/tasks.py\\\", line 12, in hello\\n    raise Exception()\\nException\\n\", \"children\": [], \"task_id\": \"d5024461-52b3-429b-ab71-57c8e76522b3\"}"

ちょっと長いので整形。

{
    "status": "FAILURE",
    "result": {
        "exc_type": "Exception",
        "exc_message": ""
    },
    "traceback": "Traceback (most recent call last):\\n  File \\"/Users/ariarijp/.local/share/virtualenvs/celerytutorial-nzo1Q02k/lib/python3.6/site-packages/celery/app/trace.py\\", line 375, in trace_task\\n    R = retval = fun(*args, **kwargs)\\n  File \\"/Users/ariarijp/.local/share/virtualenvs/celerytutorial-nzo1Q02k/lib/python3.6/site-packages/celery/app/trace.py\\", line 632, in __protected_call__\\n    return self.run(*args, **kwargs)\\n  File \\"/Users/ariarijp/workspace/python/celerytutorial/tasks.py\\", line 12, in hello\\n    raise Exception()\\nException\\n",
    "children": [],
    "task_id": "d5024461-52b3-429b-ab71-57c8e76522b3"
}

ステータスが FAILURE になり、結果に例外の種類やトレースバックが含まれるようになった。

この値を使って結果を受け取るときに例外を発生させている様子。

ここのコードを追っていったら、Python で Promise を実現するための vine というモジュールがあることを知った。Celery プロジェクトの一部っぽい。

github.com