その手の平は尻もつかめるさ

ギジュツ的な事をメーンで書く予定です

YAPC::Kansai 2017 OSAKAで「Webアプリケーションのキャッシュ戦略とそのパターン」と言うタイトルで喋ります

表題のとおりです.喋ります.詳細は以下のリンクを参照ください.

http://yapcjapan.org/2017kansai/talks.html#talk-22

ここ1,2年ずっとキャッシュと密にやっていく仕事 (サービス) をやっていて,それにより給料と言う名のキャッシュを得て生活していたわけですが,ここらでそこらへんの知見らしきものをまとめ,ついでにアウトプットしようという狙いでトーク応募をしましたら採択されましたから喋ります.役に立つ話が出来ると嬉しいなあと思っています.ぜひ来てください!!!

あとdankogaiさんのライブコーディングの実況・解説という珍ロールで登壇もします.こちらも絶対面白いので来てください!!!

mcrouterはget-multiリクエストを個別のgetリクエストに分割する

mcrouterという,Facebookが作っているmemcachedの為のルータがあります.「ミクルーター」と発音するようです *1

mcrouterは多機能なルータであり,シンプルなルーティング (例えば乱択やhash basedなど) からfailoverを前提とした大規模なクラスタのルーティングまで様々なルーティングを行うことが可能です (Facebook内の数千台規模のmemcachedクラスタでも運用されているようです.参照: Facebookの数千台規模のmemcached運用について - ゆううきブログ).

さて,mcrouterの詳細や使い方の説明については他の資料に譲るとして,本記事ではmcrouterがmemcachedget-multi リクエストを各々複数の get リクエストに分解して宛先のmemcachedに送る挙動をするという話をします.

どういうことなのか

Multi-key GET Behavior · Issue #40 · facebook/mcrouter · GitHub

このissueそのままではあるんですが,検証してみましょう.

{
    "pools": {
        "sample": {
            "servers": [
                "localhost:11211"
            ]
        }
    },
    "route": {
        "type": "AllAsyncRoute",
        "children": [
            "PoolRoute|sample"
        ]
    }
}

この設定ファイルを食わせてmcrouterを起動して (e.g. mcrouter -f ./config.json -p 22122),更に宛先のmemcachedを起動します.memcachedmemcached -vvというふうに起動すると検証が楽 (Ref: memcached おすすめ起動オプションまとめ - blog.nomadscafe.jp).

しかる後に以下のようなコードを流し込んでみる.

use IO::Socket;

# mcrouter に get-multi
my $socket = IO::Socket::INET->new(
    PeerAddr => 'localhost',
    PeerPort => 22122,
    Proto    => 'tcp',
);

my $keys = join " ", map { $_ } 1..5; # 5件multi-get
$socket->print("get $keys\r\nquit\r\n");
my @lines = $socket->getlines;
$socket->close;

さてこの時にmemcachedのログを確認してみると

<30 get 1
>30 END
<30 get 2
>30 END
<30 get 3
>30 END
<30 get 4
>30 END
<30 get 5
>30 END

という感じで,get-multiリクエストではなく個別のgetリクエストが来ていることがわかります.

一方,memcachedに直接繋いだ時は当然get-multiでリクエストが飛びます.

use IO::Socket;

# memcached に get-multi
my $socket = IO::Socket::INET->new(
    PeerAddr => 'localhost',
    PeerPort => 11211,
    Proto    => 'tcp',
);

my $keys = join " ", map { $_ } 1..5; # 5件multi-get
$socket->print("get $keys\r\nquit\r\n");
my @lines = $socket->getlines;
$socket->close;
<31 get 1 2 3 4 5
>31 END

つまりmcrouterはget-multiリクエストを個別のgetリクエストにバラしてから宛先memcachedに送りつけているのだ!! (正確にはget-multiに限らず,multi系のオペレーションは全部分解される)

内部的にどうなっているのか?

主に関連する部分

詳細な実装に踏み込んで説明すると大変なので端折りますが

  • multi operationなリクエストはそれぞれバラしてcontextという単位にする (get-multiだったら個別のget contextにする)
  • 1リクエストあたりのcontext群はシリアライズする
  • シリアライズしたコマンド列をソケットに流し込む

という挙動をしているようです.

つまり先程の例 (1から5のkeyをget-multiする場合) では get 1\r\nget 2\r\nget 3\r\nget 4\r\nget 5\r\n というコマンドの塊をソケット越しで送るという動きになるようです.

