etcd入門 (1)

Kubernetesにも採用されている分散型KVSについて,何回かに分けて勉強していく.
今回のモチベーションとして,Kubernetesのアーキテクチャを詳しく勉強したい,Terraformのstate保存をローカルではなくetcdにしたいという2つがある.

まず,インストールとクラスタの作成について見ていく.

インストール

クラスタ作成のためには,まずetcd本体のバイナリが必要になる.
GitHubのreleaseページから,自分のOSにあったバイナリをダウンロードしてくる.

$ curl -LO https://github.com/etcd-io/etcd/releases/download/v3.3.10/etcd-v3.3.10-linux-amd64.tar.gz

ダウンロードしたファイルは圧縮されており,それを解凍するといくつかのファイルの中に2つのバイナリが確認できる.

$ tar xzf etcd-v3.3.10-linux-amd64.tar.gz
$ cd etcd-v3.3.10-linux-amd64.tar.gz
$ ll etcd*
-rwxr-xr-x. 1 yamazaki yamazaki 19237536 Oct 11  2018 etcd
-rwxr-xr-x. 1 yamazaki yamazaki 15817472 Oct 11  2018 etcdctl

etcd はetcd本体, etcdctl はetcdのクライアントとなるプログラム.
これら2つをPATHが通っている場所に置いてあげる.

$ sudo mv etcd* /usr/local/bin/
$ ll /usr/local/bin/etcd*
-rwxr-xr-x. 1 yamazaki yamazaki 19237536 Oct 11  2018 /usr/local/bin/etcd
-rwxr-xr-x. 1 yamazaki yamazaki 15817472 Oct 11  2018 /usr/local/bin/etcdctl

これでインストールは完了.
きちんとインストールされているか一応確認する.

$ etcd --version
etcd Version: 3.3.10
Git SHA: 27fc7e2
Go Version: go1.10.4
Go OS/Arch: linux/amd64

クラスタの作成

まず,1台のみでetcdクラスタを作成して簡単に使い方を把握し,その後複数メンバでのetcdクラスタを作成していく.

1台のetcdクラスタ作成

基本的にGitHubに書いてある手順通りに試していく.

1台のみでetcdクラスタを作成するときは,特に何も考えずに etcd コマンドを実行する.

$ etcd

etcd コマンドを実行すると,多くのログが出力される.
ここで,作成したetcdクラスタを使って,データの登録と取得を試してみる.

etcdクラスタとのやり取りには, etcdctl コマンドを使用する.
etcd コマンドを実行しているシェルとは別にもう1つシェルを起動し,そこで etcdctl コマンドを使用していく.
なお,etcd APIにはバージョンがいくつかあるが,今回はバージョン3を使用する.
etcdctl を普通に使うとv2 APIが使われてしまうので,v3 APIを使うために ETCDCTL_API という環境変数の値を 3 に設定する必要があることに注意する.
公式のREADMEのように etcdctl コマンドを実行するごとに毎回 ETCDCTL_API を指定してもよいが,毎回記述するのも面倒なのであらかじめexportしておく.

$ export ETCDCTL_API=3

etcdクラスタにデータを登録するには,putコマンドを使用する.

# Usage
$ etcdctl put <key> <value>

$ etcdctl put mykey "this is awesome"
OK

putコマンドを実行して, OK と表示されれば成功している.

登録した値を取得するにはgetコマンドを使用する.

# Usage
$ etcdctl get <key>

$ etcdctl get mykey
mykey
this is awesome

指定したキーの名前とデータが続けて出力されれば成功.

次に,複数台のetcdクラスタを作成してみる.
その前に,今使っていたetcdクラスタを停止させておく.
etcd コマンドを実行していたシェルに戻り,Ctrl-Cで終了する.

複数メンバでのetcdクラスタ作成

今回は1つのノードの中にetcdを複数立ち上げることで,複数メンバで構成されるetcdクラスタを作成する.
もちろん,複数のノードを使ってノード1台につきetcdを1つ動作させるという形で作成することもできる.

公式には,etcdはTCP 2379番・2380番のポートを使用する.

  • 2379/tcp: クライアントとの通信
  • 2380/tcp: etcdメンバ間の通信

今回は同じノード内に複数etcdを立ち上げるため,それぞれのetcdでポートが競合しないようにしておく.
今回は3つのetcdメンバを動作させ,それぞれの名前,ポートは次の表のようにした.

