2019年5月18日土曜日

Sinatra + Redis で簡単な like ボタンを作ってみた

概要

Sinatra + Redis を使って簡単な like ボタンを実装してみました
Redis にデータを格納しているため like 数を永続化しています
今回はコーディングの方法を 1 から紹介したいと思います

環境

  • macOS 10.14.4
  • Ruby 2.6.2p47
  • Redis 4.0.9
  • Sinatra 2.0.5

環境構築

まず開発するための環境を準備します
必要な gem のインストールや redis の起動を行います

  • bundle init
  • vim Gemfile
gem "sinatra"
gem "redis"
  • bundle install --path vendor

でライブラリのインストールは OK です
あとはアプリに必要なファイルを作成します

  • touch app.rb
  • touch config.ru

この 2 つはサーバ側のコードを書くファイルになります
この 2 つは Ruby で実装します

  • mkdir views
  • mkdir public
  • touch views/index.erb
  • touch public/custom.css
  • touch public/custom.js

WebUI を構成するファイルになります
HTML や CSS, JavaScript を書きます
これで今回必要なファイルの準備が整いました
あとは redis を localhost で起動しておきましょう

  • redis-server

とりあえず like ボタンを設置する

まずはとりあえず like ボタンを設置してみましょう
今回はボタンのデザインに FontAwesome を使います

  • vim views/index.erb
<html>
<head>
  <link rel="stylesheet" href="/custom.css">
  <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.2/css/all.css" integrity="sha384-oS3vJWv+0UjzBfQzYUhtDYW+Pj2yciDJxpsK1OYPAYjqT085Qq/1cq5FLXAZQ7Ay" crossorigin="anonymous">
</head>
<body>
<button id="like">
  <i class="fas fa-heart fa-2x"></i>
</button>
<span id="like_count"><%= @like %></span>
</body>
</html>

FontAwesome を使うのに必要な CSS ファイルの読み込みとボタンおよびカウントの設置をしています
@like は Ruby 側から受け取った変数を展開するための erb の機能です

  • vim public/custom.css
#like_count {
    margin-left: 10px;
}

#like {
    border: none;
}

適当にスタイルシートを当てています
この辺りは好きなようにカスタムしてもらって大丈夫です

  • vim app.rb
require 'sinatra'
require 'redis'

class LikeApp < Sinatra::Base
  redis = Redis.new

  get '/' do
    @like = redis.get('like') || 0
    erb :index
  end
end

サーバ側のコードです
先程作成した index.erb を返すだけのコードになります
Redis にアクセスするコードも含めています
like というキーの値を取得しています
もしまだ値が設定されていない場合は 0 を設定します

  • vim config.ru
require './app.rb'
run LikeApp

あとはアプリケーションを Rack で起動するための起動スクリプトを書けば OK です
作成した app.rb を読み込んで Rack のコマンドの run を記載しているだけです

  • bundle exec rackup config.ru

これで localhost:9292 にアクセスすると以下のように like ボタンが表示されると思います

当然ですがクリックしてもまだ何も起きません

ボタンをクリックしたときのクライアント側の処理

次にボタンを押したときにサーバ側にリクエストするクライアント側のコードを作成しましょう
具体的には JavaScript を書きます

  • vim public/custom.js
$('#like').click(function() {
  $.ajax({
    url: '/like',
    type: 'POST'
  })
  .done((data) => {
    $('#like_count').text(data)
  })
});

DOM の操作には jQuery を使っています
button タグには id="like" という ID を振りました
それが .click() された場合にこの関数が呼ばれます
クリックされた場合にはサーバ側に Ajax を使ってリクエストします
具体的には POST /like をリクエストします
この POST /like のサーバ側の処理はこの後実装します
あとはレスポンスを受け取ったあとの処理を .done() 内に記載します
サーバからは like された後の like 数が返ってくる想定なので、それを DOM に表示してあげる処理を実装しています
Ajax では他にもエラー時の処理を .fail(), 常に行う処理を .always() として実装することができます
今回はそれらは特に使っていませんが例えば .fail() ではエラーの原因などの文章を DOM に表示してあげたりすると良いでしょう

  • vim views/index.erb
<html>
<head>
  <link rel="stylesheet" href="/custom.css">
  <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.2/css/all.css" integrity="sha384-oS3vJWv+0UjzBfQzYUhtDYW+Pj2yciDJxpsK1OYPAYjqT085Qq/1cq5FLXAZQ7Ay" crossorigin="anonymous">
  <script type="text/javascript" src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
</head>
<body>
<button id="like">
  <i class="fas fa-heart fa-2x"></i>
</button>
<span id="like_count"><%= @like %></span>
<script type="text/javascript" src="/custom.js"></script>
</body>
</html>

作成した JavaScript を HTML 内で読み込む処理を追記しましょう
具体的には <script> タグを 2 箇所追記しています
ポイントは自分で作成した custom.js は body タグの直前に記載している点です
HTML が描画される前に jQuery を使った JavaScript がロードされると対象の DOM がまだない状態でロードされてしまうためうまく動作しません
なので DOM がすべて描画されたあとで JavaScript をロードしてあげましょう
もしくは JavaScript の実装を $(function() { ... }) で囲ってあげれば ready 後にロードされるのでそれでも OK です

