MacBook Pro 2018 13" + Intel NUC Bean Canyon + eGPU で Mac/Win グラボ共有環境を構築した話

今年もよろしくお願いします、小野マトペです。去年の10月、MacBook Pro 2018 13インチを購入しました。

今まで使っていた2013モデルでは性能重視で15インチを使用していましたが、今年のモデルはCPUのコア数が増え、13インチでも去年の15インチよりも高いCPUパフォーマンスが出るということだったので、モビリティのために13インチをチョイス。やっぱりノートパソコンは持ち運んでなんぼですね。軽くて最高。

japanese.engadget.com

13インチを購入するにあたって最後まで躊躇した要素はグラフィック性能でした。15インチには専用のdGPUが搭載されていますが、13インチは性能の低いiGPUしか搭載されていません。僕はたまに After Effects での動画制作作業をするので、この点はちょっと都合が悪かったです。

そこで目をつけたのがeGPU(外付けGPU)です。macOSではHigh Sierraから Thunderbolt 2/3 での eGPU 接続に対応したことで、Mac環境でのeGPUカテゴリが昨今盛り上がりを見せています。ともあれこれを使えば一台のMacを、外出時には軽量ラップトップ、自宅ではデスクトップレベルのハイパワーグラフィックマシンとして運用することができるため、今回検討しました。

GIGABYTE GAMING BOXを試すつもりだったのだが…

そういうわけで、調査の末に去年の年末に amzon.com で GIGABYTE GAMING BOX 580 を注文しました。これはRadeon RX 580 グラフィックカード組み込み済みで販売されているeGPUエンクロージャで、コンパクトさが際立つ一品です(eGPUはどれもけっこうなサイズなのです)。持ち運び用のバッグが付属しているなどモビリティが高いのもポイントです。一方、ファンが壊れてノイズが鳴るようになったとか、不快なコイル鳴きがあるとか、工作精度の点で不安なレビューも見られました。しかし、Amazon.comでの価格の安さ(本体$419に送料+Import Fee Depositで$56)とコンパクトさに惹かれ、注文することにしたのですが・・・

が、開封した製品がバッキバキに破損しており、そのまま返品となりました。残念・・・

GAMING BOXを返品し、もう一度同じ製品を注文する気にもならなかったので、改めてグラボとエンクロージャを選び直すことにしました。

グラボ

GAMING BOXのような一体型でない eGPU を使うためには、eGPUエンクロージャとグラフィックボードを用意する必要があります。エンクロージャごとに供給電力やカードサイズがあり、適合する組み合わせを選ぶ必要があります。 https://egpu.io というサイトは発売中のエンクロージャとグラボの詳細な情報やレビュー記事が掲載されており、製品選びの助けになりました。

egpu.io

Macで eGPU を使用するにあたって最も大きい制約は、GPUのメーカーです。ゲーム用のGPUとしてはnVidia製品の方がメジャーですが、Macは現在AMDGPUしかサポートしていないため、AMD製品から選択することになります。nVidiaは記事執筆時点でMojave用のドライバーをリリースしておらず、High Sierra以前の環境でしか動作しません。

僕は Windows PC に詳しくないので今回初めてグラフィックカードを調べたのですが、現在AMDグラフィックカードは、主に以下のラインナップのようです。ミドルレンジ〜ハイエンドのゲーミングPC相当の性能が欲しいのであれば、RX580, RX590, Vega56あたりから選ぶことになるみたいです。

  • Radeon RX 570 (GeForce GTX970 と同じくらいの性能)
  • Radeon RX 580 (GeForce GTX1060 と同じくらいの性能)
  • Radeon RX 590 (GeForde GTX1060 より速いくらいの性能)
  • Radeon Vega 56 (GeForce GTX1070Ti と同じくらいの性能)
  • Radeon Vega 64 (GeForce GTX1080 と同じくらいの性能)

現在グラボは暗号通貨のマイニング需要や代理店のマージンにより国内での価格が高騰しているため、私は今回は Vega56 を実質35,000円程度( サンデーくじで入手したクーポン 3000円を使用)でネットオークションで入手しました。グラフィックカードは経年劣化しにくいパーツですし、中古市場で手に入れても良いと思います。amazon.com をのぞいてもいいかもしれませんね。

MSI Radeon RX Vega 56 Air Boost 8G OC グラフィックスボード VD6516

MSI Radeon RX Vega 56 Air Boost 8G OC グラフィックスボード VD6516

androgamer.net

Razer Core X

次にeGPUエンクロージャですが、これは Razer Core Xを選びました。これは安価で、Vega56への供給電力も十分、USB-PDでMac本体への電力供給も可能、動作実績も豊富と堅実な機種ですが、とにかくデカいという製品です。

左端の一番でかいやつです。

www2.razer.com

現物を見てあまりのデカさに笑う私。

狭小住宅の住人としては設置性は重視したかったのですが、年末に大容量の本棚を導入したことでデスク周りの収納スペースに余裕ができたために導入を決意し、TSUKUMO eX.の地下のRAZERストアで税込35,424円から2,000円のTSUKUMOポイント割引を適用して購入しました。空間は正義。

なんとかデスク下のメタルラックに押し込めました。デカいeGPUも、どこか隠せる場所に設置できればなんとかなります。ただし Thunderbolt 3 アクティブケーブルは高価なので気をつけてください。

eGPUゲーミングに最適な Intel NUC Bean Canyon

さて、ここまででeGPUはセットアップできたのですが、ぶっちゃけGPUを酷使する作業は年に数回あるかどうかという感じなので、そのためだけにeGPUを導入するのは正直もったいないです。せっかくつよつよグラボを入手したので、これを使ってどうにかWindowsゲームでもできないか考えました。久しぶりにマウスでFPSがやりたい。PUBGとか俺もやりたい!

最初に考えたのは、eGPU を Mac の BootCamp から利用するアプローチですが、

という問題があるため BootCamp 案は却下し、ゲーム用にWindows PCを生やすことにしました。1台くらい WIndows マシンを持っておくと何かと潰しが効くだろうし。

PCの選定ですが、

  • 内臓グラフィックカードは必要なく
  • 代わりにeGPU接続のためのThunderbolt3コネクタを搭載し、
  • CPUはミドルレンジ以上。
  • 部屋が狭いのでコンパクトPCと言える筐体サイズ、
  • コンソールゲーム機が4,5万のこのご時世、ゲームのためだけにそんなにお金をかけたくない…

というわがまま条件で探したところ、ひとつだけベストマッチな製品が見つかりました。それが Intel NUC Bean Canyon NUC8i5BEHです。

INTEL インテル i5-8259U M.2 SSDに対応 2.5" (9.5mm厚) HDD/SSDも搭載可能 ハイパフォーマンス小型ベアボーンキット BOXNUC8I5BEH

akiba-pc.watch.impress.co.jp

NUC とは Intel が出している小型・安価なPCベアボーンキットで、本体の他に電源ケーブル、メモリ、ストレージを別途購入する必要がありますが、現行世代(Beran Canyon)の NUC8i5BEH は MacBook Pro 2018 13インチと同じ第8世代i5 CPU i5-8259U と Thunderbolt 3コネクタを搭載しています。しかもサイズは超コンパクト。これ、まさに eGPU ゲーミングマシンに最適なPCじゃないですか。

この製品を狙っていたところ、ちょうどPayPayの20%ポイント還元祭りの期間中に池袋のTSUKUMOで4.8万円で販売しているのを見つけたため、その場で購入しました。250GBのm.2 SSD(6千円)、16GBメモリ(1.6万円)、Windows10 Home Edition(1.7万円)、電源ケーブル(1千円) で合計9万6千円のところ、PayPayの20%ポイント還元で実質7.7万円となりました。

Tweetのツリーに写っていますが、ついでに隣のビックカメラでLG 27UK850-Wも20%ポイント還元で買いました。これも大変良い4Kモニタです。ありがとうPayPay)

かわいい!こんなに小さいのにCPUもメモリもSSDもほとんどMBP 2018 13と同じスペック。みんなが欲しかった Mac mini ってこういうやつでは?