メンバ クライアント通信用 メンバ間通信用
etcd1 2379 2380
etcd2 12379 12380
etcd3 22379 22380

実際に3つのetcdメンバを動かしていく.
ターミナルのウィンドウを3つ開いて,それぞれのターミナルにつき1つのetcdメンバを起動する.
それぞれのターミナルで,次のようなコマンドを実行する.

1つ目のターミナル

$ etcd --name etcd1 \
    --initial-advertise-peer-urls http://localhost:2380 \
    --listen-peer-urls http://localhost:2380 \
    --advertise-client-urls http://localhost:2379 \
    --listen-client-urls http://localhost:2379 \
    --initial-cluster etcd1=http://localhost:2380,etcd2=http://localhost:12380,etcd3=http://localhost:22380 \
    --initial-cluster-state new \
    --initial-cluster-token etcd-cluster-1

2つ目のターミナル

$ etcd --name etcd2 \
    --initial-advertise-peer-urls http://localhost:12380 \
    --listen-peer-urls http://localhost:12380 \
    --advertise-client-urls http://localhost:12379 \
    --listen-client-urls http://localhost:12379 \
    --initial-cluster etcd1=http://localhost:2380,etcd2=http://localhost:12380,etcd3=http://localhost:22380 \
    --initial-cluster-state new \
    --initial-cluster-token etcd-cluster-1

3つ目のターミナル

$ etcd --name etcd3 \
    --initial-advertise-peer-urls http://localhost:22380 \
    --listen-peer-urls http://localhost:22380 \
    --advertise-client-urls http://localhost:22379 \
    --listen-client-urls http://localhost:22379 \
    --initial-cluster etcd=http://localhost:2380,etcd2=http://localhost:12380,etcd3=http://localhost:22380 \
    --initial-cluster-state new \
    --initial-cluster-token etcd-cluster-1

正しくコマンドを入力できていれば,正常にetcdクラスタが起動しているはず.
出力されるログでエラー等が出ていなければひとまずOK.

