ざっくり理解する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 追記

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

Goのflagでダブルダッシュ(--hoge)なフラグを許可する

それヘルパー関数書くだけで出来ますよ!

と言う訳で書いた

double-dash hyphen

この実装が実現するのは

  • "-hoge" と "--hoge"を変数hoge *stringに受け取れる
  • "-str"と"--str"を変数str stringに受け取れる
  • UsageはflagのデフォルトFlagSetのものを使っているので、ダブルダッシュ版のFlagはUsageに出てこない
./flag2 -h
Usage of ./flag2:
  -hoge="default-value": First string option
  -str="default-value": Second string option

GoのHTTPサーバーを80番や443番ポートでListenする方法を調べた

1024以下の番号のポートでサーバーをListenするには、rootで実行する必要がある。それはもちろん嫌なので、GoのWebサーバーを80番ポートでサービスするためにどういう方法があるのか調べた。

root権限で起動してListenしてからSetuidで権限降格

最初はroot権限で起動して、80番ポートをListenしてからsetuidで権限降格するやりかた。 Node.jsでもそんな感じだったし、まあそういう感じだろうと当たりをつけて調べたら、どうもLinuxではうまくいかないらしい。

コメント欄でのid:methaneさんの指摘によると、Linuxのsetuidシステムコールは、実行したスレッドにしか効力が無い。Goは自動的に複数スレッドに分散されるので、権限降格されないまま実行されるGoroutineが出てくる事になり、望ましくない。さらにGo 1.4ではLinuxでのSetuid/Setgidが禁止されるらしい。Oh...

という訳で他の方法を探す。

rootで起動した起動スクリプトからポートのfdをもらってExec

Go で 1024 以下のポートを Listen するアプリを作る - methaneのブログ

id:methaneさんの実装。外部スクリプトで80番をListenして、そのポートのfdをコマンドラインオプションで与えてGoのサーバーをexecする。サーバの方は、ポートで起動する場合はgoprogram -port 8080、fd受け渡しの場合はgoprogram -fd 4みたいな感じで起動できるように実装を変更する必要がある。外部スクリプトの方はCircusのようなツールを利用することもできるようだ

トリッキーな起動オプションの実装が追加されるのは若干気持ち悪い気がするが、それさえ気にしなければ実装コストも少ないしパフォーマンスペナルティも多分ない。ソケット管理ツールを書くなり、既存のツールを学習するコストはそこそこ面倒くさそうで気になる。

iptablesでポートフォワーディングする

この方法であればサーバー側実装には全く手を入れる必要は無い。起動用のツールをrootで立ち上げる必要が無いのでクリーンな感じもする。ひとつのサーバーを動かすためにシステム設定に手を入れるのは気が引けるとか、運用コストが上がりそうとか思うが、Chefでiptablesの書き換えすればOKみたいな環境であれば手軽なのでは。

Deploying Go servers with Docker - The Go Blogとかを読んでも、Dockerを使うならDockerにポートフォワーディングを任せればいいみたいな感じがするので、環境側でなんとかする方向はありっぽい。

ローカルにHTTP層のReverse Proxyを使うという手もある。この場合プロキシを挟むのでパフォーマンスペナルティや、プロキシに使うサーバとの相性などが気になる。

実際、Nginxを挟む事によるパフォーマンスペナルティはちょっと無視できない。

あるいは今気づいたけど、もともとReverse Proxy用のサーバを別に立てるつもりなら、そもそも気にしなくてよい問題ではある。

GoバイナリにウェルノウンポートをListenするケーパビリティを設定する

ケーパビリティ で権限を少しだけ与える - いますぐ実践! Linuxシステム管理 / Vol.183

ケーパビリティとは、ファイルまたはスレッドに対して、root権限の一部を付与したり剥奪したりできるもの。実行ファイルに対してsetcapコマンドで任意の権限を与えられる。GoWebの人たちはこの方法を使っているらしい。この場合は以下のようになる。

setcap 'cap_net_bind_service=+ep' /path/to/program

Why not use Goweb? · Issue #49 · stretchr/goweb · GitHub

簡単にcapabilityを試してみた。通常のlsでは/root配下が覗けないが、読み込み時パーミッションチェックをスキップするCAP_DAC_READ_SEARCHケーパビリティをlsのコピーに付与したところ、sudoもなしに一般ユーザーで/root配下が参照できる。

[vagrant@localhost ~]$ ls /root
ls: ディレクトリ /root を開くことが出来ません: 許可がありません
[vagrant@localhost ~]$ cp /usr/bin/ls ./ls.copy
[vagrant@localhost ~]$ getcap ./ls.copy
[vagrant@localhost ~]$ sudo setcap 'CAP_DAC_READ_SEARCH=+ep' ./ls.copy
[vagrant@localhost ~]$ getcap ./ls.copy
./ls.copy = cap_dac_read_search+ep
[vagrant@localhost ~]$ ./ls.copy /root
anaconda-ks.cfg
[vagrant@localhost ~]$ 

ただ、このcapability、手元で検証した限りではcpなどで複製すると権限は失われてしまうようなので、デプロイ先の環境でsudo setcapしてやる必要があるようだ。

まとめ

今回候補に挙げた手法は以下の通り。

  • Listen後にsetuidで権限降格する
    • Linuxでは問題がある
  • 起動スクリプト(カスタムメイド or Circus)からfdをもらう
    • ソケット管理ソフトウェアを用意する必要がある
    • サーバー側にもfdによるListenに対応するコードを追加する必要がある
  • iptablesなど、OSやコンテナ側でポートフォワーディングする
    • システム側に手を加える必要がある
  • ローカルでProxyを立てる
    • パフォーマンスペナルティがある
  • ケーパビリティを設定する
    • デプロイ先ホストにバイナリを配置後、sudo setcapする必要がある。

この中では、ポートフォワーディングかケーパビリティが筋が良さそうな気がしている。せっかくシングルバイナリデプロイができるGoなのだし、できればバイナリで完結する手法でなんとかしたいが、そういう方法は今は無いようだ。