セットアップには少し手こずって、Windowsをインストールしたところ有線LANも無線LANもドライバがないためWebからドライバがダウンロードできないという事態になりました。MacIntelのサイトからドライバ一式をダウンロードしてUSBでコピーしましたが、他にPCがない人は注意が必要です。

完成!+ベンチマーク

f:id:ono_matope:20190120211948j:plain

f:id:ono_matope:20190120215914j:plain

というわけで、MacBook Pro, Intel NUC, Razer Core X, Vega 56 による軽量ラップトップ+ハイパワークリエイティブPC+WindowsゲーミングデスクトップPCシステムが完成しました。コンパクトさがすごくないですか(Razer Core Xをうまく机の下に隠せた場合に限る)。

ケーブルの差し替えはどうしても多くなるので、ケーブルマネジメントはだいじ。

f:id:ono_matope:20190120220252j:plain

さっそくベンチマークをとりましょう。まずは Mac で GeekBench 4 の Compute スコアを計測します。

MBP内臓GPU (Iris Plus 655) eGPU (Razer Core X + Vega 56)
Metal 35,000 146,234
OpenCL 35,238 140,855

4倍以上のグラフィックパフォーマンスが得られました。

次は NUC の Windows3DMark の Time Spy と Fire Strike を実行します。

f:id:ono_matope:20190119205711p:plain
Time Spy

f:id:ono_matope:20190119210459p:plain
Fire Strike

スコア 結果URL
3DMark Time Spy 5351 https://www.3dmark.com/3dm/32565573?
3DMark Fire Strike 13214 https://www.3dmark.com/3dm/32570334?

結果ページによると 4KゲーミングPC と ゲーミングラップトップの中間程度のパフォーマンスと評価されました。安価な小型PCをベースに構成したシステムの性能としては上出来ではないでしょうか。実際に CoD:BO4 をプレイしていますが、HD解像度の最高画質で滑らかにプレイができています。BO4 たのしい!

静音性については、ゲームプレイ中はRazer Core Xのファンが強めに回転しますが、筐体ファン半径が大きいため音が低く、耳障りな音ではないです。

そういうわけで、

  • Intel NUC + Windows10Home 7.7万円
  • Razer Core X 33,424円
  • Vega56 Air Boost(中古) 35,000円

PayPayやクーポンを併用しましたが、eGPUセット(7万弱円)とNUCセット(7.7万円)をあわせて14万円で、eGPUをMac+Winで共有可能なゲーミングPC環境が構築できました。超コンパクトな設置サイズを考慮すると、なかなかコスパよく組めたのではないでしょうか。私のように、eGPU構成を検討しているMacユーザーの方は、Intel NUCを使ったサブマシン構成を検討してみるのもおすすめです。

部屋の本棚をIKEAのBillyにした話

今の部屋には、引っ越す前から育てている無印のパルプボードボックスを利用した本棚があるのだけど、じわじわと紙の本が増えて、そろそろ本が溢れるようになってきた。このままパルプボードを増設していってもよかったが、この製品は仕様上は5段までしか重ねられず、壁の上部が有効利用できない問題があったため、ここはひとつ背の高い本棚を導入し、本棚の設置面積を圧縮して部屋をより広く使うことにした。ゆくゆくはソファを置くなどもしたい。

f:id:ono_matope:20181219193137j:plain
before: 無印のパルプボードボックス

本棚選び

我が家の本棚としての要件は、大判の美術書から文庫版の漫画まで、効率よく収納するために可動棚をもつことと、スッキリしたデザインであることだったが、デザインについて納得できるものがなかなか見つからず、本棚選びは難航していた。

無印のパルプボードボックスがそうであったように、ぼんやりした曲線を排したソリッドなかたちの本棚が欲しかったし、色に関しては、部屋がドアや木枠を含めて白で統一されていたので、本棚も白というのは心に決めていた。その点で、入手しやすい定番商品である 大洋のエースラック も、白井産業のタナリオも選択できなかった。エースラックはなんかちょっとカーブが入っていたのがどうしても許せなかったのと、タナリオのホワイトはうっすら木目が入っており、展示店舗が近くにないために実際の見栄えを確認することができなかったためだ。

このあたりでKEAの定番書棚Billyに行き着いた。

www.ikea.com

Billyはまさに上に挙げた要件を満たす理想の本棚だったが、購入に踏み切れない問題として、「たわみ」問題があった。どうも調べてみると、買おうと思っていた幅80cmのBillyは本を目一杯に収納していると、かなりの高確率で棚がたわんでくるらしかった。

たわむのはかなり見栄えが悪いので躊躇したが、結局80cm幅のBillyを諦め、40cm幅のBillyを連結することで回避することにした。40cmのBillyでは、さすがにたわみの報告は見られなかったためである。結果的に、40cm*3のマス目デザインは見た目にも良いものであった。

もう一つの問題として、転倒対策があった。Billyはかなりの重量があるので、倒れてくるとマジで危ない。IKEAの本棚は日本の家具と違い、重心が手前側に来るので、L字金具による壁固定など、転倒防止をしないと危険だとIKEAの店員に教えてもらった(どの家具でも何らかの対策は必要なのだが)。

しかし部屋は賃貸なので壁への釘打ちは躊躇われる。どうしたものかとネットを検索していたら、家具転倒防止おじさんこと篠原進さんが考案した対向くさびという転倒対策が目に入った。書棚と天井の間に楔状に組み合わせたスタイロフォームを詰め、震災時に転倒する方向への力を抑えるというものだった。これと、一般的な転倒防止シートを組み合わせて転倒防止策とすることに決めた。

家具転倒防止―経験交流サイト

auient.hatenablog.com

設置

というわけでIKEAオンラインストアで40cm幅のBillyを3台と追加棚を注文。工作精度への期待と横連結のため、組み立てサービスを依頼した。連結は別料金3000円でやってくれる。配送と組み立ては異なる業者により行われるので、配送から組み立てまでの間、届いた部材を部屋に置いておく必要がある。

設置されたBilly。連結されているため、この時点で一人では持ち上げるのが難しいほど重い。しかしなんとか頑張って足元にふんばる君120をはさんである。Billyは両側面に出っ張りがあるため、適宜カットして挟んだ。

f:id:ono_matope:20181222111715j:plain
組み立てが完了したBilly

ニトムズ 家具転倒防止安定板 ふんばる君 120 M6090

ニトムズ 家具転倒防止安定板 ふんばる君 120 M6090

連結は、使わない棚穴にビスを通して行われる。IKEAの家具は一度分解すると再組み立てが難しいため引っ越し業者に敬遠されるといわれるが、ビスは簡単に外して再連結できるそうなので、引っ越しの際は横連結を解除すれば問題なさそうである。

f:id:ono_matope:20181222171308j:plain
連結ビス

対向くさびを作ろう

さて、篠原式のスタイロフォーム対向くさびを作成する。東急ハンズで21cmx10cmx6cmの白のスタイロフォームレンガ、ノンスリップシート、発泡スチロールカッターを購入。レンガ一つで2つのくさびを作れる。(6つ作ろうと思ってレンガを3つ購入したが、強度的に2つで充分ですよだったので、レンガは余った。)

f:id:ono_matope:20181223222849j:plain
素材

棚から天井の梁まで高さ7.5cm。奥行き10cmのスタイロフォームに勾配1/10の斜面を作るため、1cmの高低差で線を引く。

f:id:ono_matope:20181223154440j:plain
罫書き

スタイロフォームの上下にノンスリップシートをあわせ、(霧吹きがなかったため)クッキングペーパーで水を含ませ…

f:id:ono_matope:20181223162940j:plain
ノンスリップシートに挟まれたスタイロフォーム

ハンマーで押し込む。なんかちょっと角の壁紙が裂けてしまった気がするが、気にしない。これで完成である。ふんばる君を敷いて本を収納した時点で、重みからそこそこ安定はしていたが、対向くさびを追加することで本当に押しても引いてもビクともしない、まるでL字金具で壁に固定されているかのような頑丈さになった。これはすごい。