これで再度アプリを起動しましょう
特に見た目は変わっていません
またサーバ側を実装していないので動きはしませんがコンソールを見ると 404 が発生しサーバ側にリクエストしようとしていることがわかると思います

ボタンをクリックしたときのサーバ側の処理

さてではメインのサーバ側の処理を実装します
やることは以下の通り

  • リクエストを受け取ったら現在の like 数に +1 して Redis に格納する
  • レスポンスには +1 後の like 数を返却してあげる

これを踏まえたサーバ側のコードは以下のようになります

  • vim app.rb
require 'sinatra'
require 'redis'

class LikeApp < Sinatra::Base
  redis = Redis.new

  get '/' do
    @like = redis.get('like') || 0
    erb :index
  end

  post '/like' do
    like = redis.get('like')
    if like.nil?
      like = 1
    else
      like = like.to_i + 1
    end
    redis.set('like', like)
    like.to_s
  end
end

post /like do ... end の部分を追記しています
redis.get した値は必ず String になっているので計算する際には .to_i メソッドを呼び出して Integer にしてあげましょう
また Sinatra はメソッドの最後に評価した値をレスポンスボディ (またはレスポンスコード) として返却します
文字列などの場合はレスポンスボディとして返却し数字の場合にはレスポンスボディが空で指定されたレスポンスコードで返却します
今回はレスポンスコードが 200 でレスポンスボディが +1 後の like 数なので like 数を文字列に変換することで、それをレスポンスボディとして返却しています

これで JavaScript 側で実装した .done() の data に like.to_s の値が返ってくるようになります

  • bundle exec rackup config.ru

これで正常に動作します
like ボタンを押すとカウントアップするのが確認できると思います

アニメーションを入れる

これだけだと少しさびしいのクリックした際にアニメーションを入れてみましょう
クリックしたことがわかれば何でもいいので好きなアニメーションを入れれば OK です
今回は Animate.css を使ってみます

  • vim views/index.erb
<html>
<head>
  <link rel="stylesheet" href="/custom.css">
  <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.2/css/all.css" integrity="sha384-oS3vJWv+0UjzBfQzYUhtDYW+Pj2yciDJxpsK1OYPAYjqT085Qq/1cq5FLXAZQ7Ay" crossorigin="anonymous">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.5.2/animate.min.css">
  <script type="text/javascript" src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
</head>
<body>
<div id="section">
  <button id="like">
    <i class="fas fa-heart fa-2x"></i>
  </button>
</div>
<span id="like_count"><%= @like %></span>
<script type="text/javascript" src="/custom.js"></script>
</body>
</html>

animate.min.css を head タグ内で読み込んでいるのとアニメーションさせるためにボタンを <div id="section"> で囲んでいます
animate.css の仕様ですがブロック要素でないとアニメーションしないため div で囲っています
なお div などのブロック要素で囲っても display: inline などにするとインライン要素になりアニメーションしないので注意してください

  • vim public/custom.css
#like_count {
    float: left;
    margin-left: 10px;
}

#like {
    border: none;
}

#section {
    float: left;
}

ボタンとカウンタが横並びになるようにしています

  • vim public/custom.js
$('#like').click(function() {
  $('#section').addClass('animated bounce')
  $('#section').on('animationend', function() {
    $('#section').removeClass('animated bounce')
  })
  $.ajax({
    url: '/like',
    type: 'POST'
  })
  .done((data) => {
    $('#like_count').text(data)
  })
});

あとはボタンが押されたさいにアニメーションするための animate.css のクラスを追加してあげるだけです
jQuery を使ってクラスの追加や削除を行うのは addClass/removeClass を使います
また animationend がアニメーション終了時に発火するのでそれを拾ってちゃんとクラスを削除するようにしましょう

動作確認

あとはアプリを起動して動作確認してみましょう
今回の記事のアプリを完成されると以下のように動作すると思います

最後に

Sinatra + Redis で永続可能な like ボタン的なのを作ってみました
今回の仕様だと 1 人で何回も like できてしまいます
一番良いのはアカウント登録させて 1 アカウント 1 回だけ like させるような処理をサーバ側で実装するのが良いと思います
ただ、アカウント登録させるとなるとそれだけでグッとハードルが上がってしまいます
なので session などを使って制限するのが簡単かなと思います
例えば前回の like から 24 時間経過していないと次の like ができないようにするなどです
ただ session の場合はクライアント側で cookies を削除してしまえばどうにでも対応できるので完璧な対応ではないのでご注意ください

2019年5月17日金曜日

dkron で結果を Slack に通知してみた

概要

dkron にはジョブの実行結果を通知する仕組みがあります
dkron Pro であれば Processors を使って実現できますが OSS 版には通知用の Processors がありません
OSS 版にも File, Log, Syslog Processors はありますがメールや Slack に通知するための Processors はありません
が、OSS 版でも Slack に通知する機能はあります
Pro 版とのジョブごとに異なるチャネルに通知することができないくらいです

環境

  • Ubuntu 16.04 LTS
  • dkron 1.2.4

ジョブの登録

ジョブは何でも OK です
とりあえず Shell executor を使って定期的にコマンドを実行するジョブを登録します