懸念

memcachedのconn_yieldsはどういう時に増えるのか - その手の平は尻もつかめるさ

前回の記事で書いたように,1回のソケットで大量のコマンドを発行するとしきい値を超えた場合にはconn_yieldsが発生します.conn_yieldsが発生するとそのバッチリクエストの性能が劣化するという懸念があります.
従ってmemcached client側がget-multiで送っていると思い込んでいたコマンドは,実はmcrouter側で個別のgetリクエストに変換されており,key数が多い場合はconn_yieldsが多発するというような予期せぬ事態に陥ることがあります.




さてconn_yieldsによって本当に性能劣化するのか? という検証については次回行うこととします.

memcachedのconn_yieldsはどういう時に増えるのか

memcachedconn_yieldsはどのような時に増えるのかという話です.なおmemcachedはテキストプロトコルかつデフォルトの設定で実行しているものとします (なお,この記事で重要なのは -R がデフォルト,つまり20ということです).

memcached の conn_yields について - tokuhirom's blog

1つのコネクションでコマンドを発行しまくっている場合にここに到達するようだ

と,上記記事にあるとおりなんですが,実際にmemcachedのコードを読みながら試してみたという記録です.

TL;DR

1回のソケットで大量のコマンドを発行している場合にincrementされる.

getコマンドを何度も発行してみる

get 1get 2get 3とクエリを何度も発行していくパターン.

use IO::Socket;
my $socket = IO::Socket::INET->new(
    PeerAddr => 'localhost',
    PeerPort => 11211,
    Proto    => 'tcp',
);

$socket->print("get $_\r\nquit\r\n") for 1..100;

my @lines = $socket->getlines;

$socket->close;

この時は conn_yields がincrementされない.

multi-getで大量のエントリを取得してみる

get 1 2 3...と1つのgetで大量のエントリをひいてくるパターン.

use IO::Socket;
my $socket = IO::Socket::INET->new(
    PeerAddr => 'localhost',
    PeerPort => 11211,
    Proto    => 'tcp',
);

my $keys = join ' ', map { $_ } 1..100;
$socket->print("get $keys\r\nquit\r\n");

my @lines = $socket->getlines;

$socket->close;

この時も conn_yields はincrementされない.

1回のソケットで大量のコマンドを発行してみる

get 1\r\nget 2\r\nget 3\r\n...と1回のリクエストに複数コマンドを詰めるパターン.

use IO::Socket;
my $socket = IO::Socket::INET->new(
    PeerAddr => 'localhost',
    PeerPort => 11211,
    Proto    => 'tcp',
);

my $gets = join "\r\n", map {"get $_"} 1..100;
$socket->print("$gets\r\nquit\r\n");

my @lines = $socket->getlines;

$socket->close;

この場合は conn_yields はincrementされる (memcachedのデフォルトの最大コマンド数の閾値は20なので,その場合は (100 - 20)/20 = 4 ということで conn_yields は4増える).

コードを読んでみる

基本的に memcached.c を読むとわかる.

memcached/memcached.c at 12ea2e4b50a3222412e9e1cffb1253d907c56cd5 · memcached/memcached · GitHub

注目すべきはこのwhileループconn_yields はこのループ内のこの部分でしかincrementされない.
で,どういう時に conn_yields がincrementされるかというと,

  • nreqsという変数が0を下回っていて
  • なおかつstateが conn_new_cmd の時にループが回った

という時にされる (このnreqsというのは「1つのコネクションで何個のコマンドを許容するか」という閾値memcachedコマンドの -R オプション相当).
nreqs はstateが conn_new_cmdの時にループが回るとdecrement される(実装はこの辺).

つまりざっくり言うと,stateが conn_new_cmd の時に何度もループが回ると conn_yields が増加するという事になる.
stateが conn_new_cmd になるのはどういう時かというと色々あるのだけれど,頻度が多いものとしては「コマンド実行後 (正確には結果出力後)」がある.

1コネクション中で大量のコマンドが送られてきた場合,

1. 送られてきた文字列をバッファしつつ読み込みparseする
2. コマンドを実行
3. stateがconn_new_cmdになる (nreqsのdecrement,条件を満足している時は conn_yields のincrement)
4. まだ読み込んでいない文字列がある場合は1に戻る

という挙動をする (実装はこの辺).
例として get 1\r\nget2\r\n という文字列が送られてきた時は get 1\r\n を読み込み・実行した後に get 2\r\n を読み込み,実行する.この時コマンドは2度実行されるので, nreqs-2 されている.

