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なのだし、できればバイナリで完結する手法でなんとかしたいが、そういう方法は今は無いようだ。

Golangで関数をグローバル変数に代入してテスト時にスタブする

こんにちは、小野マトペです。タイトル全部です。

昨日、現在時刻によって条件分岐するロジックを含むGoプログラムを書いていて、どうテストするか困ったのですが、うまい(と思う)やり方を思いついたのでここに書いておきます。

書いていたのはまあだいたいこういうソースコードです。

// main.go
package main

import (
        "fmt"
        "time"
)

func main() {
        fmt.Println(Greet("マトペ"))
}

func Greet(n string) string {
        t := time.Now()
        if 6 <= t.Hour() && t.Hour() <= 18 {
                return fmt.Sprintf("こんにちは%sさん。今は%d時ですよ!", n, t.Hour())
        } else {
                return fmt.Sprintf("こんばんは%sさん。今は%d時ですよ!", n, t.Hour())
        }
}
go run main.go
こんにちはマトペさん。今は12時ですよ!

で、このGreet関数をテストしたいんですが、このままでは夜の挨拶を出すのに18時まで待つ必要があります。それでは困るのでtime.Nowの部分をなんとかちょろまかしていつでも全ての条件をテスト出来るようしないといけません。いわゆるスタブ化ですね。

すぐに思い浮かぶのは、Greet関数を下のようにDIっぽく依存性である現在時刻を引数から受け取るように変更する方法だと思います。

// main.go
func Greet(n string, t time.Time) string { // 引数に現在時刻を追加
        if 6 <= t.Hour() && t.Hour() <= 18 {
                return fmt.Sprintf("こんにちは%sさん。今は%d時ですよ!", n, t.Hour())
        } else {
                return fmt.Sprintf("こんばんは%sさん。今は%d時ですよ!", n, t.Hour())
        }
}

これなら任意の時間の場合のGreet関数のユニットテストが書けますが、現在時刻のテストのために本来不要な引数が増えるのは大げさで好ましくありません(好みの問題です)。それに、結局コールスタックのどこかでtime.Now()を呼ぶ必要があり、その呼び出しポイントを含むテストはスタブ化できません。

ここはやはりtime.Now関数をテスト実行時だけ動的に上書きしたいところですが、Golangはあまりスタブ化に優しい言語ではなく、例えばtimeパッケージをテスト時に動的に他のパッケージに差し替えるとかはできません。これが自作パッケージで提供している構造体かなにかであれば、interfaceでもなんでも使ってスタバブルにすればいいと思いますが…。