curl localhost:8080/v1/jobs -XPOST -d '{
  "name": "job1",
  "schedule": "@every 30s",
  "timezone": "Asia/Tokyo",
  "owner": "hawksnowlog",
  "owner_email": "hawksnowlog@gmail.com",
  "disabled": false,
  "tags": {
    "dkron_server": "true:1"
  },
  "concurrency": "allow",
  "executor": "shell",
  "executor_config": {
    "command": "date"
  }
}'

OSS 版の Slack の通知はジョブが成功しても失敗しても通知されます
ちなみに Pro 版の Slack Processor は onSuccess という設定で成功した場合も送信するか失敗した場合だけにするか設定することができます

Slack の設定を有効にする

次に Slack の通知を有効にします
Incomming Webhook の仕組みを使うので URL を事前に取得しておいてください
また設定は管理画面からは行えないので設定ファイルに直接記載します

  • sudo vim /etc/dkron/dkron.yml
webhook-url: https://hooks.slack.com/services/xxxxxxxxxx/xxxxxxxxxx/xxxxxxxxxxxxxxxxxxxx
webhook-payload: "{\"text\":\"A status of {{.JobName}} is {{.Success}}\",\"username\":\"DkronBot\",\"icon_url\":\"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg2LszE5GtwoK-DGY1onFDSRJ4y6hyphenhyphenTkB3PqKxuHgO9TYJ77RwDdy9Fa9DBOs_bWzkUOUqCg2oah1i5_GEGLs1ZyEoUIFIeRNZ87lwznv94tth9ReNahxhWyRFkMNDAaqoCll-VyvaGYdo/s1600/jarato.png\"}"
webhook-headers:
  - Content-Type:application/json

webhook-url は取得したものに変更してください
webhook-payload は Slack の Incomming Webhook で使える payload がそのまま指定できます
また golang のテンプレートを使うことができます
struct のフィールドが使用可能になっているようです
一覧はコードを直接見るしかありませんが以下が参照可能です
https://github.com/victorcoder/dkron/blob/master/dkron/notifier.go#L69

自分が試した感じだと {{.Report}} を使うと invalid payload になるためうまく通知できません
おそらく JSON のフォーマットに違反した情報が展開されてしまっているためだと思います (p-r 案件かなと)

設定ファイルに Slack の情報を記載したら dkron を再起動しましょう

  • sudo systemctl restart dkron

動作確認

あとは動作確認です
ジョブが失敗したときに Slack に通知がくれば OK です

今回は webhook-payload 内で channel の指定はしていないので通知されるチャネルは Incomming Webhook を作成した際に指定したデフォルトのチャネルになります
dkron.yml は dkron のグローバルな設定になるのですべてのジョブのエラーが通知されてしまいますが、一応こんな感じで Slack の通知を実現することはできました

Tips

dkron のログをチェックする

  • sudo journalctl --no-pager -u dkron.service | less

ログをデバッグ表示にする

  • vim /etc/dkron/dkron.yml
log-level: debug

最後に

OSS 版 dkron で Slack の通知を試してみました
dkron.yml に Incomming Webhook の URL を指定することで実現できますが、すべてのジョブに共通の設定になってしまいます
ちなみに Pro 版では Slack Processor という仕組みが使えます
dkron-processor-slack.yml にチーム単位で Incomming Webhook URL を指定することができ、そのチームをジョブに紐付けることでジョブごとに通知先を切り替えることができるようです
当然ですが OSS 版では使えないので試せませんでした

2019年5月16日木曜日

dkron 試してみた

概要

dkron は cron のスケジューリングを自動で行ってくれるツールです
ノードを追加していくことでスケジューリングされたジョブを各ノードで分散して実行してくれます
今回は 1 台の Ubuntu 上にインストールし簡単に使ってみました
docker でも動作しますがホストに直接インストールして使っています

環境

  • Ubuntu 16.04 LTS
  • dkron 1.2.4

インストール

  • sudo sh -c "echo 'deb [trusted=yes] https://apt.fury.io/victorcoder/ /' > /etc/apt/sources.list.d/dkron.list"
  • sudo apt -y update
  • sudo apt -y install dkron

systemd 配下に登録されているので systemctl コマンドが使えます

修正

  • sudo vim /lib/systemd/system/dkron.service
ExecStart=/usr/bin/dkron agent --server

--server が抜けている場合は追記してください

起動

  • sudo systemctl start dkron

これで localhost:8080 にアクセスすると管理画面が表示されます

ジョブ登録と確認

ジョブと呼ばれる単位で登録していきます
登録は管理画面からは行えないので API を使います
簡単な REST で登録できます
とりあえず 10s おきに date コマンドを実行するジョブを登録しましょう

curl localhost:8080/v1/jobs -XPOST -d '{
  "name": "job1",
  "schedule": "@every 10s",
  "timezone": "Asia/Tokyo",
  "owner": "hawksnowlog",
  "owner_email": "hawksnowlog@gmail.com",
  "disabled": false,
  "tags": {
    "dkron_server": "true:1"
  },
  "concurrency": "allow",
  "executor": "shell",
  "executor_config": {
    "command": "date"
  }
}'

schedule で実行する間隔を登録します
dkron は秒単位でスケジュールすることができます
cron のフォーマットでも可能ですが dkron 内で使える簡易記法もあります