f:id:ono_matope:20181223223845j:plain
対向くさび完成

パルプボードボックスから本を移して完成(写真は対向くさび設置とパルプボードボックスの撤去の前)。初期よりだいぶ設置面積がおさえられたし、部屋にもよく馴染んでおり満足。ただ、新しい本棚の容量をすでにほぼ使い切っており、写真にもあるように本などが少し入りきっていない。これとは別に映画のパンフレットの束も本当は本棚にしまいたかったし、会社に置いてあるダンボール一箱分の技術書もいつかは持ち帰ってこないといけないので、Billyがもう一台必要かもしれない。しかし既に述べたように3台連結済みのBillyは絶望的に重く、場所の微調整などできればしたくないので迷うところだ。しばらくは完全移行せず、パルプボードボックスを一部残して運用するかもしれない。本棚沼はこわい。

f:id:ono_matope:20181222142319j:plain
After: IKEA Billy 40cm x 3

ざっくり理解するPaxos

ゴールデンウィーク中、Appleオープンソースとしてリリースした分散データベース FoundationDB のドキュメントを読んでいました。なかなか面白いデータベースだと思うのでこれについては別途書きたいですが、それはそれとしてFoundationDBでは、分散環境下でACIDトランザクションを実現するために、分散合意プロトコルとして有名なPaxosを採用しているようでした。

PaxosはGoogleのChubbyやCassandraのLight Weight Transactionなどで使われていますが、僕はいまだにPaxosがどのように動作するのかあまりよく分かっていませんでした。良い機会だと思い、FoundationDBの技術を理解するためにも連休の後半でLeslie LamportによるPaxosの論文の一つ Paxos Made Simple を読み、何となくわかった気持ちになったので備忘録としてここに書き残しておきます。(間違いがあったらマサカリ頂きたいです)

Paxosが達成したいこと

Paxos を使って達成できるのは、簡単にいうと以下のようなセマンティックです。

  • ネットワーク上の複数ノードが、「提案」された値の中からひとつの値だけを 「選択」したい。
  • 一度値が「選択」されたら、その値はあとから未選択状態に戻ったり、値が変わったりしない。
  • 多少の参加ノードが途中で故障したり復帰したりしても、残ったメンバーで破綻なくプロトコルが続行され、選択に至ることができる。

一度選択された決定が、後からなかったことにされたり、ひっくり返されたりしない、というのは日常生活からも有用性さが納得できる話です。ここでは、例として「今日のお昼ご飯」を決定する例に挙げます。ここでは複数のメンバーが「ラーメン」「カレー」「ピザ」などの値を「提案」し、そのなかからひとつのメニューを「選択」し、そのメニューのお店にメンバー全員で一緒にご飯を食べに行きたいとします。(例題設定は @nobu_k さんのスライドからお借りしました)

メンバーからの提案を受けてメニューを決定するリーダーが一人だけいれば、「ラーメンにしよう」などとすぐに「選択」できて話は簡単です。しかし、このワンマンリーダーはプロジェクトの単一障害点なので、プロトコル中に突然の交通事故で病院に搬送されてしまうと、残されたメンバーは昼飯のメニューを選択できなくなります。そのような場合、他のメンバーをリーダーに昇格させ、新リーダーに「カレーにしよう」などと選択してもらうことができます。しかし、旧リーダーの傷が浅く、すぐに病院から戻ってきて、「ピザにしよう」などと選択を続行する可能性があります(Crash-Recovery故障モデル)。これは一度決まった決定がひっくり返り、値が一つより多く選択されてしまった状態と言えます。こうなると、メンバーのうち一部は新リーダーとカレーを食べに、一部は旧リーダーとピザを食べに行ってしまうなどの分裂状態に陥る可能性があり、全員で一緒にご飯を食べるという目的は達成されません(Split-Brain障害)。

Crash-RecoveryによるSplit-Brainを避ける手法として一般的なのは、リーダーに心拍計を取り付け、確実にリーダーが死亡してから新リーダーを昇格させるというものです。しかし、この死亡確認までの所要時間は、長すぎるとメンバーはいつまでも昼飯に出発できない(可用性の低下)し、かといって短すぎるとやはりSplit-Brainを起こします。(SRE本では他にもSTONITH(Shoot The Other Node in the Head = リーダーの頭を撃ち抜く)コマンドというより積極的な手法も紹介されていますが、こちらもネットワーク障害などでリーダーの機能を確実に停止できない可能性あり完全ではありません)

このように、従来一般的な単一の「マスター」ノードに値の「選択」をさせるアーキテクチャは、Crash-Recovery故障モデルでの耐障害性がありません。そこで、単一のリーダーノードによる独裁的な決定ではなく、分散された複数のノードによる「合意」に基づいた選択をすることによって、ノードが多少故障したりNWが一時的に分断しても値を選択し続けられるような耐障害性を獲得したいという動機があり、そのための分散合意アルゴリズムのひとつがPaxosです。

…というのが前提についての自分のおおざっぱな理解です。

Paxos

ここからPaxosの解説を始めます。

Paxos では、参加ノードを、値を提案する Proposer, 提案を受け入れる Acceptor, 提案の合意状態を観測して「選択」を判定する Learner の3種類のロールに分けます(Fig.1)。それぞれ何台いても良いし、同じサーバーが複数のロールを兼務してもよいです。 Proposerにより提案された値が過半数のAcceptorに受理(accept)されたとき、Learnerはその値を「選択」することができます(そのため、Acceptorノードは3つや5つなど、奇数が好ましい)。そのため、Paxosは、Acceptorの過半数が生き残っている限り、合意に至るプロトコルのどの段階でProposerが全滅したり復活したりしても破綻しません。

Fig.1: f:id:ono_matope:20180513201645p:plain

通常、Acceptorが値をAcceptするとLearnerに通知されるのでLearnerは素早く合意を検知できますが、パケットロスなどのため、Learnerが常に形成された合意を知りえるとは限らないので、そういう場合はLearnerからAcceptorにaccept状況を確認しにいく必要があります。

Paxosプロトコル

さて、この各々のAcceptorノードに、どのようなプロトコルで値をAcceptさせるかが肝心な点です。ナイーヴなプロトコルではPaxosが求める正しさや耐障害性は得られません。たとえば、単純な「各Acceptorは最初に自分に提案された値をAcceptする」プロトコルでは、同時に3つ以上の提案がなされたり(Fig.2)、合意形成前にAcceptorがFailしたりすると(Fig.3)、いずれの提案も過半数を獲得できずにプロトコルが終了しません。反対に、「最後に提案された値をAcceptする」プロトコルの場合は、いちど値が選択されたあとも、選択値が変化してしまい、目的を達成できません。

Fig.2 単純なFirst-Write-Winでは3つ以上のproposeで誰も過半数を握れなくなる f:id:ono_matope:20180513201841p:plain

Fig.3 単純なFirst-Write-Winではacceptorのfailで誰も過半数を握れなくなる f:id:ono_matope:20180513201902p:plain

Paxos では、Proposerが提案するそれぞれの提案に各提案にユニークな番号(便宜上、ここでは提案番号と呼ぶ)を付与し、フェーズ1 (prepare) / フェーズ2 (accept) のふたつのフェーズからなるプロトコルで提案をやりとりします。提案番号は、提案どうしで重複してはいけないので、時刻とノードIDの組み合わせなどで生成します。

フェーズ1: Prepare / Promise

Paxosのフェーズ1は、Proposerが値を提案するところから始まります。フェーズ1のルールは以下の通りです。

  • Phase 1. (a): Proposerは過半数のAcceptorに、生成した提案番号nとともにprepare(n)要求を送信する。
  • Phase 1. (b): Acceptorが既に応答したどのprepare要求の提案番号よりも大きな提案番号nとともにprepare要求を受信したら、nより小さな番号の提案は今後acceptしないという約束(promise)と、もしあれば現在までにacceptした最大の番号の提案とともに要求に応答する。そうでなければ、Acceptorはこのprepare要求を無視する。
    • 無視とあるが、これはタイムアウトと無視エラーレスポンスを同一視してよく、レスポンスを返さなくてもプロトコルは破綻しないという意味で、パフォーマンス最適化のために即時に無視レスポンスを返してもよい。