上記の不発だった例のように「getコマンドを何度も発行してみる」という場合はそれぞれのコマンド実行が別のコネクションになっているので,conn_new_cmd時のループは高々1度しか回らない.よってnreqsは-1しかされないので conn_yields はincrementされない.

また「multi-getで大量のエントリを取得してみる」という場合は少し特殊で,送られてきた文字列を読み込む時にバッファ内に収まるのであればそれは通常のコマンド1発のように扱われる.バッファ内に収まらない時は一回読み込んでからstateを conn_waiting に遷移し,更にそこから conn_read に遷移することで続きの文字列を読み込む (読み込み切るまでこれを繰り返す).いくら文字列が長くてもコマンド1発としてみなされるので nreqs は-1しかされない.よって conn_yields はincrementされないということになる (実装はこの辺).




認識が足りなくて,getコマンドを何度も発行してみたり,multi-getで大量のエントリを取得してみたりした時でも conn_yields がincrementされると勘違いしていて,動作検証時にハマったので調べてみました.

所感

Perlの場合,Cache::MemcachedCache::Memcached::Fastを使っていれば1つのコネクションで複数コマンドを発行するケースは無さそうなので, conn_yields のことは考えなくても良さそう?

Test::Fluent::Logger書いた

表題の通りです.Perl5の話です.

CPANにもあげてあります.

https://metacpan.org/pod/Test::Fluent::Logger

何をするモジュールかというと,useするだけでFluent::Loggerのpost及びpost_with_timeの内容をインターセプトして,本来送るべき宛先には送らずに内部のリストにそれを貯めるという動作をするモジュールです.そしてそのリストをgetしてきて中身を確認したり,またリストを空にする (clear) こともできます.
使い方についてはSYNOPSISを読んでいただければわかると思います.とくに難しいことはありません.

本来はMockみたいな名前をつけるべきだったのかもしれませんが,こんなのテスト用途以外に誰が使うんだということでこの名前になっています.
毎度毎度この手のコードを書いていて,良い加減飽きたなという動機でこのたびモジュール化した次第.久々にCPANに新しいモジュールをアップロードしたんですが,昔のあの日のまま変わっていなくて少し安心しました.
ご利用くださいませ.

最近のgitを使った開発フローについて

最近のgitを使ったWebアプリケーションのプロジェクトの開発フロー (主にブランチ運用) について記すものです.
なお前提としてGitHub Enterpriseを利用しています.

git-flow

大上段に構えたもののあまり特殊なことはしていなくて,基本的にgit-flowをそのまま踏襲しています.
git-flowについてはしっかりした解説記事がインターネット上に数多く存在しますからそれらを参考にしていただければと思いますが,ざっくり説明すると

  • masterブランチ,developブランチ,releaseブランチ,featureブランチ及びhotfixブランチがある
  • masterブランチは常にリリース可能な状態になっている (すなわち現在本番で稼働しているアプリケーションのコードと等しい)
  • developブランチは開発中の状態で,ステージング環境等に上がっている
  • releaseブランチはリリース準備の為のブランチで,developから派生しmasterにマージされる
  • featureブランチはdevelopブランチから切られるブランチで,新機能やバグ修正などが実装される
  • hotfixブランチはmasterブランチから切られるブランチで,緊急で修正しなければならないバグ修正などが実装される
  • 開発中はfeatureブランチがdevelopブランチにマージされる
  • 通常のリリースではdevelopブランチをmasterブランチにマージして,そのmasterブランチをデプロイする
  • hotfixリリースではhotfixブランチをmasterブランチにマージして,そのmasterブランチをデプロイする
    • hotfixブランチの内容は何らかの方法でdevelopブランチに反映する

という感じです (間違ってたら教えてください).

実際にそれぞれのパターンで或るブランチから或るブランチにマージする際はpull requestを発行し,それを経由してマージするという感じでやっています.
この時featureブランチをdevelopブランチにマージする場合や,hotfixブランチをmasterブランチにマージする場合はコードレビューを行い,適切なフィードバックや修正が行われてからマージされるという流れになっています.

なぜgit-flowのようになったかというと,developブランチが複数走るという状況が日常化してきたためです (後述).複数のコンポーネントが絡んでくるとリリース時期を調整したりなんなりをフレキシブルにやる必要があってですね……