tags はジョブの検索や実行するノードを指定するときに役立ちます

今回は dkron をインストールした Ubuntu 上でコマンドを実行するので executor は shell を指定しています
dkron にはいろいろな executor が用意されており例えば HTTP リクエスト送信するための executor などがあります

これでジョブの登録ができます
登録したジョブは API では以下のように取得できます

  • curl localhost:8080/v1/jobs
  • curl localhost:8080/v1/jobs/job1

詳細を取得する場合は /jobs/{job_name} を使ってください
また管理画面でもジョブの確認ができます

動作確認

一覧からジョブを選択すると実行結果を確認できます

API であれば executions をコールしましょう

  • curl localhost:8080/v1/jobs/job1/executions

ジョブを削除する

DELETE メソッドでコールするだけです

  • curl localhost:8080/v1/jobs/job1 -XDELETE

HTTP executor を使ってみる

先程は Shell executor を使いました
今度は HTTP executor を使ってみたいと思います
まずは登録します

curl localhost:8080/v1/jobs -XPOST -d '{
  "name": "job2",
  "schedule": "@every 1m",
  "timezone": "Asia/Tokyo",
  "owner": "hawksnowlog",
  "owner_email": "hawksnowlog@gmail.com",
  "disabled": false,
  "tags": {
    "dkron_server": "true:1"
  },
  "concurrency": "allow",
  "executor": "http",
  "executor_config": {
    "method": "POST",
    "url": "https://kaka-request-dumper.herokuapp.com/",
    "headers": "[\"Content-Type: application/json\"]",
    "body": "{\"key\":\"value\"}",
    "timeout": "30",
    "expectCode": "200",
    "expectBody": "",
    "debug": "true"
  }
}'

ポイントはやはり executor と executor_config です
リクエストの情報は executor_config でいろいろと設定します
今回は POST + JSON ボディを送信しています
headers に Content-Type: application/json を method に POST を設定します
そして body に JSON を設定するのですがここが文字列を設定しなければなりません
せっかくだったらそのまま JSON を書ければ良いと思って試したのですが Invalid Parameter Error になります
なのでダブルクオートなどはちゃんとエスケープしてあげましょう
expectCode と expectBody はレスポンスチェックに使えます
今回は expectCode だけ指定しています
その場合はステータスコードだけを見て成功かどうか判断します
expectBody も指定している場合はレスポンスボディもチェックして成功かどうかを判断します
失敗した場合は Not match the expected body となるようです

失敗した場合はジョブに設定したリトライ設定に応じてリトライするか諦めるかに分岐します

最後に

dkron を Ubuntu 上にインストールして Shell executor と HTTP executor を試してみました
基本は REST ベースでやり取りします
競合だと Jenkins などが近いかなと思います
まだ細かく使っていないので何とも言えませんが Executor をカスタムできたりすると便利なような気がします
また結果をメールや Slack で通知できたりするらしいのでその辺りも別記事で紹介したいと思います

参考サイト

2019年5月15日水曜日

docker + redis で Sentinel 環境を構築する

概要

docker 上で redis + sentinel を試してみました
最初は docker コマンドで master 1 台、slave 1 台、sentinel 1 台を構築しフェイルオーバーの挙動を確認します
その後で docker-compose 化しスケールなどに対応します

環境

  • macOS 10.14.4
  • docker 18.09.2
  • Redis 5.0.3

とりあえず master/slave 構成を作成する

master コンテンを起動します

  • docker run -d --name master redis

次に slave コンテナを起動します
master を参照できる必要があるので link で master コンテナの名前と紐付けます
そして salve として起動させるために起動コマンドに --slaveof を付与して起動します

  • docker run -d --link master --name slave1 redis redis-server --slaveof master 6379

これで slave の状態を確認しましょう
以下のようになっていれば OK です

  • docker exec slave1 redis-cli info replication
# Replication
role:slave
master_host:master
master_port:6379
master_link_status:up
master_last_io_seconds_ago:7
master_sync_in_progress:0
slave_repl_offset:98
slave_priority:100
slave_read_only:1
connected_slaves:0
master_replid:b403efc891b46f6d9e1ef6b663f21c2ae43739dc
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:98
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:98

動作確認

master に書き込みをして slave 側にもデータが反映されているか確認します

  • docker exec master redis-cli set a a
  • docker exec master redis-cli get a

=> a

  • docker exec slave1 redis-cli get a

=> a

また slave 側に書き込みを行えないことも確認します

  • docker exec slave1 redis-cli set b b
READONLY You can't write against a read only replica.

sentinel を組み合わせる

redis の単純な master/slave 構成を構築したところで次に sentinel と組み合わせます
sentinel と組み合わせることで master がダウンした際に slave が自動的に master に昇格することができます

sentinel 用のイメージの作成から始めます
sentinel 用の設定ファイル sentinel.conf を作成します

  • vim sentinel.conf
port 26379
dir /tmp
sentinel monitor mymaster master 6379 1
sentinel down-after-milliseconds mymaster 5000
sentinel parallel-syncs mymaster 1
sentinel failover-timeout mymaster 5000

sentinel は 1 台のみ起動するので monitor は 1 にします
次に Dockerfile です
設定ファイルを元に redis-server を起動するようにすれば OK です

  • vim Dockerfile