Proposerが過半数のAcceptorからpromiseレスポンスを受け取れたらフェーズ2に進みます。できなければ、もっと大きな提案番号を用いて最初からやり直します。少し複雑なので、以下に図解します。(Fig.4, Fig.5, Fig.6)

Prepare要求した提案番号がAcceptorの最大のpromise済み提案番号よりも大きかった場合

この場合、AcceptorはProposerにpromise応答を返します(Fig.4)。Acceptorがすでに何らかの提案をacceptしていた場合、その最大の提案をpromise応答に付与します(Fig.5)。

Fig.4 f:id:ono_matope:20180513202326p:plain Fig.5 f:id:ono_matope:20180513202334p:plain

Prepare要求した提案番号がAcceptorの最大のpromise済み提案番号よりも小さかった場合

AcceptorはPrepare要求を無視します。(Fig.6)

Fig.6 f:id:ono_matope:20180513183720p:plain

フェーズ2: Accept

  • Phase 2(a): proposerがprepare(n)要求に過半数のacceptorからレスポンスを受信したら、それらのAcceptorに、提案番号n、値Vのaccept要求を送信する。Vはレスポンスされた中で最大番号の提案の値か、もし何の提案も受け取っていなければ任意の値。
  • Phase 2 (b): accept(n,V)要求を受け取ったAcceptorは、もしnが今までpromiseした最大の提案番号よりも大きければ、その提案をacceptする。そうでなければ無視する。
    • Phase 1 (b) と同様、無視ではなくIgnoreレスポンスを返すことで素早くリトライできる。

過半数のAcceptorが同じ値をacceptすると、合意が成立したとみなされ、Learnerはその値を「選択」することができます。Phase 2(a) での値Vの選択がやや複雑ですが、ここがPaxosのキモです。

accept要求の提案番号が、Acceptorがpromiseした最大の提案番号以上だった場合

問題なくacceptされる。

Fig.7 f:id:ono_matope:20180513202431p:plain

accept要求の提案番号が、Acceptorがpromiseした最大の提案番号より小さかった場合

accept要求は無視される。

Fig.8 f:id:ono_matope:20180513202517p:plain

Paxosプロトコル

Paxos Made Smple で定義されているプロトコルはこれだけです。本当にこれだけのプロトコルで、「値がただ一つだけ選択される」が実現できるのか、実際に試してみよう。ここではProposer2ノード、Acceptor5ノード、Learner1ノードの集合を想定します。本当はラーメン・カレー・ピザの例を続けたかったですが、作図上の都合により、値V1,V2,V3と記して説明します。

一番かんたんなパターン

まずはすんなり選択に至るパターンを試してみましょう。Proposer P1が提案番号n1, 値V1 の提案(n1,V1)を提案したいとします。

フェーズ1

Fig.9: P1が過半数のAcceptor(ここではA1,A2,A3の3ノード)に提案番号n1をprepare要求する。全てのAcceptorがn1をpromiseしてくれたのでフェーズ2に進む。 f:id:ono_matope:20180513203218p:plain

フェーズ2

Fig.10: P1がpromise応答したAcceptorにaccept(n1,V1)要求をする。いずれのAcceptorもこれをacceptする。 f:id:ono_matope:20180513203234p:plain

過半数のAcceptorが同一の値をacceptしたので、Learner L1は値V1を「選択」できるようになりました。よかったですね。

選択後に他の値が提案されるパターン

ところで、Paxosの合意においては、いちど値が選択されたら、それ以外の値が選択されてはいけません。たとえば、この状態からProposer P2が、大きな提案番号n2で値V2を提案した場合、はたして値V2が選択されずにいられるでしょうか。ためしてみましょう。(n1より小さな提案番号を使った場合については、フェーズ1を満たせず棄却されることが自明なので省略する)

フェーズ1

Fig.11: P2が過半数のAcceptor A3,A4,A5に提案番号n2をprepare要求する。全てのAcceptorはpromise応答するが、(n1,V1)提案をaccept済みのA3はこの提案(n1,V1)をpromise応答に付与する。 f:id:ono_matope:20180513203253p:plain

提案(n1,V1)はすでに過半数のAcceptorによりacceptされているので、このとき、P2がどの過半数のノードの組み合わせにprepare要求しようとも、少なくともひとつのノードからは提案(n1,V1)つきのpromiseが応答されることになります。

フェーズ2

Fig.12: ルールPhase2(a)により、P2は、元々の提案(n2, V2)を、acceptorから受け取った値V1で書き換えた提案(n2,V1)をノードA3,A4,A5にaccept要求する。このaccept要求は、すべてacceptされるが、結局すでに選択済みの値と同じ値がacceptされるに過ぎず、Learnerが選択する値はV1のまま変わらない。 f:id:ono_matope:20180513203302p:plain

このように、各ProposerがPaxosプロトコルを守る限りにおいて、いちど過半数にAcceptされた値(V1)はくつがえされることはありません。

これで、Paxosがどういう挙動をするものなのかなんとなくイメージできたと思います。

衝突するパターン

もう少し複雑な、同時に提案が走ったり、途中でProposerが落ちたりするパターンを試してみましょう。

P1がA1,A2,A3に(n1,V1)をprepare要求します。 f:id:ono_matope:20180513162536p:plain

P2がA3,A4,A5に(n2,V2)をprepare要求します。 f:id:ono_matope:20180513162626p:plain

P1がA1,A2,A3に(n1,V1)をaccept要求しますが、A3が受理しなかったのでやり直しになります。 f:id:ono_matope:20180513162739p:plain

P2がA4,A5に(n2,V2)をaccept要求しますが、A3に要求する前にプロセスが停止し、離脱してしまったとします。 f:id:ono_matope:20180513162855p:plain

そこに新たなP3が登場し、(n3,V3)を提案しようと、A2,A3,A4にprepare要求します。すでにP1,P2による提案をacceptしているノードからは、それらの提案がpromise応答されます。 f:id:ono_matope:20180513163040p:plain

P3は、提案(n3,V3)の値をpromise応答中最大の値に書き換えた提案(n3,V2)をA2,A3,A4にaccept要求(n3,V2)します。これで過半数のノードが同じ値V2をacceptしたので、選択値はV2に確定します。よかったですね。 f:id:ono_matope:20180513163212p:plain

ざっくりと雰囲気でいうと、Paxosは

  • Acceptorは、promiseした番号以上の提案番号だけをacceptする
  • Proposerは、Acceptorがacceptしている最大番号の提案で自分の提案を上書きする

のルールの組み合わせによって、後から提案するProposerほど、すでに場に出ている提案を追認せざるを得ない仕組みで選択値の変動を防いでいるようです。

Multi-Paxosと複製ステートマシン

ここまでで、Paxosがいかにお昼ご飯を選択するのに便利なプロトコルか理解してもらえたと思いますが、実際のところ、Paxosそのものでは大したものは作れません。Paxosのうまみは、単一の値についての合意というよりも、これを順序つきのコマンド列についての合意に拡張し、複製ステートマシン(RSM)の保持に応用できる点にあります。(Paxos Made Simpleの最後の章はこの複製ステートマシンの実装についての記述になっています。この論文にはMulti-Paxosという名前は一度も出てきませんが、他の論文や資料ではこの手法をMulti-Paxosと呼んでいるので、たぶんこれがMulti Paxosなんだと思います)

ステートマシンとは、ある状態にコマンドを入力して出力と新しい状態を生成する概念上の機械です。例えば、銀行システムは口座の全ての集合を一つのステートマシンと考えることできて、預金の引き出しは、口座に引き出し金額以上の預金がある場合に引き出し金額を出力し、金額を差し引いた新しい状態に遷移するコマンドと表現できます。複製ステートマシンでは、初期状態から全く同じ操作を、同じ順番で適用することで、複数のレプリカの状態を同一に保つことができます。RDBのbin-logみたいですね。