実際のブランチ構成

  • master (1本)
  • develop (複数本, 後述)
  • release (複数本,後述)
  • backport (複数本,後述)
  • feature (複数本)
  • fix (複数本)
  • hotfix (複数本)

といった感じで存在しています.

featureとfixについては上記のgit-flowの説明的には「featureブランチ」にあたるものですが,それぞれ「新機能の実装」と「バグの修正」とで役割に応じてブランチを分けることで見通しを良くしています.
release, backportブランチについては後述.

ブランチの命名規則

ブランチの命名規則としては

  • develop/x.x.x (x.x.xはバージョン番号)
  • feature/<description>
  • fix/<description>
  • hotfix/<description>

という風に定めて運用しています.
かつてはfeature, fix及びhotfixブランチの<description>部分は詳細に記述すべきだとして,詳しく説明を書いていたのですが最近ではそこまで詳細じゃなくて良いかな〜という気持ちになっています.そもそもマージする時はpull requestを作るのでそのpull requestのタイトルでその内容が把握できるし,branch名長いとタイプする時とかめんどいやん? みたいな感じになったためです.
あとJIRAにGitHubプラグインが入っているとfeature/XXX-123のような風にJIRAのチケット番号をブランチ名に含めた上でpull requestを発行すると自動でJIRA側にトラッキングされて便利というのもあり,JIRAのチケット番号をブランチ名に含めることも多くなってきました.

リリース時

リリース時はdevelopブランチからrelease/x.x.x (x.x.xにはバージョン名が入る) というブランチを切り,そのreleaseブランチをmasterに向けたpull requestを出すというスタイルでやっています.
いちいちreleaseブランチを切る理由としてはreleaseブランチが切られた時点のスナップショットがmasterに取り込まれる事をはっきりさせたかったのと,リリース処理に係るコミット (例えばメタ情報が書かれているファイル中のバージョンを書き換えるなど) をdevelopに含めたくなかったという理由からです.
リリース後はmasterブランチから新たなdevelopブランチを作成し,そちらを今後の開発用ブランチにするという感じにしています.

hotfixリリース

hotfixリリースを行なう際,git-flowではmasterブランチから直接hotfixブランチを作成し,そのブランチ上で修正してmasterにマージしてリリースという流れが示されています.基本的にはこの方法でhotfixリリースを行なうことが多いです.
しかし一方で一旦ステージングで確認してからmasterに反映してhotfixリリースを行いたい場合もままあります.そうした時は

  1. developブランチからfixブランチを作成
  2. fixブランチ上で修正してdevelopブランチに向けてpull request作成 & マージ
  3. ステージング環境で確認
  4. masterからhotfixブランチ作成
  5. hotfixブランチ上で,fixブランチのcommitをcherry-pick
  6. あとは通常のhotfix flowに従う

という流れで対応しています.これで対応できない場合がまれにあるので (cherry-pick時にconflictしたり……) そういった場合は気合で対応しています.

なお,hotfix後にdevelopブランチへその内容を反映させる際は,masterブランチからbackport/x.x.xというブランチを作成し、それを対象developブランチへと向けたpull requestを作成し,マージするということでhotfix内容の反映・同期を行っています.

developブランチが複数存在していて並行に開発している場合

さて,プロジェクトがある程度の規模になってくるとdevelopブランチが複数走る状況になることがあると思います.実際に我々のプロジェクトについてもdevelopブランチが複数本存在する状況がままあるので,そのような時にどのように運用しているのかという事について以下に記します *1

複数のdevelopブランチが存在している場合はそれぞれのブランチでは独立してコミットを積んでゆき,可能であればそれぞれのdevelopブランチと対応するステージング環境を用意して開発を進めていきます.
それぞれのdevelopブランチ間での同期については,いずれかのdevelopブランチがリリースされた (つまりmasterにマージされた) タイミングで行っています.以下のような感じ (develop/1.1.0develop/2.0.0がある場合の例).

  1. develop/1.1.0がmasterに取り込まれる
  2. masterからbackport/1.1.0というブランチを作成する
  3. backport/1.1.0develop/2.0.0に向けたpull requestを作成
  4. Conflictしても泣かない,頑張って直す
  5. そのpull requestをマージして同期完了

とは言え,masterにマージする前にdevelopブランチ間で同期を取りたいこともあるとは思うので,その際はcherry-pickなりmergeなりをマニュアルでやって頑張りましょうという感じです.

hotfix時は上述のhotfixリリースのフローに従いつつ,複数のdevelopブランチにbackportするという流れを採っています.