FROM redis

ADD sentinel.conf /etc/redis/sentinel.conf
RUN chown redis:redis /etc/redis/sentinel.conf

CMD ["redis-server", "/etc/redis/sentinel.conf", "--sentinel"]

これでイメージをビルドしてコンテナを起動しましょう
コンテナを起動する際は link で master と slave を参照できるようにします

  • docker build -t sentinel .
  • docker run -d --link master --link slave1 --name sentinel1 sentinel

sentinel コンテナが起動したら sentinel API を使って状態を書くにします
まずは slave として登録されているコンテナを取得します

  • docker exec sentinel1 redis-cli -p 26379 sentinel slaves mymaster | grep -A 1 'name'

=> 172.17.0.3:6379

次に master として登録されているコンテナを確認します
それぞれ別のコンテナが登録されていることがわかります

  • docker exec sentinel1 redis-cli -p 26379 sentinel get-master-addr-by-name mymaster

=> 172.17.0.2

master を停止さて昇格するか確認

では本題のフェイルオーバーを試してみます
master コンテナを停止して slave が master になるか確認してみましょう

  • docker stop master

sentinel コンテナのログには以下のように表示されました

  • docker logs -f sentinel1
1:X 14 May 2019 00:26:27.421 # +sdown master mymaster 172.17.0.2 6379                                                            
1:X 14 May 2019 00:26:27.421 # +odown master mymaster 172.17.0.2 6379 #quorum 1/1                                                
1:X 14 May 2019 00:26:27.421 # +new-epoch 1
1:X 14 May 2019 00:26:27.421 # +try-failover master mymaster 172.17.0.2 6379                                                     
1:X 14 May 2019 00:26:27.428 # +vote-for-leader 6d8c18e7bbe1fe0c25c8fb8ea02d55795bc3aa11 1                                       
1:X 14 May 2019 00:26:27.428 # +elected-leader master mymaster 172.17.0.2 6379                                                   
1:X 14 May 2019 00:26:27.428 # +failover-state-select-slave master mymaster 172.17.0.2 6379                                      
1:X 14 May 2019 00:26:27.506 # +selected-slave slave 172.17.0.3:6379 172.17.0.3 6379 @ mymaster 172.17.0.2 6379                  
1:X 14 May 2019 00:26:27.506 * +failover-state-send-slaveof-noone slave 172.17.0.3:6379 172.17.0.3 6379 @ mymaster 172.17.0.2 6379
1:X 14 May 2019 00:26:27.584 * +failover-state-wait-promotion slave 172.17.0.3:6379 172.17.0.3 6379 @ mymaster 172.17.0.2 6379   
1:X 14 May 2019 00:26:28.438 # +promoted-slave slave 172.17.0.3:6379 172.17.0.3 6379 @ mymaster 172.17.0.2 6379                  
1:X 14 May 2019 00:26:28.438 # +failover-state-reconf-slaves master mymaster 172.17.0.2 6379                                     
1:X 14 May 2019 00:26:28.490 # +failover-end master mymaster 172.17.0.2 6379                                                     
1:X 14 May 2019 00:26:28.490 # +switch-master mymaster 172.17.0.2 6379 172.17.0.3 6379                                           
1:X 14 May 2019 00:26:28.491 * +slave slave 172.17.0.2:6379 172.17.0.2 6379 @ mymaster 172.17.0.3 6379                           
1:X 14 May 2019 00:26:33.506 # +sdown slave 172.17.0.2:6379 172.17.0.2 6379 @ mymaster 172.17.0.3 6379

これでフェイルオーバーは完了しています
今回のようなそれぞれ 1 台構成だとすべてが起動していないと sentinel API で状況を確認できないのでダウンした master を再度起動します
次に起動する際は redis の世界では slave として起動します

  • docker start master
1:X 14 May 2019 00:28:17.289 # -sdown slave 172.17.0.2:6379 172.17.0.2 6379 @ mymaster 172.17.0.3 6379
1:X 14 May 2019 00:28:27.266 * +convert-to-slave slave 172.17.0.2:6379 172.17.0.2 6379 @ mymaster 172.17.0.3 6379

動作確認

ではフェイルオーバーの動作確認をします
まずは先程実行しように master/slave として登録されているコンテナを確認します

  • docker exec sentinel1 redis-cli -p 26379 sentinel slaves mymaster | grep -A 1 'name'

=> 172.17.0.2:6379

  • docker exec sentinel1 redis-cli -p 26379 sentinel get-master-addr-by-name mymaster

=> 172.17.0.3

ちゃんと入れ替わっていることが確認できると思います
次に master コンテナに対して set 命令をしてみます

  • docker exec master redis-cli set b b
READONLY You can't write against a read only replica.

このように master コンテナに対して set できなくなっています
これは redis の世界ではすでに master コンテナが slave として扱われているためです
これが docker 上で sentinel を使った場合のややこしい点でフェイルオーバー後は docker の name と redis 内部で管理されている master/slave に齟齬が発生するので注意が必要です

sentinel に対して read/write できるのか

念の為確認しましたがそれはできないようです

  • docker exec sentinel1 redis-cli -p 26379 get a
ERR unknown command `get`, with args beginning with: `a`,