こういったシステムでは、一般的に単一のマスターサーバー(マスターDB)が全ての更新順序を決定する役割を担いますが、冒頭で議論したように、単一マスターのシステムは可用性とSplit-Brainの問題に悩まされることになるので、やはり分散合意による高い耐障害性を獲得したいわけです。そのためにMulti-Paxosを使います。

Multi-Paxos の仕組み

Multi-Paxosは、「連続したコマンド入力を受け取り、コマンドが同じ順序で適用されるように合意していく」ことで複製ステートマシンを構築します。複製ステートマシンは、順番つけられた、個別のPaxosインスタンスの列を持ちます。i番目のPaxosインスタンスで選択された値は、i番目のステートマシンコマンドになります。クライアントがProposerにコマンドを要求すると、そのProposerはそのコマンドが何番目に実行されるべきコマンドかを決定し、その番号の値についてAcceptorに提案を行います。

f:id:ono_matope:20180513145042p:plain

ところで、Paxosである値を選択するには、Prepare/Acceptの2フェーズが必要で、アクセプターが5台の場合は最低6ラウンドトリップ、さらに他の提案と衝突した場合はリトライのため追加のラウンドトリップが必要です。これではステートマシンとして使うにはあまりにスループットが低いです。

そこで、Multi-Paxosでは特定のProposerをLeader Proposerに選出し、そのノードにコマンド順序の決定を行わせる最適化を行います。さらにPaxosプロトコルを拡張し、Proposerは「n番目以降の全てのPaxosインスタンスに対して、同じ提案番号でAcceptorにPrepare要求」ができるようになります。これらの拡張により、リーダープロポーザは、リーダー選出直後などのレアなイベント時をのぞいてフェーズ1を省略でき、さらに提案同士の衝突による性能低下も回避できます。 

f:id:ono_matope:20180513203859p:plain

リーダーを選出と聞いて、Sprit-Brainが起きないか心配してしまうかもしれませんが、あくまで性能最適化のためのリーダーに過ぎず、もしリーダー以外のノードがコマンドを提案したとしても(性能は低下しますが)、個々のPaxosにより正しさは守られているので、選択値が壊れたりはしません。

Multi-Paxos リーダー交代時の挙動

リーダープロポーザの故障などで他のプロポーザにリーダーシップを切り替える場合を考えてましょう。まず、新任リーダーは、コマンドを受け入れる仕事を始める前に、決定済みの全てのコマンドの決定を知る必要があります。Multi-Paxosでは基本的にProposerはLearnerも兼ねるので、リーダーを任された時点でほとんどの選択はLearnしているはずですが、単一Paxosの項で少し触れたように、Acceptor側の全ての合意が即座に全てのLearnerに通知されるわけではないので、認識に欠けがある可能性があります。

では、この新Leader Proposerがどうやって完全な決定を知るかというと、旧リーダーのものより大きな提案番号で「n番目以降の全てのPaxosインスタンスに同じ番号でPromise要求」コマンドを使います。このコマンドを受けたAcceptorは、promiseとともに「n番目以降の全てのAccept値」をレスポンスするので、これで新Leader Proposerは全ての未認知コマンドの選択値を入手できます。ちなみに、この方法でも選択値が確定できなかった虫食いコマンドのインデックスに対しては、なんと「No-opコマンドを提案」してスキップします。歴史改変じゃないかと心配になりますが、上記プロトコルで選択値が得られなかったインデックスのコマンドは、過去においても一度も選択されていなかったコマンドという保証があるので大丈夫ということのようです(つまり、プロトコルが正しく動いている限り虫食いは現れないはず)。

Multi-Paxos その他

そのほか、Multi-Paxosについてはメンバーシップ変更時は、メンバーシップの変更そのものをひとつのコマンドとして記録すればいいよとか、Leader Proposerは、提案が成功すれば、Learnを待たずに次の提案に移って良いけど、未Learnのコマンドの個数はα個までに制限すると良いとか、細かい議論がいくつかあります。

PaxosとRaft

ここまでPaxosとMulti-Paxosの動作を見てきました。しかし、Paxosは現在は以前ほど流行っていないようです。機能と性能がほぼ同じだがシンプルで理解しやすいとする新しい分散合意アルゴリズムのRaftが2013年に発表されました。また、Paxos Made Liveでは、Paxosは現実の分散システムに実装するためには論文に書かれていないことが多く、最終的なプロダクトは「未証明のプロトコル」になってしまうと指摘されています。実際、定評のあるオープンソース製品でPaxosを実装したものはあまり聞きません。それこそCassandraのLWTやFoundationDBくらいでしょうか。

一方、Raftは(まだ読んでいないのですが)、リーダーエレクションなど、実装に必要な詳細が論文化されているため多様なオープンソース実装が存在し、特にGo言語向けの github.com/coreos/etcd/raft は、etcdだけではなくCockroachDB、TiDBにも採用されるなど、新時代の分散DBの興隆を支えている感があります。そういうわけで、次はRaftを勉強したいと思います。

Raft Consensus Algorithm

参考文献

SRE サイトリライアビリティエンジニアリング ―Googleの信頼性を支えるエンジニアリングチーム

SRE サイトリライアビリティエンジニアリング ―Googleの信頼性を支えるエンジニアリングチーム

Googleを支える技術 ?巨大システムの内側の世界 (WEB+DB PRESSプラスシリーズ)

Googleを支える技術 ?巨大システムの内側の世界 (WEB+DB PRESSプラスシリーズ)

2016年を振り返って

あけましておめでとうございます。

2016年を振り返ってみます。

アジェンダ

  • 仕事:自分で立ち上げたプロダクトをローンチできた
  • 英語:海外出張がたて続き、英語能力の不足を痛感した
  • OSS:GoとCassandraにぼちぼちパッチを送っている
  • 抱負

仕事

取り組んでいたGoのプロダクトを1月にきちんとローンチ出来た。以前参加していたプロジェクトではいろいろあって挫折を味わったが、これはその反省から自分で立ち上げて、技術的には完全に統括し、自分がこうあるべきだと信じるやり方で開発したプロダクトだったので、実際に動くコードとなり価値を生み出すことができたというのは何より嬉しい。遅すぎるかもしれないが、やっとソフトウェアエンジニアとして一人前になれたと感じた。社内プロダクトだが、どこかで成果を外に出したい。

(そういえば自分が8年前に今の会社に入った時、配属先決定面談で上司に「技術力を身につけて、一人前になりたい」と言った気がする。では一人前のエンジニアとは何なのか自省してみると、「多くの人に価値を認められるソフトウェアを生み出すことができる人間」だろうか。自分はそうなりたいんだったなということを思い出したのでここに書いておく。元旦だし)

今年の後半はいろいろあって海外出張が立て続いた。9月はサンノゼのCassandra Summit、11月はラスベガスのAWS re:Inventに参加した。予備日にシリコンバレー巡りをしたりヨセミテ公園まで足を伸ばしたり、re:Inventではグローバル企業のパワーを間に当たりするなど、楽しみもしたし得るものが多かった。

英語

2015年から英会話レッスンを始めたのはこういった機会があると予期してのことだったので、その自己投資は正しかったわけだけども、一方でその投資が足りなかったのか、ネイティブのスピーチやミーティングについていくにはもっと勉強が必要だと痛感する機会ともなった(ちなみに英会話レッスンは5月まで続け、それ以降は社内で始まった英会話レッスンを受けていた)。

最近は HiNative Trek と TEDict をぼちぼちやっている。今年は英文法の基礎を固めなおすのとディクテーションを強化したい。

trek.hinative.com

OSS、その他活動

Go