コマンドラインオプションをたくさん使っているので複雑に見えるが,一度理解してしまうとそこまで難しくはないと思う.
今回指定しているコマンドラインオプションは,大きく分けて次の2つに分かれている.

  • 新しいクラスタを作成するときに使用するもの(Clustering flags
  • 他のメンバやクライアントとの通信や,etcdの設定に関するもの(Member flags

Clustering flagsでは,新しいクラスタを一から作成するときに必要となる情報を指定する.
今回使用したものは次の通り.

flags description
--initial-advertise-peer-urls クラスタ内の他のetcdメンバからの通信を受け付けるURLを指定する
--advertise-client-urls クライアントからの通信を受け付けるURLを指定する
--initial-cluster クラスタを構成するetcdメンバの情報をカンマ区切りで指定する
--initial-cluster-state 新しいクラスタを作成する場合は new を指定する
--initial-cluster-token クラスタ作成中に用いられるトークン.複数クラスタを管理している際,意図せず別のクラスタに影響を与えるのを防ぐ

Member flagsでは,作成するメンバに関する情報等の設定ができる.
今回使用したものは次の通り.

flags description
--name メンバ名
--listen-peer-urls クラスタ内の他のetcdメンバからの通信を受け付けるURLを指定する
--listen-client-urls クライアントからの通信を受け付けるURLを指定する

注意点として, --initial-cluster で指定しているメンバ情報は, --name--listen-peer-urls で指定した名前とURLに一致させなければならない.
もし一致していないと,クラスタ起動時にエラーとなる.

次のコマンドで,きちんと3つのメンバが表示されたら成功.

$ etcdctl member list
29727fd1bdf9fb62, started, etcd1, http://localhost:2380, http://localhost:2379
44dd3cd8faa339d0, started, etcd3, http://localhost:22380, http://localhost:22379
b59b01c27098773e, started, etcd2, http://localhost:12380, http://localhost:12379

次は,作成したクラスタを使ってもう少しetcdctlの使い方を勉強し,その後TLSの設定やDiscoveryを使ったクラスタ作成,無停止でのメンバアップグレード等について勉強していく予定.

GoのType AssertionとType Switches

Goでは,型を interface{} として宣言してあげることで,とりあえずどんな値でも格納することが出来る.
SlackのEvent API等,メッセージの形式がEventごとに異なるといった場合に使うと便利.

Example

func eventHandler(w http.ResponseWriter, r *http.Request) {
    var event map[string]interface{}
    decoder := json.NewDecoder(r.Body)
    if err := decoder.Decode(&event); err != nil {
        log.Fatalln(err)
    }
}

この例だと,変数 event に受け取ったイベントが格納される.
eventmap[string]interface{} として宣言されているので, event["token"] のように値を取得しても返ってくるのは interface{} 型である.
そのため,値を使いたいときに適切な型へ変換してあげる必要がある.

このような場合に,Goの言語仕様として用意されているType AssertionやType Switchesというものを使ってあげるとうまく型変換が出来る.

Type assertions

Type assertionsを使うと, interface{} から指定した型に変換することが出来る.
Type assertionsでは,次のような順番で処理が行われる.

  1. 値がnilであるかどうかを検査
  2. 指定した型に変換して値を返却

書き方は次の2つある.

1. 基本形

var x interface{} = 7   // interface{}としてxを宣言
i := x.(int)            // Type assertionsを使ってint型に変換

Type assertionsが成功すると,指定した型に変換された値が返り値として返される(上の例だと int に変換された 7 が返ってくる).
もし次の例のように正しく変換できなかった場合は,panicが発生する.

var x interface{} = "hello" // interface{}としてxを宣言
i := x.(int)                // xの値 "hello" はint型に変換できない => panic発生

2. 別の書き方

別の書き方として,次のように返り値を2つ受け取るバージョンがある.

var x interface{} = 7   // interface{}としてxを宣言
i, ok := x.(int)        // okには正しく変換できたかどうかがboolで格納される.成功した場合はtrue

この書き方を使うと,失敗したときでもpanicは発生せず, okfalse が設定されるだけとなる.
この時,1つめの返り値は指定した型のゼロ値となる.

var x interface{} = "hello" // interface{}としてxを宣言
i, ok := x.(int)            // xの値 "hello" はint型に変換できない => okはfalse
fmt.Println(i)              // 0(intのゼロ値)が出力される

Type Switches

ドキュメントでは,Type SwitchesはType assertionの特別形であると説明されている.
Type assertionでは変換したい型名を指定していたが,Type Switchesでは代わりに type を指定する.

var x interface{} = "hello"
switch i := x.(type) {
    case int:
        fmt.Println("x is int")
    case string:
        fmt.Println("x is string")
}

これを使うと,複数の型候補がある場合にswitch文を使って分岐させることが出来る.

参考文献

Goでバイナリを作る

ネットワークパケットを作るために,構造体や変数からバイト型のスライスへの変換方法を調べた.

テストで書いてみたプログラムは次の通り.
とりあえずint型の変数をいくつか対象としてバイト変換を試してみた.

何らかの型(構造体やint等のプリミティブ型)からバイト型のスライスに変換するには, encoding/binary パッケージの Write メソッドを使用すると良い.

binary.Write() メソッドは次のような定義になっている.

func Write(w io.Writer, order ByteOrder, data interface{}) error

第3引数に指定したデータを,第2引数で指定したバイトオーダーで第1引数の w に書き込む.

このメソッドを使うために,まず io.Writer インタフェースを実装したものが必要になる.
バイト型のデータを書き込みたいので, bytes パッケージの Buffer を使用することにする.

buf := new(bytes.Buffer)

注意点として, new() を使う点が挙げられる.
bytes.BufferWrite() メソッドはポインタ型( *bytes.Buffer )がレシーバとなっているため, new() を使ってポインタを取得して上げる必要がある.

これで io.Writer を用意できたので, binary.Write() を使ってデータを書き込む.
適当にint型の変数を用意して binary.Write() による書き込みを行う.
なお,今回はネットワークパケットを作りたいので,第2引数のバイトオーダはビッグエンディアンを指定する.

var val32bit int32
val32bit = 123

err = binary.Write(buf, binary.BigEndian, val32bit)
if err != nil {
    log.Fatalln(err)
}
fmt.Printf("uint32: % x\n", buf.Bytes())

これで無事書き込みができた.

サンプルプログラムの実行結果

uint8: 7b
uint16: 00 7b
uint32: 00 00 00 7b

今回はint型のみを扱ったが,構造体を書き込みたいときも同じようにして扱うことができるようだ.

HTTPリダイレクトについて調査した

HTTPのリダイレクトには,大きく分けて次の二つ存在する.

  • 一時的なリダイレクト
  • 恒久的なリダイレクト

一般的に用いられるリダイレクトのステータスコードは 301302 である(個人的な考えですが).
しかし,他にもいくつかリダイレクトのステータスコードは存在する.

また,似たような意味を持つステータスコードもあるので,整理するために少し調査した.

今回対象とするリダイレクトのステータスコード

今回は,次の4つを対象とする.

  • 301 Moved Permanently
  • 302 Found
  • 307 Temporary Redirect
  • 308 Permanent Redirect

それぞれのステータスコードについて,RFCを参考に概要を以下に示す.
308以外はRFC 7231,308はRFC 7238で定義されている.

301 Moved Permanently

  • 恒久的なリダイレクトを表す
  • 歴史的な経緯から,301を受け取ったクライアントはリクエストメソッドをPOSTからGETに変更する可能性がある
    • これを望まない場合,代わりに307を用いることができる
    • しかし,307とは意味が異なる(恒久的↔一時的)
      • 308が新しく定義された

302 Found

  • 一時的なリダイレクトを表す
  • 歴史的な経緯から,301を受け取ったクライアントはリクエストメソッドをPOSTからGETに変更する可能性がある
    • これを望まない場合,代わりに307を用いることができる

307 Temporary Redirect

  • 一時的なリダイレクトを表す
  • これを受け取ったクライアントはリクエストメソッドを変更してはいけない

308 Permanent Redirect

  • 恒久的なリダイレクトを表す
  • これを受け取ったクライアントはリクエストメソッドを変更してはいけない

挙動の確認

それぞれのステータスコードに対する挙動を確認するため,簡単なサーバプログラムをGoで作成し,そのサーバに4種類のクライアントで接続した.
作成したサーバプログラムは mas9612/http-redirect-test に置いている.

用いたクライアント
* curl 7.43.0
* Safari 11.1.2
* Google Chrome 67.0.3396.99
* Firefox 59.0.2

上記それぞれのクライアントから,GETとPOST2つのメソッドで作成したサーバに接続した.
結果を次の表に示す.

Method GET

client 301 302 307 308
curl GET GET GET GET
Safari GET GET GET GET
Chrome GET GET GET GET
Firefox GET GET GET GET

Method POST

client 301 302 307 308
curl POST POST POST POST
Safari GET GET POST POST
Chrome GET GET POST POST
Firefox GET GET POST POST

実験結果より,curl以外のクライアントは,POSTリクエストの応答結果が301と302の時,リダイレクト先へのリクエストメソッドをGETに変更していることがわかった.
また,RFCの定義どおり,307と308はきちんとリクエストメソッドが維持されていることが確認できた.

References

Docker Engine API試用

前に少し気になっていたDocker Engine APiを使ってみたので,それについて.

特に複雑なことはせず,引数に与えたイメージを削除するというプログラムを作ってみた.
ただ単純に削除するだけではつまらないので,何世代分保存しておくか,というのをオプションで指定できるようにした.

コードは mas9612/docker-tools/image-remove に置いてある.
あまりきれいなコードではないのでご注意ください.

Client.ImageList() メソッドでローカルにあるイメージの一覧が取得できるが, filter でイメージ名を指定できなさそうだったので,愚直にfor文で1つ1つ確認している.
アルゴリズムは得意ではないので,良い方法があれば教えてください…

images, err := client.ImageList(ctx, types.ImageListOptions{})
if err != nil {
    log.Fatalf("[ERROR] client.ImageList(): %s\n", err)
}

for _, image := range images {
    for _, repotag := range image.RepoTags {
        repository := strings.Split(repotag, ":")
        if repository[0] == *imageName {
            imageInfos = append(imageInfos, imageInfo{
                ID:      image.ID,
                Created: image.Created,
                Name:    repotag,
            })
        }
    }
}

削除対象のイメージをリスト出来たら,それを作成日時でソートし,指定した世代分は残してそれ以外を Client.ImageRemove() メソッドで削除している.
デフォルトでは,イメージ名にマッチしたもの全てを削除するようになっているのでお気をつけください.

if *generation > len(imageInfos) {
    *generation = len(imageInfos)
}
removeOptions := types.ImageRemoveOptions{
    Force: *force,
}
for _, image := range imageInfos[*generation:] {
    _, err := client.ImageRemove(ctx, image.ID, removeOptions)
    if err != nil {
        log.Fatalf("[ERROR] client.ImageRemove(): %s\n", err)
    }
    fmt.Printf("Image %s was deleted.\n", image.Name)
}

やっている事自体は簡単なので,ドキュメントと見比べて頂ければわかると思います.