つまりアプリケーション側では常に master 側に書き込みを行うような処理を入れなければならないことがわかります
もしくは sentinel 以外を使って常に master にバランシングするようなミドルウェアの導入が必要です

docker-compose 化する

最後に docker-compose 化しましょう

  • vim docker-compose.yml
version: '2'
services:
  master:
    image: redis
  slave:
    image: redis
    command: redis-server --slaveof master 6379
    links:
      - master
  sentinel:
    build: .
    links:
      - master
      - slave
  • vim Dockerfile
FROM redis

ADD sentinel.conf /data/sentinel.conf
RUN chown redis:redis /data/sentinel.conf

CMD ["redis-server", "/data/sentinel.conf", "--sentinel"]

sentinel.conf だけやや変えています
この後スケールさせるので monitor を 1 -> 2 に変更しています

  • vim sentinel.conf
port 26379
dir /tmp
sentinel monitor mymaster master 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel parallel-syncs mymaster 1
sentinel failover-timeout mymaster 5000

あとはビルドして各コンテナを起動すれば OK です

  • docker-compose build
  • docker-compose up -d

動作確認

まだそれぞれ 1 台構成ですがとりあえず起動しているか確認してみましょう

  • docker-compose exec sentinel redis-cli -p 26379 sentinel get-master-addr-by-name mymaster

=> 172.21.0.2

  • docker-compose exec sentinel redis-cli -p 26379 sentinel slaves mymaster | grep -A 1 'name'

=> 172.21.0.3:6379

get/set の確認もしておきます

  • docker-compose exec master redis-cli set a a
  • docker-compose exec master redis-cli get a

=> a

  • docker-compose exec slave redis-cli get a

=> a

スケールさせて動作確認

最後に sentinel と slave コンテナをスケールさせて挙動を確認します
sentinel コンテナを 3 台、slave コンテナを 4 台にします

  • docker-compose scale sentinel=3
  • docker-compose scale slave=4

これで master コンテナを停止しましょう
フェイルオーバーが発生し salve の 4 台のうちどれかが master に昇格しています

  • docker-compose stop master
  • docker-compose exec sentinel redis-cli -p 26379 sentinel get-master-addr-by-name mymaster

=> 172.21.0.8

先程 master だったコンテナが slave として新たに登録されています

  • docker-compose exec sentinel redis-cli -p 26379 sentinel slaves mymaster | grep -A 1 'name'

=> 172.21.0.9:6379, 172.21.0.2:6379, 172.21.0.7:6379, 172.21.0.3:6379

この挙動自体は何ら問題ないのですがアプリケーションの観点から見ると以下のような問題点が発生します

問題点

docker の世界のコンテナ名で set を行おうとするとできなくなっています

  • docker-compose exec master redis-cli set a a
  • docker-compose exec master redis-cli set a a
(error) READONLY You can't write against a read only replica.

これは master だったものが slave になっているのが原因と slave が複数いるため本当の master を docker-compose exec では見つけられないため発生しています
もし docker コマンドで操作したい場合は各 slave コンテナの IP を調べて master に昇格しているコンテナを探した上でそのコンテナに対して docker exec すれば set 命令することができます

  • docker inspect sentinel_test_slave_4 | grep 'IPAddress'

=> "IPAddress": "172.21.0.8"

  • docker exec sentinel_test_slave_4 redis-cli set b b

=> OK

  • docker-compose exec master redis-cli get b

=> b

こんな感じです
ここから考察するに docker-compose を使って sentinel を構築した場合、アプリケーション側は link の名前ではなく IP ベースで master を追跡できるような仕組みを検討しないとダメかもしれません

最後に

docker 上で redis + sentinel 環境を構築し実際にフェイルオーバーやスケールなどをさせて簡単に挙動を確認してみました
一番問題なのは docker の世界の name と redis の世界の master/slave に齟齬が発生する点かなと思います
たぶん調べればバランシングするコンテナなどと組み合わせてアプリケーションは常にそこにアクセスするようにすれば解決できる方法はあると思います
ただ純正の sentinel の機能を使うだけだとそれは厳しいということがわかりました

正直なところ docker + sentinel は少し相性が悪いのかなといった印象を受けました

参考サイト

2019年5月14日火曜日

docker + redis でデータのバックアップを考える

概要

redis のダンプデータを docker 上で永続化する方法を検討します

環境

  • macOS 10.14.4
  • docker 18.09.2
  • redis 5.0.3

特に何もしない状態では

とりあえずコンテナを起動してみます

  • docker run -d -p 6379:6379 redis

これでデータをセットして

  • redis-cli set a a
  • redis-cli get a

コンテナを再起動してみると

  • docker restart d33509295abd

実はデータが残っています

  • redis-cli get a

=> a

これはなぜかというとデフォルトで以下のパスに dump.rdb がダンプされているからです
コンテナ停止時にちゃんとデータを吐き出してくれているようです

  • docker exec d33509295abd ls -ltr /data

ダンプの設定を確認する

  • redis-cli config get save
1) "save"
2) "3600 1 300 100 60 10000"

save の条件は 3 つほどあるようです
おそらくこれはデフォルトの Redis と同じはずです

  • redis-cli config get appendonly
1) "appendonly"
2) "no"