2015年はGoの net/http にコントリビュートできたのが自分の中で大きかったが、2016年は「go getをGithub Enterpriseネイティブに対応させるパッチ」を送って Go1.9 Milestoneに入れてもらった。 2月にGo1.8がリリースされたらレビューが開始され、うまくいけば1.9に入るので、もうドメイン末尾に.gitをつけたりgo getを改造したりせずに、普通にGHEでGoが使えるようになる。「社内GHEでGoパッケージの管理どうするの?」という、悩ましい問題に直面した時、問題そのものにアタックして問題を消し去る、というアプローチが推奨され、実際にそれを行う敷居が低いのがGoのいいところだな、と思う。

github.com

OSSではないが、deeeetさんに呼んでもらい、Go 1.7 Release PartyでContextの話をできたのも楽しかった。実はContextパッケージが出てきた時点では「これ対応する必要あるのかなー」と懐疑的だったので、同じように懐疑的な人たちにContextの良さを伝えられたら良かったと思う。仕事でもその後Contextをヘビーに使うことになったので、そのリファレンスとしてとても役に立った。

Cassandra

Goの他に、2016年はCassandraにもいくつかのパッチをコントリビュートできた。ひとつは、system_tracesで取得可能なクエリのトレース記録に、プリペアドステートメントのCQL文を含めるようにしたというもの。Cassandraパフォーマンスチューニング中にカッとなって実装した。

もう一つは、Cassandra 3.7ではBig Partitionの扱いに大きな最適化が導入されたが、その最適化後のコードを読んでいたら明らかに無駄な配列のアロケーションが存在したので、そのアロケーションを削除して30%程度の高速化(Big Partition条件下において)をしたというもの。やったことは数行のコードを削除して幾つかの代替実装のベンチマークとともに提示しただけで、ほとんどこぼれ玉を拾ったみたいな話だけど、データベースを速くする仕事というのは実は昔から憧れるもののひとつだったので、少し夢がかなったような気持ちがある。

[CASSANDRA-12731] Remove IndexInfo cache from FileIndexInfoRetriever. - ASF JIRA

抱負

2017年の抱負はこんな感じです。仕事は仕事でやるとして

  • 一人暮らしを始めて今年で4年になり少し手狭になってきたこともあり、今年の契約更新までに引越しをしたい。そしてドラム式洗濯乾燥機を導入したい。
  • プレゼンと質疑応答ができる程度に英語力を上げたい。一方的にしゃべるだけなら練習すればなんとかなるけど、質疑応答の壁が高い。

本年もよろしくお願いします。

GoとgRPCでKVS的なものを作ってみた

正月で時間があったので、以前から触ってみたかったgRPCをGo言語から使い、キー・バリュー・ストアのようなものを作ってみた。

KVSといっても、GoのmapへのGet/Put/Delete/ScanをgRPC経由で叩けるようにしただけのもの。それだけだとあまり面白く無いので、gRPCらしく、Watch機能をつけてmapへの更新を監視できるようにした。

github.com

f:id:ono_matope:20160105002700g:plain

個人的には、HTTP/1.1 + JSON APIと比べた時のgRPC(HTTP/2 + ProtoBuf)のメリットや違いが気になっていたので、そのあたりを気をつけながら書いた。

開発の手順

サービス定義

まずはProtocol Buffers 3でKVSのサービスを定義する。サンプルを見ながら適当に書いた。

grpc-kvs/grpc-kvs.proto at master · matope/grpc-kvs · GitHub

syntax = "proto3";

package proto;

service Kvs {
  rpc Get(GetRequest) returns (GetResponse) {}
  rpc Put(PutRequest) returns (PutResponse) {}
  rpc Delete(DeleteRequest) returns (DeleteResponse) {}
  rpc Range(RangeRequest) returns (stream Entry) {}
  rpc Watch(WatchRequest) returns (stream Entry) {}
}

message Entry {
  string key = 1;
  string value = 2;
}

message GetRequest { string key = 1; }
message GetResponse { string value = 1; }

message PutRequest { string key = 1; string value = 2; }
message PutResponse {}

message DeleteRequest { string key = 1; }
message DeleteResponse {}

message RangeRequest { string startKey = 1; int32 maxKeys = 2; }

message WatchRequest { string prefix = 1; }

メソッドの引数と戻り値はmessageで定義した構造体である必要があるらしい。

RangeとWatchのreturnsにstreamとあるが、これはServer-side Streamingの宣言で、レスポンスをEntry構造体のストリームにすることができる。ストリームは非常に長いレスポンスの返却にも使えるし、Server-Sent Eventのようにサーバープッシュ用途にも使える。リクエストもストリーム化することができる(Client-side Streaming / Bidirectional Streaming)。

コード生成

上のkvs.protoから、protocコマンドでkvs.pb.goを生成する。

protoc  --go_out=plugins=grpc:. ./grpc-kvs.proto

ただし、事前にprotocol buffers 3.0 (未リリースなので、google/protobuf · GitHub からダウンロードしてインストールする必要がある)と github.com/golang/protobuf/protoc-gen-go をgo install しておく必要がある。

サービス実装

protocに成功すると、kvs.pb.goに下のようなサーバー用interfaceが定義されるので、これを実装してやれば良い。

grpc-kvs/grpc-kvs.pb.go at master · matope/grpc-kvs · GitHub

// Server API for Kvs service

type KvsServer interface {
    Get(context.Context, *GetRequest) (*GetResponse, error)
    Put(context.Context, *PutRequest) (*PutResponse, error)
    Delete(context.Context, *DeleteRequest) (*DeleteResponse, error)
    Range(*RangeRequest, Kvs_RangeServer) error
    Watch(*WatchRequest, Kvs_WatchServer) error
}

サーバー側実装はこのようになった。grpc-kvs/main.go at master · matope/grpc-kvs

type kvsServer struct {
    elements map[string]string
    mu       sync.RWMutex
    chans    map[chan pb.Entry]struct{}
}

func NewKvsServer() *kvsServer {
    return &kvsServer{
        elements: make(map[string]string),
        chans:    make(map[chan pb.Entry]struct{}),
    }
}

func (s *kvsServer) Get(ctx context.Context, r *pb.GetRequest) (*pb.GetResponse, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    if val, ok := s.elements[r.Key]; ok {
        return &pb.GetResponse{
            Value: val,
        }, nil
    }
    return &pb.GetResponse{}, grpc.Errorf(codes.NotFound, "element not found value=[%s]", r.Key)
}

func (s *kvsServer) Put(ctx context.Context, r *pb.PutRequest) (*pb.PutResponse, error) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.elements[r.Key] = r.Value

    // Notify updation
    for c := range s.chans {
        c <- pb.Entry{Key: r.Key, Value: r.Value}
    }
    return &pb.PutResponse{}, nil
}

func (s *kvsServer) Delete(ctx context.Context, r *pb.DeleteRequest) (*pb.DeleteResponse, error) {
    s.mu.Lock()
    defer s.mu.Unlock()
    delete(s.elements, r.Key)

    // Notify deletion
    for c := range s.chans {
        c <- pb.Entry{Key: r.Key}
    }

    return &pb.DeleteResponse{}, nil
}

func (s *kvsServer) Range(r *pb.RangeRequest, rs pb.Kvs_RangeServer) error {
    s.mu.RLock()
    defer s.mu.RUnlock()

    // sort and filter  keys of elements
    keys := make([]string, 0, len(s.elements))
    for k := range s.elements {
        if k < r.StartKey {
            continue
        }
        keys = append(keys, k)
    }
    sort.Strings(keys)

    for _, k := range keys {
        if err := rs.Send(&pb.Entry{Key: k, Value: s.elements[k]}); err != nil {
            return err
        }
    }
    return nil
}

func (s *kvsServer) Watch(r *pb.WatchRequest, ws pb.Kvs_WatchServer) error {
    ech := make(chan pb.Entry)
    s.mu.Lock()
    s.chans[ech] = struct{}{}
    s.mu.Unlock()
    fmt.Println("Added New Watcher", ech)

    defer func() {
        s.mu.Lock()
        delete(s.chans, ech)
        s.mu.Unlock()
        close(ech)
        fmt.Println("Deleted Watcher", ech)
    }()

    for e := range ech {
        if !strings.HasPrefix(e.Key, r.Prefix) {
            continue
        }
        err := ws.Send(&e)
        if err != nil {
            return err
        }
    }
    return nil
}