(参考:go - Is there an easy way to stub out time.Now() globally in golang during test? - Stack Overflow

で、どうしたかというと、タイトル通りですがmain.goでtime.Nowの関数ポインタをグローバル変数に代入しておき、テスト時にmain_test.goで任意の時刻を返す関数にさしかえるという方法を使いました。

// main.go
package main

import (
        "fmt"
        "time"
)

var timeNowFunc = time.Now // 関数をグローバル変数に代入

func main() {
        fmt.Println(Greet("マトペ"))
}

func Greet(n string) string {
        t := timeNowFunc() // グローバル変数でtime.Now関数を呼び出し
        if 6 <= t.Hour() && t.Hour() <= 18 {
                return fmt.Sprintf("こんにちは%sさん。今は%d時ですよ!", n, t.Hour())
        } else {
                return fmt.Sprintf("こんばんは%sさん。今は%d時ですよ!", n, t.Hour())
        }
}

Goは関数が一級市民なのでシグネチャさえ合致すれば変数に代入できます。この場合、timeNowFunc変数は型推論func() time.Time型として宣言され、time.Now関数で初期化されます。で、これを次のようにテストします。

// main_test.go
package main

import (
        "testing"
        "time"
)

const timeformat = "2006-01-02 15:04:06" // timeのフォーマット指定文字列

// timeNowFuncグローバル変数の関数を入れ替える関数
func setNow(t time.Time) {
        timeNowFunc = func() time.Time { return t }
}

func TestMain(t *testing.T) {

        evening, _ := time.Parse(timeformat, "2014-08-14 14:10:00")
        night, _ := time.Parse(timeformat, "2014-08-14 22:30:00")

        // 昼のテスト
        setNow(evening)  // スタブ!timeNowFuncに昼時刻を返す関数をセット
        if ret := Greet("まとぺ"); ret != "こんにちはまとぺさん。今は14時ですよ!" {
                t.Errorf("Greet Fails. Got [%s]\n", ret)
        }

        // 夜のテスト
        setNow(night)  // スタブ!timeNowFuncに昼時刻を返す関数をセット
        if ret := Greet("まとぺ"); ret != "こんばんはまとぺさん。今は22時ですよ!" {
                t.Errorf("Greet Fails. Got [%s]\n", ret)
        }
}
$ go test
PASS
ok      _/Users/matope/dev/go/mock     0.006s

テスト内で宣言しているsetNow(time.Time)関数は、グローバル変数のtimeNowFuncに、任意の時刻を返す関数を代入する関数です。これでテストごとにtimeNowFuncの返す時刻をコントロールできるので、任意の時刻のテストが書けます。しかも面倒なスタブ用クラス実装や不要な引数の引き回しなしで、テスト時にのみ作用するスタブです。

グローバル変数と聞いてつらくなる人もいるかもしれませんが、Goのグローバル変数は実際にはパッケージスコープで、頭文字を小文字にしておけばパッケージの外からアクセスできないので、比較的クリーンです。Goはテストがコードと同じパッケージ空間で実行できるので、テストコードに対してのみExportされたパラメータとして扱えます。

この、テスト時にスタブしたい関数の呼び出しをグローバル変数でプロキシしておいてtest側の初期化で差し替える方法は、同一パッケージ内であればモック化のための追加の記述量が少なく、データベースのテストなど、いろいろ応用が利きそうだなと思いました。複数パッケージにまたがるtime.Now()を一括で差し替えたいような場合は、スタブプロキシ用のパッケージを作ってそこのExported(大文字から始まる)グローバル変数にセットしてやればよさそうですね。

ちなみに豆ですが、Grouped Globalを使えばこんな感じでちょっとかっこよくグローバル変数を呼べます。もはや紛らわしいような気もしますが。

// main.go
package main

import (
        "fmt"
        "time"
)

var _time = struct {
        Now   func() time.Time
        Since func(time.Time) time.Duration
}{
        time.Now,
        time.Since,
}

func main() {
        fmt.Println(_time.Now()) // ← ちょっとかっこいい
}

追記:同じやり方を解説してる記事 があったのでこっち読んだ方がよさそう

Go Concurrency Patterns: Context[翻訳]

7月29日付けのgolang.orgブログエントリーで context というパッケージが紹介されました。

Go Concurrency Patterns: Context - The Go Blog

参考: Go言語のcontextパッケージについてのやりとり - ワザノバ | wazanova

今現在、業務でGo言語を使ったWebサービスを書いているのですが、contextはリクエストのキャンセルとタイムアウトとリクエストスコープの変数を扱う、Google社内で利用が標準化されているパッケージだという事なので、エントリーを翻訳しました。

以下和訳。

Introduction

Goサーバーでは、入ってくる各リクエストは専用のgoroutineで処理されます。リクエストハンドラは、データベースやRPCサーバーにアクセスするためにしばしば追加のゴルーチンを開始します。リクエスト上のゴルーチンは、一般的にエンドユーザーのアイデンティティ、認証トークン、リクエストのデッドラインなどのリクエスト特有の情報にアクセスする必要があります。リクエストがキャンセルされるかタイムアウトした場合、システムが資源を再利用できるようにするため、そのリクエスト上で動作していた全てのゴルーチンは即座に終了すべきです。

Googleで私たちは、リクエストスコープの値、キャンセレーションシグナル、そしてAPI境界をまたぐデッドラインを、リクエスト処理に関わる全てのゴルーチンに渡す事を簡単にするためのcontextパッケージを開発しました。パッケージは code.google.com/p/go.net/contextで公開されています。この記事では、パッケージの使い方と完全な動作例を紹介します。

Context

contextパッケージの核となるのはContext型です:

// A Context carries a deadline, cancelation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
    // Done returns a channel that is closed when this Context is canceled
    // or times out.
    Done() <-chan struct{}

    // Err indicates why this context was canceled, after the Done channel
    // is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}

(この記述は要約です。godocが信頼できます)

DoneメソッドはContextを実行中の関数へのキャンセレーションシグナルとして振る舞うチャンネルを返します:そのチャンネルがクローズされたら、関数は自分たちの仕事を放棄してreturnするべきです。ErrメソッドはそのContextがキャンセルされた理由を示すerrorを返します。Pipelines and CancelationでDoneチャンネルイディオムについてより詳しく議論しています。

Contextには、Doneチャンネルがreceive-onlyであることと同じ理由によりCancelメソッドがありません:キャンセレーションシグナルを受信している関数は、通常そのシグナルを送信する関数と同じではないからです。特に、親オペレーションがサブオペレーションのためにゴルーチンを開始したとき、それらのサブオペレーションは親をキャンセルできるべきではありません。そのかわりに、WithCancel関数(後述)は新しいContext値をキャンセルする方法を提供します。

Contextは複数ゴルーチンによる同時利用に対して安全です。コードは単一のContextをいくつのゴルーチンにでも渡せますし、それら全てにシグナルしてそのContextをキャンセルする事も出来ます。

Deadlineメソッドにより、関数は自分が仕事を開始するべきかどうか判断することができます;もし残り時間がほとんどなければ、全くの無駄になるかもしれません。コードはI/Oオペレーションのタイムアウトを設定するのに使う事も出来ます。

Valueにより、Contextでリクエストスコープなデータを運ぶ事が出来ます。そのデータは複数ゴルーチンによる同時利用に対して安全である必要があります。

派生コンテキスト

contextパッケージは既存のContextから新しいContextを派生するための関数を提供します。これらの値はツリーを形成します; あるContextがキャンセルされたとき、そこから派生している全てのContextもまたキャンセルされます。

Backgroundは全てのContextツリーのルートです;決してcancelされることはありません。

// Backgroundは空のContextを返します。これは決してキャンセルされず、デッドラインを持たず、値も持ちません。
// Backgroundは一般的にmain,init,そしてtests内で、やってくるリクエストのトップレベルコンテキストとして使われます。
func Background() Context

WithCancelとWithTimeoutは、親Contextがキャンセルされるよりも早くキャンセルできる派生Contextの値を返します。到着したリクエストに関連づけられたContextは、一般的にはリクエストハンドラがreturnした時にキャンセルされます。WithCancelは複数レプリカ使用時に冗長なリクエストをキャンセルするのにも便利です。WithTimeoutは、バックエンドサーバーへのリクエストにデッドラインを設定するのに便利です:

// WithCancel は、parent.Doneがクローズされるかcancelが呼び出されると
// Doneチャンネルがクローズされるようなparentのコピーを返します
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// CancelFuncはContextをキャンセルします
type CancelFunc func()

// WithTimeoutはparent.Doneがクローズされるかcancelが呼び出されるかtimeoutが
// 経過するとDoneチャンネルがクローズされるようなparentのコピーを返します。
// 新しいContextのDeadlineはnow+timeoutと、もしあればparentのdeadlineのうちの近い方です。
// もしtimerがまだ動いていれば、cancel関数はそのリソースをリリースします。
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithValueはrequest-scopedな値をContextに関連づける方法を提供します

// WithValue はValueメソッドがkeyに対するvalを返すようなparentのコピーを返します。
func WithValue(parent Context, key interface{}, val interface{}) Context

contextパッケージの使い方を見る一番の方法は、動いているサンプルを見る事です。

Example: Google Web Search

この例は、/search?q=golang&timeout=1s のようなURLを、クエリ"golang"をGoogle Web Search APIにフォワードして結果を表示するという風にハンドルするHTTPサーバーです。timeoutパラメーターはサーバーにこの時間が経過した後にリクエストをキャンセルするように指示します。このコードは三つの部分に分かれています:

  • server はmain関数と/search のハンドラを提供します
  • userip はユーザーIPアドレスをリクエストから取得してContextに関連づける関数を提供します
  • google はクエリをGoogleに送信するSearch関数を提供します

The server program

serverプログラムは/search?q=golang のようなリクエストに対して最初のいくつかのgolangGoogle検索結果を返します。ハンドラはctxと呼ばれる初期Contextを作成し、ハンドラがreturnした時にキャンセルされるように準備します。リクエストがtimeout URLパラメータを含んでいたら、Contextはtimeout経過後に自動的にキャンセルされます:

func handleSearch(w http.ResponseWriter, req *http.Request) {
    // ctxはこのハンドラのContextです。cancleを呼ぶと、このハンドラ
    // が開始したリクエストへのキャンセレーションシグナルである
    // ctx.Doneチャンネルがクローズします
    var (
        ctx    context.Context
        cancel context.CancelFunc
    )
    timeout, err := time.ParseDuration(req.FormValue("timeout"))
    if err == nil {
        // リクエストはタイムアウトを持つので、timeoutがexpireしたら
        // 自動的にキャンセルされるcontextを作ります
        ctx, cancel = context.WithTimeout(context.Background(), timeout)
    } else {
        ctx, cancel = context.WithCancel(context.Background())
    }
    defer cancel() // handleSearchがリターンしたらctxをキャンセル

ハンドラはリクエストからクエリを抜き出し、useripパッケージを呼び出す事でクライアントのIPアドレスを取得します。クライアントのIPアドレスはバックエンドリクエストに必要なので、handleSearchはそれをctxにくっつけます:

    // Check the search query.
    query := req.FormValue("q")
    if query == "" {
        http.Error(w, "no query", http.StatusBadRequest)
        return
    }

    // Store the user IP in ctx for use by code in other packages.
    userIP, err := userip.FromRequest(req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    ctx = userip.NewContext(ctx, userIP)

ハンドラはctxとqueryでgoogle.Searchを呼び出します:

    // Run the Google search and print the results.
    start := time.Now()
    results, err := google.Search(ctx, query)
    elapsed := time.Since(start)

検索が成功したら、ハンドラは結果を表示します:

    if err := resultsTemplate.Execute(w, struct {
        Results          google.Results
        Timeout, Elapsed time.Duration
    }{
        Results: results,
        Timeout: timeout,
        Elapsed: elapsed,
    }); err != nil {
        log.Print(err)
        return
    }

Package userip

useripパッケージはリクエストからユーザーIPアドレスを抜き出してContextに関連づける関数を提供します。Contextはkey-valueマッピングを提供しますが、そのkeyとvalueはどちらもinterface{}型です。Key型は等価をサポートし、valueは複数ゴルーチンからの同時利用に対して安全である必要があります。useripのようなパッケージはこのマッピングの詳細を隠蔽し、特定のContext値への強く型付けされたアクセスを提供します。

キー衝突を避けるために、useripはexportされない型のkeyを定義してcontext keyとしてこの型の値を使います。

// The key type is unexported to prevent collisions with context keys defined in
// other packages.
type key int

// userIPkey is the context key for the user IP address.  Its value of zero is
// arbitrary.  If this package defined other context keys, they would have
// different integer values.
const userIPKey key = 0

FromRequestはuserIP値をhttp.Requestから抜き出します

func FromRequest(req *http.Request) (net.IP, error) {
    s := strings.SplitN(req.RemoteAddr, ":", 2)
    userIP := net.ParseIP(s[0])
    if userIP == nil {
        return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
    }

NewContextは与えられたuserIP値を運ぶ新しいContextを返します:

func NewContext(ctx context.Context, userIP net.IP) context.Context {
    return context.WithValue(ctx, userIPKey, userIP)
}

FromContextはuserIPをContextから抜き出します:

func FromContext(ctx context.Context) (net.IP, bool) {
    // ctx.Value returns nil if ctx has no value for the key;
    // the net.IP type assertion returns ok=false for nil.
    userIP, ok := ctx.Value(userIPKey).(net.IP)
    return userIP, ok
}

Package Google

google.Search関数はGoogle Web Search APIへのHTTPリクエストを作ってJSONエンコードされた結果をパースします。これはContextパラメータctxを受け入れ、もしリクエストを飛ばしている間にctx.Doneがクローズされたらすぐにリターンします。

Google Web Search APIリクエストはサーチクエリとユーザーIPをクエリパラメータに含みます:

func Search(ctx context.Context, query string) (Results, error) {
    // Prepare the Google Search API request.
    req, err := http.NewRequest("GET", "https://ajax.googleapis.com/ajax/services/search/web?v=1.0", nil)
    if err != nil {
        return nil, err
    }
    q := req.URL.Query()
    q.Set("q", query)

    // If ctx is carrying the user IP address, forward it to the server.
    // Google APIs use the user IP to distinguish server-initiated requests
    // from end-user requests.
    if userIP, ok := userip.FromContext(ctx); ok {
        q.Set("userip", userIP.String())
    }
    req.URL.RawQuery = q.Encode()

Searchはヘルパー関数httpDoを使ってHTTPリクエストを発行し、もしリクエストかレスポンスが処理中にctx.Doneがクローズされたらキャンセルします。SearchはHTTPレスポンスを処理するクロージャをhttpDoハンドルに渡します:

    var results Results
    err = httpDo(ctx, req, func(resp *http.Response, err error) error {
        if err != nil {
            return err
        }
        defer resp.Body.Close()

        // Parse the JSON search result.
        // https://developers.google.com/web-search/docs/#fonje
        var data struct {
            ResponseData struct {
                Results []struct {
                    TitleNoFormatting string
                    URL               string
                }
            }
        }
        if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
            return err
        }
        for _, res := range data.ResponseData.Results {
            results = append(results, Result{Title: res.TitleNoFormatting, URL: res.URL})
        }
        return nil
    })
    // httpDo waits for the closure we provided to return, so it's safe to
    // read results here.
    return results, err

httpDo関数は新しいゴルーチンの中でHTTPリクエストを実行してレスポンスを処理します。もしゴルーチンがexitする前にctx.Doneがクローズしたらキャンセルします。

func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
    // Run the HTTP request in a goroutine and pass the response to f.
    tr := &http.Transport{}
    client := &http.Client{Transport: tr}
    c := make(chan error, 1)
    go func() { c <- f(client.Do(req)) }()
    select {
    case <-ctx.Done():
        tr.CancelRequest(req)
        <-c // Wait for f to return.
        return ctx.Err()
    case err := <-c:
        return err
    }
}

コードをContextに適合する

多くのサーバーフレームワークがリクエストスコープな値を運ぶためのパッケージと型を提供しています。私たちは、既存のフレームワークとContextパラメータを期待するコードとの間をブリッジする新しいContextインターフェイスの実装を定義する事が出来ます。

例えば、Gorillaのgithub.com/gorilla/contextパッケージは、HTTPリクエストからkey-valueペアへのマップを提供する事でハンドラにデータをリクエストに関連づける事を許します。gorilla.goにおいて、私たちはGorillaパッケージ内で特定のHTTPリクエストに関連づけられた値を返すValueメソッドを持つContext実装を提供します。

他のパッケージは、Contextに似たキャンセレーションサポートをサポートしてきました。例えば、TombはDyingチャンネルをcloseすることでキャンセルをシグナルするKillメソッドを提供します。Tomはまたこれらのゴルーチンが終了するのを待つ、sync.WaitGroupに似たメソッドも提供します。tomb.goでは、私たちは親のContextがキャンセルされるか与えられたTombがkillされるかの場合にキャンセルされるContext実装を提供します。

結論

Googleでは、私たちはGoプログラマーにContextパラメーターをリクエストの到着から返却までの間に呼ばれる全ての関数の第一引数にContextパラメーターを渡す事を要求しています。これは、たくさんの異なるチームで開発されたGoのコードに良好な相互運用性をもたらします。タイムアウトとキャンセレーションのシンプルな制御を導入し、セキュリティクレデンシャルのようなクリティカルな値が適切にGoプログラム内を移動する事を確かなものにします。

Context上に構築したいサーバーフレームワークは、それらのパッケージとContextパラメータを期待するものの間をブリッジするContext実装を提供するべきです。そうすればそれらのクライアントライブラリは呼び出しコードからContextを受け入れることでしょう。リクエストスコープなデータとキャンセレーションのための共通インターフェイスを確立する事により、Contextはパッケージ開発者がスケーラブルなサービスを作るためのコードを共有するのを簡単にするのです。

By Sameer Ajmani