appendonly はデフォルトでは無効のようです

  • redis-cli config set appendonly yes

と実行して書き換えることはできますが再起動すると no に戻ってしまいます
再起動しても yes にするには設定ファイルを直接書き換えるしかありません

redis.conf を使う方法

Dockerfile を作成するのが公式なやり方のようです
例えば以下のような感じでできます

  • vim /usr/local/etc/redis.conf
appendonly yes
dir /data
  • vim Dockerfile
FROM redis
RUN mkdir -p /usr/local/etc/redis/
RUN chown -R redis:root /usr/local/etc/redis/
COPY redis.conf /usr/local/etc/redis/redis.conf
CMD [ "redis-server", "/usr/local/etc/redis/redis.conf" ]
  • docker build -t my_redis .
  • docker run -d -p 6379:6379 -v $(pwd):/data my_redis
  • redis-cli config get appendonly
1) "appendonly"
2) "yes"

上記は appendonly を設定する方法ですが、こんな感じで独自の redis.conf を使うことができます
当然コンテナを再起動しても appendonly の設定は変わっていないことが確認できると思います
またマウントしたパスに appendonly.aof があることも確認できると思います

単純に /data をホストでマウントする

もしコンテナが削除されてしまった場合は dump.rdb もなくなってしまいます
それを回避する一番簡単な方法をコンテナホストで /data をマウントすれば OK です

  • docker run -d -p 6379:6379 -v $(pwd):/data redis
  • redis-cli set a a

コンテナを作成して set コマンドを実行してみるとわかりますがコマンドを実行した直後にダンプファイルが作成されるわけではありません
コンテナが再起動や停止をするとダンプファイルが作成されます

  • docker restart 910f6ffcb975
  • ls -ltr

=> dump.rdb

この状態でコンテナを停止 -> 削除し再度コンテナを作成してもデータが存在することが確認できます

  • docker stop 910f6ffcb975
  • docker rm 910f6ffcb975
  • docker run -d -p 6379:6379 -v $(pwd):/data redis
  • redis-cli get a

=> a

マウントしたパスをクラウドストレージのファイルシステムでマウントする

では万が一コンテナホストがなくなってしまった場合はどうすればいいでしょうか
なくならないにしてもコンテナホストが起動できない状態になると大変です
そんな場合にはコンテナホストでマウントしたパスを更にクラウドストレージと連携しておくと安心です
gcsfuse を使った方法は過去に紹介しているので参考にしてください
https://hawksnowlog.blogspot.com/2019/05/gcsfuse-on-macos.html

最後に

docker + redis でデータを永続化する方法を考えてみました
データが飛んでもいいような単純なキャッシュであればここまで考える必要はないかなと思います
あと考慮しなければいけないのは Sentinel 環境かなと思います
Sentinel を使った場合も別記事で紹介できればなと思っています

参考サイト

2019年5月13日月曜日

Google Cloud Storage を macOS 上にマウントして使う方法

概要

Google Cloud Storage (GCS) を macOS 上のファイルシステムにマウントして使う方法を紹介します
macOS の場合権限周りが面倒なので注意しましょう

環境

  • macOS 10.14.4
  • osxfuse 3.9.0
  • Cloud Storage FUSE 0.23.0

インストール

macOS の場合は Homebrew が使えます
osxfuse というパッケージに依存しているので事前にインストールしておきます

  • brew cask install osxfuse
  • brew install gcsfuse

PATH に gcsfuse コマンドが通らなかったので自分は以下も実行しています

  • ln -s /usr/local/Cellar/gcsfuse/0.23.0/bin/gcsfuse /usr/local/bin/

fstab など mount コマンドから制御したい場合は以下も実行しましょう (今回は使いません)

  • ln -s /usr/local/Cellar/gcsfuse/0.23.0/sbin/mount_gcsfuse /sbin/

osxfuse の使用を許可する

おそらく mojave など最新のバージョンだと必須です
Homebrew でインストールした際に警告文も出ているので対応します
システムの環境設定からプライバシーの設定を開き「許可」を選択します

バケットの作成 (任意)

事前に作成できている場合は不要です
また gsutil コマンドを使うので設定がまだの場合は事前にインストールしておいてください

  • gsutil mb gs://gcsfuse-test-20190513
  • gsutil ls

でバケットが作成できているか確認しましょう
ちなみにバケット名は GCS 内でユニークなので注意してください
必要な場合は mb コマンドの -c オプションでストレージクラスを指定してください

認証する (任意)

gcsfuse アプリケーションが Google Cloud Platform にアクセスするためのトークンを取得します

  • gcloud auth application-default login

でブラウザで OAuth の画面が表示されるのでアクセスするユーザを選択してアクセスを許可しましょう

  • gcloud auth application-default print-access-token

でトークンが表示されれば OK です

マウントする

ではマウントします
マウント用のディレクトリを作成しましょう

  • sudo mkdir /gcsfuse-test
  • gcsfuse gcsfuse-test-20190513 /gcsfuse-test
Using mount point: /gcsfuse-test                              
Opening GCS connection...                                  
Opening bucket...                                             
Mounting file system...                                 
File system has been successfully mounted.

これで成功しました

  • df -h

でちゃんとファイルシステムとしてマウントされているかも確認できます