通常のRPCは、フレームワーク経由でnet/httpのハンドラを書くの感覚と大して変わらない。

ただしコード生成によりリクエストフィールドが全て静的にアクセスできるので、ボイラープレートコードが不要で良い。

REST APIでいうところの404などのアプリケーションエラーコードは、grpc.Errorfを使い、 https://godoc.org/google.golang.org/grpc/codes に定義されているエラーコードを渡してやるのが流儀らしい。

Sever-side Streaming RPCは、ハンドラに専用のサーバーが渡されるので、そのサーバーにSendしてやることでストリームの要素を送出する(Range, Watch参照)。

サーバー起動部分はこれだけ。

func main() {
    lis, err := net.Listen("tcp", port)
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    s := grpc.NewServer()
    pb.RegisterKvsServer(s, NewKvsServer())
    s.Serve(lis)
}

クライアント実装

grpc-kvs/main.go at master · matope/grpc-kvs · GitHub

protocにより完全なクライアントコードが生成されているので、クライアントの接続部分はこれだけでよい。

func main() {
    conn, err := grpc.Dial(addr, grpc.WithInsecure())
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }
    defer conn.Close()
    c := pb.NewKvsClient(conn)
 

あとはKvsClientに実装されたメソッドを使えば良い。

所感

コンポーネント間通信をREST APIで設計する場合、そこそこ綺麗に書いたとしても、どうしても認証・ハンドラルーティング、リクエスト・レスポンスのエンコーディングなど、ボイラープレートコードは発生してしまう。

また、メソッドを増やすたびにURLやBody, ステータスコードなどREST表現の全体としての整合性を考慮しなければならず、しかも使用しているURLルータによっては変更を余儀なくされたりとつらさを感じていた。そんな理由でRPCに魅力を感じてはいたが、できること・できないことがいまひとつ分からず、乗り換えられずにいた。

gRPCを試してみて、サービスの定義さえしてしまえばサーバ・クライアント実装が自動生成されるため、ボイラープレートコードも発生せず、本質的なロジックに集中して書き進めていけるのは快適だった。コードの質もよくなっていると思う。そのうえで、HTTPハンドラに出来てgRPCに出来ないことはあまりなさそうということも分かった。例えば大きなファイルのやり取りはチャンク化してStreaming APIで流せば大丈夫そうとか。

RPCの使用にはコネクティビティの心配もあるが、gRPCは主要な多くのプログラミング言語へのコード生成に対応しているので、実際に問題なることはなさそう。むしろC++あたりだと普通のREST APIよりクライアント/サーバーの実装が楽になって結果的にRESTより相互接続性が上がりそう。

性能

似たようなHTTP/1.1 + JSON版実装を作り、id:lestrrat さんのベンチマーク実装 http://lestrrat.ldblog.jp/archives/43568967.html を参考に、Getのパフォーマンスを比較してみた。

go バージョン 並列数 gRPC HTTP/1.1+JSON
go1.5.2 100 16161.13 jobs/sec 37361.61 jobs/sec
go1.5.2 1 6705.33 jobs/sec 10151.33 jobs/sec
gotip 100 21528.42 jobs/sec 37931.31 jobs/sec

(サーバ・クライアント同居での雑な計測です)

遅い…。便利であるとはいえ、スループットが生REST時の半分以下になるのはちょっと厳しい。gotipでビルドしたところかなりパフォーマンスが改善しているので、今後のgoの実装の改善を期待しつつ、パフォーマンスが重要でないところから導入したほうがいいかもしれない。

参考

2015年を振り返って

あけましておめでとうこざいます。

なんかみんな振り返っているので、少し出遅れましたが2015年を振り返りたいと思います。

  • アジェンダ
    • 英会話教室に通い始めた
    • Goにコントリビュートをした
    • 仕事で作っているプロダクトがリリースできなかった

英会話教室に通い始めた

2014年はGo言語に全てをつぎ込んだ年だったので、2015年は英語を頑張ろうと思っていました。5月のTOEIC試験の結果が700点ラインを上回ったので、会社の教育補助を受けて英会話教室のGabaに通い始めました。

Gabaではほぼ毎週、40分のマンツーマンのレッスンを受けています。レッスンは雑談とテキストを主体に進みますが、雑談で話題を無茶振りされたりはしないので、無理なく進められます。趣味の話で盛り上がったりもあり、アニメ好きの女性講師とシン・ゴジラの話題で盛り上がるのはこう…いいよね…みたいなのもあります。

英会話教室は高価でなかなか踏ん切りがつかないので、こういう機会を持ててよかったです。補助で行けるのは10回までなので、その後は自費で継続しています。

実際にどれくらい英語力が向上しているのかは分かりませんが、ミーティングで突然外国人エンジニアへの説明を振られた時も慌てずに英語で対応できたし、「ああこれくらい喋れれば海外でも生活できるかもなー」みたいな肌感を得られたのは大きな収穫です。相変わらず映画の英語は全く聞きとれないけど対面なら案外大丈夫

Goにコントリビュートをした

技術面で2015年一番よかったのは、Goの標準ライブラリにコントリビュートをしたことです。

net/http: Client support for Expect: 100-continue · Issue #3665 · golang/go

仕事でGoのHTTPクライアントを使っていて、net/http.ClientがExpect: continue ヘッダに対応していないことに気づきました。この仕様はPUT/POSTリクエストのリトライ処理を実装するは重要なものですが、リクエストとレスポンスの例外的なやりとりが含まれ、実装は面倒なものでした。そのせいか、2012年にissueがopenされて以降、実装に至る動きが見られませんでした。

そこで、HTTPのRFCを調べ、net/httpの実装を読み込み、この機能を実装してパッチを送りました。ちょうどGo1.5リリースのためのコードフリーズ期間中だったのでやや期間はあきましたが、Bradfitzに(!)丁寧にコードをレビューしてもらい、無事本体にマージしてもらえました。レビューをしてもらえた時は「Bradfitzが俺のコードを見てくれた!What a lovely day!!」とウォーボーイズみたいな気持ちになっていました。

仕事で使っていて大好きな言語にコードを取り込んでもらえて、しかも自分にとってヒーローのようなプログラマにコードを見てもらえるというのは最高に嬉しい経験でした。

仕事

2015年は、以前から準備していた(Goを使った)プロダクトの開発が本格化し、メンバーも増えて自分の役割も変化していった一年でした。

相変わらずマネジメントはしませんが、技術的には責任を持っているので、どうやってGo経験の浅いメンバーを短時間で訓練して仕事を任せられるようにするか、というのが大きなテーマでした。

序盤では鬼コーチよろしく徹底的にコードをレビューして、主にGoのイディオム面からの指摘や代替実装を教えるという方針を取りました。この方法は大量の駄目出しが必要になりメンバーへの心理的な負荷が大きいのですが、終盤はレビューもそれほど必要なくなったので、結果的には良かったのかなと思います(どのみち性格上変なコードにツッコミを入れずには済ませられないし)。

そのプロジェクトは本当は昨年末リリース予定だったのですが諸事情により2016年1月に持ち越しになってしまったのが心残りです。なので2016年の抱負はまずリリースと、成果を使って色々と展開していきたいです。

本年もよろしくお願いします。

RFC的に、HTTPヘッダってどんな値を使えるんでしたっけ?のメモ

Web APIを開発していると、HTTPのヘッダについてRFCにおける規約を確認しなきゃいけない場面がたまにあるので、今回調べたことをまとめた。

HTTP/1.1のRFC

HTTP/1.1のRFCといえば、長らくRFC2616であったが、2014年にRFC7230〜7239が発行され、2616は廃止された。

両者の変更点については、RFC 723xの付録に記述されているので参照のこと。Content-MD5が廃止されたり、ちょいちょい面白い。文章としても723xの方が分かりやすくなっているので、一度目を通しておくことをお勧めする。