その他雑感

マージされたブランチはどんどん消しています.リモートにゴミブランチがたまると良くない!

Ref:
moznion.hatenadiary.com

結び

以上のようなフローで最近は開発していますという話でした.
これらがオーバーキルっぽい場合はGitHub flowなども良いと思います.

追記

なぜdevelopブランチが複数あるのかという疑問が散見されたので,我々のケースについて書いておきます.
ざっくり言ってしまえばリリースサイクルの違いです.例えば大きめの新機能を実装しつつ既存コードの保守もするという時,その時点で稼働しているコード (master) から派生したdevelop/A (便宜上Aとします) の上で新機能の実装を入れてしまうと,保守のコードと新機能コードが混ざってしまい,保守のためのデプロイのタイミングで出て欲しくない新機能のコードまで露出してしまうことになるので,それを防ぐ (分離させる) という目的でdevelopを複数に分ける (例えばdevelop/Bで新機能の開発は行なう) というような形にしています.
保守の為のdevelopブランチでも,新機能の為のdevelopでも,どちらも独立したステージング環境では見ておきたいので……

*1:git-flowの解説ではdevelopブランチが複数存在している場合についてあまり言及されてない気がする……

builderscon tokyo 2016で話してきました

去年の話を今するのはどうかという感じですが *1,表題の通りbuilderscon tokyo 2016で話してきました.

builderscon.io

ビールサーバーを作ったという話です.スライドは以下です.

speakerdeck.com

発表ではデモも行ったのですが,サーバから水が一瞬吹き出てしまったり,ソレノイドバルブの機構が上手く動かなかったりと,やはり本番発表のデモには魔物が住んでいるというな〜感じでした.概ね上手く行ったとは思います! *2

今更ですが他の発表の感想など.

builderscon.io

ちょっと遅れて行ったのですが,mattnさんの変態的なモチベーションの話が聞けて楽しかった.Vimで動画を再生するデモで聴衆が大きくどよめいていたのが印象的.Windowsパッチでは日頃お世話になっております.

builderscon.io

変態的な話かと思ったら果たして変態的な話だったのだけれど,現実的な,未来のウェブ・ブラウザの話という感じで興味深かった.asm.jsでどこまで出来るのかがまだ分かっていないけれどいずれ触ってみたい.しかしEmscriptenって普通に実用レベルになってたんですね……

builderscon.io

ウェブアプリケーションのソフトウェアアーキテクチャについて,昔ながらのアプローチからFluxのような最新 (ナウい) 方法までをわかりやすく説明してて良かった.小さなウェブアプリの画面とかだと自分もvue.jsを使うことが多いのだけれど,そのvueがサンプルとして示されていて個人的に分かりやすかった.vuexはいずれ使ってみたい.

builderscon.io

格好良いの一言.キーボードを自作するのにあたってハードウェアからソフトウェアまでのフルスタックな技術の実戦を紹介していて極めて格好良かった.ジェダイは自らが使うライトセーバーをイチから自作するという話がスター・ウォーズの中にあるのだけれど,ソフトウェアエンジニアにとってキーボードはまさにライトセーバーにあたるそれで,これはすなわちジェダイの話だった.

感想ここまで.


さて結びになりますが,ビールサーバーの作成に係りましてご支援下さった皆様に感謝します (敬称略).
@, @, @, @, @, @

ところでビールサーバーが生活の中にあると非常にはかどりまして,実際もう4回ほど自作のビールサーバーが稼働しております.皆さんもやりましょう!

*1:本当に色々なことがあったのです

*2:しかしまだコードを公開していない……

Vimでコードをペーストする時にauto indentなんだかスマートな言語syntax解釈なんだかよくわからねえがとにかくインデントが崩れたり,突然ある行以降全部コメントになったりしてああああああああって時

タイトルの通りです.

:set paste

と打てば良い.ずっと知らなかったので今までの自分に苛立った一方感動のあまりデプロイが大成功するなどしました.

なお戻す時は

:set nopaste

とすれば良い.

ところで今までどうやってこうした問題を解決していたかというと,一切の設定をしていない素のVimが環境内に一個あって,それにはrawvimという名前すなわちコマンド名が与えられていてそれを使っていたのだけれど,これを機にrawvimはいなくなりましたとさ.めでたしめでたし.

[追記]

コメントでid:cho45さんとid:tyruさんからメッチャ便利情報もらいました.知らなかった,便利便利!