gcsfuse-test-20190513  1.0Pi    0Bi  1.0Pi     0%       0    1125899906842624    0%   /gcsfuse-test

これで OK です
試しにファイルなどを touch して GCS 側にもファイルがあるか確認してみましょう

  • touch hoge.txt

アンマウントする

アンマウントは普通に umount コマンドを実行すれば OK です

  • umount /gcsfuse-test

ディレクトリにまだいたり読み書きしてビジー状態の場合は解除してから umount してください

トラブルシューティング

マウント時に以下のエラーが出る場合は認証情報がうまく設定されていません
再度 gcloud auth application-default login でログインしてみてください

Opening GCS connection...
daemonize.Run: readFromProcess: sub-process: mountWithArgs: getConn: DefaultTokenSource: google: could not find default credentials. See https://developers.google.com/accounts/docs/application-default-credentials for more information.

更に以下が出る場合はマウントしようとしているパスに gcsfuse でアクセスできる権限が付与されているか確認してください
もしくはシステム環境設定でちゃんと osxfuse の使用が許可されているか確認してください

daemonize.Run: readFromProcess: sub-process: mountWithArgs: mountWithConn: Mount: mount: loadOSXFUSE: exit status 1

最後に

macOS 上で gcsfuse を使ってみました
ポイントはやはり権限周りで特に osxfuse の利用を許可するところで少しハマりました
使ってみた感じとしてはネットワークのレイテンシーがある分重いですが十分使えると思います
軽量なファイルであればそこまで気にならないと思いますが重いファイルであれば ls なども時間がかかるので頻繁にアクセスするのは避けたほうが良いと思います
基本は日時バックアップなどのディレクトリとして一日一回更新するようなファイルの置き場に使うのが良いと思います

参考サイト

2019年5月1日水曜日

Homebrew でインストールした ruby で ffi を使う場合には pkg_config のパスを指定しなければいけない

概要

Mac 上で Ruby を使っている場合に Homebrew でインストールしていると ffi がビルドできない場合があります
libffi も Homebrew でインストールするのですがその場合だと ffi.h が ruby からうまく発見できるエラーになります
最新のバージョンだと発生するようです

環境

  • macOS 10.14.4
  • Ruby 2.6.2p47
  • libffi 3.2.1

エラー全文

bundle install --path vendor などで ruby-ffi がインストールできない場合に発生します
自分が遭遇したエラーの全文は以下の通りです

Fetching ffi 1.9.21
Installing ffi 1.9.21 with native extensions
Gem::Ext::BuildError: ERROR: Failed to build gem native extension.

    current directory: /usr/local/lib/ruby/gems/2.6.0/gems/ffi-1.9.21/ext/ffi_c
/usr/local/opt/ruby/bin/ruby -I /usr/local/Cellar/ruby/2.6.2/lib/ruby/2.6.0 -r ./siteconf20190501-58126-xr7vbu.rb extconf.rb
checking for ffi.h... no
checking for ffi.h in /usr/local/include,/usr/include/ffi... no
checking for shlwapi.h... no
checking for rb_thread_blocking_region()... no
checking for rb_thread_call_with_gvl()... yes
checking for rb_thread_call_without_gvl()... yes
creating extconf.h
creating Makefile

current directory: /usr/local/lib/ruby/gems/2.6.0/gems/ffi-1.9.21/ext/ffi_c
make "DESTDIR=" clean

current directory: /usr/local/lib/ruby/gems/2.6.0/gems/ffi-1.9.21/ext/ffi_c
make "DESTDIR="
Running autoreconf for libffi
/usr/local/lib/ruby/gems/2.6.0/gems/ffi-1.9.21/ext/ffi_c/libffi/autogen.sh: line 2: exec: autoreconf: not found
make: *** ["/usr/local/lib/ruby/gems/2.6.0/gems/ffi-1.9.21/ext/ffi_c/libffi-x86_64-darwin18"/.libs/libffi_convenience.a] Error 127

make failed, exit code 2

Gem files will remain installed in /usr/local/lib/ruby/gems/2.6.0/gems/ffi-1.9.21 for inspection.
Results logged to /usr/local/lib/ruby/gems/2.6.0/extensions/x86_64-darwin-18/2.6.0/ffi-1.9.21/gem_make.out

An error occurred while installing ffi (1.9.21), and Bundler cannot continue.
Make sure that `gem install ffi -v '1.9.21' --source 'https://rubygems.org/'` succeeds before bundling.

In Gemfile:
  selenium-webdriver was resolved to 3.9.0, which depends on
    childprocess was resolved to 0.8.0, which depends on
      ffi

selenium-webdriver が childprocess に依存しておりそこから ffi に依存しています
内容的には前述した通り ffi.h が見つけられないのが原因です

対応方法

  • brew link --force libffi

を実行すると分かるのですがパスが見えないところにあるので LDFLAGS or PKG_CONFIG_PATH を設定しなさいという警告文が出ます
なので素直に設定しましょう

  • export PKG_CONFIG_PATH="/usr/local/opt/libffi/lib/pkgconfig"

これで再度 bundle install すると問題なくインストールできるはずです

macOS が libffi 自体の Homebrew 経由のインストールを拒否しているのでそれが解消されるまでは上記の環境変数を自分で通すしかなさそうです