RFC的に、ヘッダの名前と値って何が使えるんでしたっけ?

RFC 7230のABNFによると

     header-field   = field-name ":" OWS field-value OWS

     field-name     = token
     field-value    = *( field-content / obs-fold )
     field-content  = field-vchar [ 1*( SP / HTAB / field-vchar )
                      field-vchar ]
     field-vchar    = VCHAR / obs-text

     obs-fold       = OWS CRLF 1*( SP / HTAB )
                    ; obsolete line folding
                    ; see Section 3.2.4

かつ、

  • token
    • "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
    • (VCHARからセパレータを抜いたもの)
  • VCHAR
    • %x21-7E (任意のUS-ASCII 任意の印字可能なUS-ASCII)
  • obs-text
    • %x80-FF (US-ASCII以外のレンジ)
    • ただし、obs-text(及びobs-fold)は廃止された文法である

なので、

箇所 利用可能文字種
ヘッダ名(field-name) token (アルファベット、数字、一部の記号)
ヘッダ値(firld-value) US-ASCII文字 印字可能なUS-ASCII

となる。念のためUS-ASCII 印字可能なUS-ASCII(%x21-7E)は以下の表の21-7Eだ。 (man asciiより)

     00 nul   01 soh   02 stx   03 etx   04 eot   05 enq   06 ack   07 bel
     08 bs    09 ht    0a nl    0b vt    0c np    0d cr    0e so    0f si
     10 dle   11 dc1   12 dc2   13 dc3   14 dc4   15 nak   16 syn   17 etb
     18 can   19 em    1a sub   1b esc   1c fs    1d gs    1e rs    1f us
     20 sp    21  !    22  "    23  #    24  $    25  %    26  &    27  '
     28  (    29  )    2a  *    2b  +    2c  ,    2d  -    2e  .    2f  /
     30  0    31  1    32  2    33  3    34  4    35  5    36  6    37  7
     38  8    39  9    3a  :    3b  ;    3c  <    3d  =    3e  >    3f  ?
     40  @    41  A    42  B    43  C    44  D    45  E    46  F    47  G
     48  H    49  I    4a  J    4b  K    4c  L    4d  M    4e  N    4f  O
     50  P    51  Q    52  R    53  S    54  T    55  U    56  V    57  W
     58  X    59  Y    5a  Z    5b  [    5c  \    5d  ]    5e  ^    5f  _
     60  `    61  a    62  b    63  c    64  d    65  e    66  f    67  g
     68  h    69  i    6a  j    6b  k    6c  l    6d  m    6e  n    6f  o
     70  p    71  q    72  r    73  s    74  t    75  u    76  v    77  w
     78  x    79  y    7a  z    7b  {    7c  |    7d  }    7e  ~    7f del

ヘッダに日本語を突っ込みたいんですけどどうにかならないですか?

RFC2616時代の見解はStudying HTTPさんが詳しい。

RFC2616では、field-valueはTEXT(制御文字以外のOCTET)が使えるが、TEXTでUS-ASCII外の文字を使っていいのは、RFC2047形式でエンコードされた時のみである。2047形式というのはこういうやつ。 要するに生のUTF-8なんかはヘッダ値に載せてはいけないのだ。

でもそれだと Content-Disposition でのファイル名指定とかでこまるよね、ということでRFC5987でUTF-8エンコードするための拡張が定義されている。だけどこれは、それこそContent-Dispositionヘッダなどのみで使える、=つなぎのパラメータに対して適用するものなので、いつでも使えるわけではない。

Rails - Content-Disposition の日本語問題 - Qiita より

 Content-Disposition: attachment; filename*=UTF-8''foo-%c3%a4-%e2%82%ac.html

一方、RFC7230ではどうなのかというと

新たなヘッダ値の構文は(中略)通例的に,US-ASCII 文字の範囲に拘束される。 より広範囲の文字を必要とするヘッダは、[RFC5987]にて定義されるものなどの,符号化方式を利用できる。 RFC 7231 — HTTP/1.1: Semantics and Content (日本語訳)

他には、

歴史的に,HTTP は、 ISO-8859-1 charset [ISO-8859-1] のテキストによるヘッダ内容を許容し、他の charset のサポートは, [RFC2047] 符号化方式の利用を通してのみ許容してきた。 実施においては、大部分の HTTP ヘッダ値は[ US-ASCII charset [USASCII] のサブセット ]のみを利用している。 新たに定義されるヘッダは、そのヘッダ値を,US-ASCII オクテットに制限するべきである。 受信者は、[ ヘッダ内容 内の他のオクテット( obs-text ) ]を,不透明なデータとして扱うべきである。 RFC 7230 — HTTP/1.1: Message Syntax and Routing (日本語訳)

などとある。RFC2616との違いは、利用可能な符号化方式がRFC2047に限定されず、RFC5987など、他のエンコードが認められていることくらいで、基本的にUS-ASCIIオクテットに制限「すべき」とのことだ。とはいえ既存の符号化手法は利用箇所が限られているし、どうしても(独自の)ヘッダでマルチバイトを送りたいなら、適当にパーセントエンコーディングなどで送ればいいという感じだろうか。

RFC的に、複数の同名ヘッダってどう扱うのが正解?

こういうやつ。

X-Foo: Bar
X-Foo: Baz

RFC7230 Section 3.2.2 によると、

  • 受信者は、複数の同名ヘッダがある時、ヘッダ値を現れた順にカンマで結合した一つのヘッダとして扱ってよい。
  • 送信者は、カンマ区切りで結合してもよい時以外、複数の同名ヘッダを送信してはならない。
  • ただしSet-Cookieヘッダは同じレスポンスに複数回現れることが多く、カンマ結合すると意味論が変わってしまうので例外である。

RFC的に、ヘッダの区切りの改行ってCRLF(\r\n)でしたよね

そうとも限らない。実際にはCRやLF単体がヘッダ区切り文字として認識されている。

  • RFCはLF単体をヘッダ区切り文字として認めている
  • 多くのブラウザはRF単体を区切り文字として認めている

参考 LWSとHTTPヘッダインジェクション | MBSD Blog

RFC的に、まさかヘッダの値って改行できないですよね?

実は、RFC2616ではLWSという仕様を使うことで、複数行に渡るヘッダ値を表現できた。 以下の例は、「LWS(改行+スペースまたはタブ)」により、2行に渡るX-Fooヘッダ値を表現できる。

X-Foo: Foo
[sp] X-Bar: Bar

だが、〜IE11などのブラウザはLWSをそのように扱わず、2行目を別のヘッダとして扱うため、レスポンス分割攻撃などが可能であった。そのような問題があったため、RFC7230では、LWSの仕様はobs-foldに改められ、(message/httpメディアタイプを除き)非推奨になった。

  • 送信者は、obs-foldに合致するfield-valueを生成してはならない
  • 受信者は、400 Bad Requestを応答しリクエストを却下するか、obs-foldを一個以上のSP(空白文字)で置換しなくてはならない
  • UAは、obs-foldを一個以上のSP(空白文字)で置換しなくてはならない

結論:できなくなりました

ちなみにGoのnet/httpはLWSにどう対応しているか?

HTTPリクエストの送信、HTTPレスポンスの送信において、ヘッダのfield-valueに含まれる改行文字(CR, LF)が各々スペースに置換されている。(http.Header.WriteSubset() at net/http/header.go) HTTPリクエストの受信時は?→LWSを処理する実装は見当たらないので単に無視されるようだ。(RFC7230の指示とは違うが) 行頭文字がSPであるヘッダがやってきた時にどうなるか?気になるので後で調べる

RFC的に、クライアントから変なヘッダを受け取ったサーバーはどうすればいいの?

PUTについては記述がある

[ PUT 要請内に受信された,認識できないヘッダ ]は、無視する(すなわち,リソース状態の一部として保存しない)べきである。 RFC 7231 — HTTP/1.1: Semantics and Content (日本語訳)

2015/8/4 追記

ヘッダ名に利用可能な文字種について修正しました。