Fire Engine

化学の修士号→消防士→ITエンジニア(2016年11月〜)

消防士からエンジニアに転職して2年が経ったので振り返る

消防士として働いていた私が、経験ゼロからプログラミングを始めて、ITエンジニアに転職してから2年が経ちました。
1年前にも同じような振り返りの記事を書きましたが、エンジニア2年目も振り返ってみたいと思います。

blog.tsurubee.tech

エンジニア2年目を振り返って

インフラエンジニアになった

エンジニア2年目での一番大きな変化は、GMOペパボに転職して、インフラエンジニアになったことです。
これにより技術スタックも大きく変化し、自分の知識や関心領域を広げることができました。今振り返っても大変良い選択をしたと思っています。
僭越ながら、最近採用サイトにインタビューを載せていただきました。

tsurubee interview

無駄なこだわりを捨てた

私には「体系的に学ぶ」とか「順序立てて学ぶ」といったこだわりが染み付いていました。これは決して悪いことではないのですが、時に成長スピードを遅らせる要因になると思いました。
例えば、何か新しいことを学びたいとか、作りたいとなったときも、どこかで「まだインフラエンジニアとしての土台もついてないのに、そんな新しいことに取り組んでる余裕はない」とか、「しっかり土台の知識や技術をつけてからやり始めよう」など考えていたのです。それを会社の何人かの上司に話すと、
「その土台ってどこまでが土台ですか?」「土台つけるのにあと何ヶ月かかるんですか?」
などと言われました。確かにこの質問ってうまく答えられないんですね。
このときに自分は変なこだわりを持ってしまっていて、成長のチャンスを逃しているなと思いました。たいていの場合、学びたい・作りたいと思ったときにやり始めるべきだし、そうやって、何かをやっている間に自然とその周辺知識がついてきて、結果的にボトムアップで学ぶより効率もいいし、楽しいし、やってよかった!ってなるんじゃないかなーと思っています。

すごい人達にまぎれて自分の成長スピードを高める

ペパボには今の自分には手の届かないくらいすごい人たちがゴロゴロいます。そういう方達にまぎれることで刺激になるし、技術的なことを直接教えてもらえることもできます。
今の私の立場だと、@pyama86さんと仕事で関わる機会が多いのですが、私が3日くらい悩むことが8秒くらいで解決することもあるし、ペアプロ・ペアオペをやってもらうと、1時間で1ヶ月分くらい勉強時間が短縮できたんじゃないかって思わされることもあります。(社内ではpyama of the yearと呼ばれている)
なので、私は新しいコードを書くときは、ミニマム実装が終わったくらいにペアプロをお願いして、初期段階で設計を相談したりしています。

技術的にすごい人たちにまぎれて、恐れずに質問・相談するスタンスは、私の中でかなり成長スピードのハックに繋がっていると思います。そのような相談する環境がない場合は、自分自身でその環境づくりをするだけです。

2年目(2017/11~2018/11)でやったことのまとめ

OSS開発

SSHプロキシサーバ

sshrというユーザ名ベースでSSHの接続先を動的に切り替えられるプロキシサーバを作りました。 現在も開発継続中です。

blog.tsurubee.tech

K近傍法に基づく異常検知

mrubyを使ってK近傍法に基づいた異常検知を行うmruby-knn-detectorというライブラリを作りました。

blog.tsurubee.tech

イベント登壇

Date Event Slide
2017/12/20 PyFukuoka #3 Pythonで学ぶUnixプロセスの基礎
2018/03/23 TechMTG #8(社内イベント) さあ、異常を検知しよう!!
2018/06/28 九州インフラ交流勉強会(Kixs) Vol.007 Golangで学ぶHot deployの仕組み
2018/09/06 Developers Summit 2018 FUKUOKA GolangでSSHサーバを作ってみる
2018/10/04 Fukuoka.go #12 GolangでSSHプロキシサーバを実装した

コミュニティ活動

エンジニア1年目のときから、PyFukuokaというコミュニティの運営をやっていて、それをきっかけに九州版のPyConの立ち上げに実行委員として携わりました。

blog.tsurubee.tech

これからやりたいこと

エンジニア1年目に力を入れて取り組んでいたデータサイエンスと、2年目に取り組んだインフラを融合させることです。
実際に半年ちょっとインフラエンジニアをやってみると、サーバのメトリクスや、各種ログなど日々大量かつ多種多様な情報が流れていることを実感できました。しかしながら、それらのデータ活用にはなかなか取り組めておらず、簡単な閾値設定やパターンマッチなどを行い、アラーティングをしているのみです。

今後Webサービスの規模が大きくなるにつれて、サーバ台数の増加やシステム構成の複雑化が進んだ場合、サーバのメトリクス等の情報を高解像度かつ長期間保持し、保持した情報を統計的に解析することで、アラーティングの精度を向上させたいといったシーンも増えてくるのではないかと思います。
オライリーSite Reliability Engineeringにも「時系列データからの実践的なアラート」といった章があるのですが、インフラにおけるデータ活用はこれからますます需要が高まってくると思いますし、何より自分が興味があるので、取り組んでいきたいです。

さいごに

ありがたいことに時々、勉強会などでお会いした方に「2年でこれはすごい成長スピードですね!」などと言ってもらえることがあるのですが、私は本当に人に恵まれました。
最初の会社ではパソコンの使い方レベルから知らないことばかりでしたが、メンターをしてくれた方が根気よく教えて下さいましたし、その後ペパボに入ってからも、lscdくらいしか叩けないレベルだったのに、丁寧に教えて下さって、なんとかインフラエンジニアとして仕事ができています。

その方々に恩返しする意味でもさらに成長して、誰にも負けない分野を作っていきます。
あと、これから本格的にベンチプレスの大会に出場していこうと思います。

デブサミ2018福岡でSSHの話をしました

2018年9月6日開催のDevelopers Summit 2018 FUKUOKA(デブサミ2018福岡)に登壇しました。

発表内容としては、先日書いた下のブログの内容です。

blog.tsurubee.tech

スライドはこちらです。

speakerdeck.com

今回の登壇はLTセッションで、発表時間が7分と短かったため、話きれなかった部分も多かったです。
なので、またどこかでガッツリ話す場があれば嬉しいなーと思ってます!その際はもう少しSSHプロトコルに踏み込んだ話だったり、実装時に苦労したことなどを話せればいいなーと思ってます!

ユーザが接続先を意識しないSSHプロキシサーバを作った

今回は、ユーザが接続先を意識しないSSHプロキシサーバを作った話です。
SSHのユーザ名から動的に接続先ホストを決定し、SSH接続をプロキシします。

github.com

作った背景

比較的規模の大きなサーバ群を管理しており、そこに対して接続してくるユーザに特定のサーバを使ってもらいたい場合を考えます。
すなわち「ユーザtsurubeeには、ssh102サーバを使ってほしい」といったようにユーザとマシン間が紐づいている場合の一番単純な運用方法は、個々のユーザが接続先ホストの情報を知っていることです。
これでも問題ないのですが、何かしらのサーバ管理の理由でユーザに使ってもらいたいサーバが変更した場合、ユーザに通知するなどして意識的に接続先を変更してもらう必要があります。

f:id:hirotsuru314:20180901145409p:plain

このようなユーザとそのユーザに使ってもらいたいサーバの紐付け情報をサーバ管理側が一元的に管理して、ユーザに意識させることなく、ユーザとサーバの紐付けを自由にコントロールすることができないかと考え、sshrというプロキシサーバを作りました。

sshrは何ができるのか

f:id:hirotsuru314:20180901145521p:plain

sshr導入後のSSHの流れは以下の通りです。

  1. SSHクライアントはsshrサーバに対してSSH接続する
  2. sshrサーバは接続してきたSSHユーザ名から接続先ホストを動的に決定する
  3. sshrサーバは特定した接続先ホストに対してSSHをプロキシする

ここで、重要なのは上図のPluggable Hooksの部分です。ここにsshrを利用する開発者が自由にロジックを組み込めるようにしています。
例えば、サーバ数・ユーザ数の規模がそんなに大きくない場合は、tomlファイルのようなもので簡単に管理したいかもしれませんし、規模が大きくなると、ユーザとそのユーザに使ってもらいたいサーバの紐付け情報をDBで管理したい場合もあるでしょう。後者の場合はPluggable Hooksの部分にDBからSELECTする、もしくはAPIサーバ経由でGETするといった処理を書けばよいわけです。

使ってみる

まずはsshrをインストールします。

$ go get github.com/tsurubee/sshr

sshrのトップディレクトリに移動して、docker-compose upを叩くと、sshrサーバが一台、接続先サーバ用としてhost-tsurubee(tsurubeeユーザの接続先)とhost-hogehogeユーザの接続先)のコンテナが立ち上がります。

$ docker-compose up
Creating sshr_host-hoge_1     ... done
Creating sshr_host-tsurubee_1 ... done
Creating sshr_ssh-proxy_1     ... done
Attaching to sshr_host-tsurubee_1, sshr_host-hoge_1, sshr_ssh-proxy_1
ssh-proxy_1      | ==> Installing Dependencies
host-tsurubee_1  | Starting crond: [  OK  ]
host-hoge_1      | Starting crond: [  OK  ]
ssh-proxy_1      | go get -u github.com/golang/dep/...
ssh-proxy_1      | dep ensure
ssh-proxy_1      | go run main.go
ssh-proxy_1      | time="2018-09-01T07:15:03Z" level=info msg="Start Listening on [::]:2222"

sshrサーバはローカルホストのポート2222でListenしているため、まずはtsurubeeユーザで接続してみます。パスワードはtestとしています。

$ ssh tsurubee@127.0.0.1 -p 2222
tsurubee@127.0.0.1's password:
[tsurubee@host-tsurubee ~]$ hostname
host-tsurubee
[tsurubee@host-tsurubee ~]$ ls /
bin  dev  etc  home  lib  lib64  lost+found  media  mnt  opt  proc  root  sbin  selinux  srv  sys  tmp  usr  var

すると、host-tsurubeeSSHログインできていることがわかります。
ログイン後は通常SSHでリモートサーバに繋いだ時と同じように対話的にコマンドが実行できます。
次にhogeユーザで繋いでみると、host-hogeに繋がっていることがわかります。(こちらもパスワードはtest)

$ ssh hoge@127.0.0.1 -p 2222
hoge@127.0.0.1's password:
[hoge@host-hoge ~]$ hostname
host-hoge

ポイントは、クライアントはユーザ名以外は同じで

$ ssh ユーザ名@127.0.0.1 -p 2222

と接続しているが、sshrによって接続先が動的に振り分けられている点です。

ちなみに、SCPもできます。

$ scp -P 2222 main.go tsurubee@127.0.0.1:
tsurubee@127.0.0.1's password:
main.go                         100%  853   424.8KB/s   00:00

$ ssh tsurubee@127.0.0.1 -p 2222
tsurubee@127.0.0.1's password:
Last login: Sat Sep  1 07:18:08 2018 from sshr_ssh-proxy_1.sshr_default
[tsurubee@host-tsurubee ~]$ ls -l
合計 4
-rw-r--r-- 1 tsurubee tsurubee 853  9月  1 07:30 2018 main.go

実装について

実装についての補足をいくつか書きます。

SSHプロトコル

今回SSHプロキシサーバを実装するにあたり、当然ですがSSHプロトコルの理解が必要でした。そしてプロトコルを理解するために以下のRFCを読みました。

  • RFC4251:SSH Protocol Architecture
    SSHプロトコル全般(暗号アルゴリズムやキーなど)について規定

  • RFC4252:SSH Authentication Protocol
    ユーザ認証(パスワード認証や公開鍵認証)について規定

  • RFC4253:SSH Transport Layer Protocol
    トランスポート層について規定

  • RFC4254:SSH Connection Protocol
    チャネル制御やポートフォワーディングについて規定

中でも今回の実装で重要だったのが、SSH Transport Layer ProtocolSSH Authentication Protocolです。
sshr自体は認証を通して暗号化されたトランスポート層の確立ができると、あとはL4レベルでのプロキシをするだけの役割になるため、アプリケーションのレイヤーでどのような要求がきているか、などは解釈する必要がありません。(ユーザ名だけは取得する必要はある)
これについて図にすると以下のようなイメージです。

f:id:hirotsuru314:20180901172248p:plain

ユーザ認証

今回の実装で一番苦労したのは間違いなくユーザ認証です。それはなぜかというと、今回のようにクライアントとサーバの間に入ってSSHの仲介をするということは、クライアントやサーバにとっては中間者攻撃のように見えてしまうからです。

ユーザ認証とはサーバがクライアントの正当性を確認するもので、その認証方式にはパスワード認証や公開鍵認証などありますが、パスワード認証の場合、実装は簡単でした。パスワード認証の場合は、sshrサーバは特に意識することなくクライアントから受けた認証時のパケットをそのまま後ろの接続先サーバに対して流し、接続先サーバが返してきたレスポンスをそのままクライアントに返してやる方法で認証が確立できます。

一方、公開鍵認証の場合、クライアントはセッションIDというサーバ間のセッションで固有の乱数に対して、秘密鍵で署名して渡します。これによりお互いがなりすましでなく、接続したい相手であることを確認できます。
しかし、sshrサーバではクライアント・サーバに対して二つのコネクションを有しており、その間に入って意図的に中間者攻撃をしているような状態です。 当然二つのコネクションのセッションIDは異なるため、パスワード認証の時のようにクライアントから受け取ったパケットをそのままサーバ側に流すだけでは、認証ができず、二段階のユーザ認証が必要になってしまいます。(sshrの実装もそうなっています)そうなった場合、authorized_keysの運用をどうするかなどの、運用・管理の問題が出てきそうなので、色々考え中です…

sshpiperについて

sshrと同様にユーザ名ベースでSSHの接続先を切り替えられるSSHプロキシサーバとして、sshpiperという素晴らしい既存のOSSがあり、私も実装の参考にさせていただいています。
sshpiperについては以前の記事でも言及しているのですが、今回こちらのOSSを利用しなかった一番大きな理由は、ユーザ名から接続先ホストを特定する処理がパッケージに組み込まれていて自由に拡張できないという点です。そのため、私のsshrでは、前述のPluggable Hooksの部分で、自由にロジックを組み込めるようにしています。
フック以外の部分でも、より汎用性の高いOSSとして追加で機能を実装していくつもりです!

今後やりたいこと

  • テストを書く
    テストを実装し、CIで自動テストする。

  • より汎用性を高める
    現在の実装だと所々でハードコーティングしてしまっている部分があるため、そこをConfigに寄せるなり、Middleware層に寄せてフックをかけるようにしたりなどすることでsshrの汎用性を高めていきたいと思っています。

「A Tour of Go」でGoに再入門した

もうかれこれ4ヶ月くらいGoを書いているんだけど、最初に文法を体系的に学ばずにいきなり書き始めたので、「A Tour of Go」で復習がてら文法を学びました。
実際にやってみると、理解が曖昧だったり、知らなかったことも出てきたので、そういったものだけピックアップして備忘録的にまとめていきます。

deferへ渡した関数はスタックされる

呼び出し元の関数がreturnするとき、 deferへ渡した関数はLIFO(last-in-first-out)の順番で実行される。

package main

import "fmt"

func main() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer fmt.Println("3")

    fmt.Println("return")
}

//=>
return
3
2
1

型推論

明示的な型を指定せずに変数を宣言する場合(:=var =のいずれか)、変数の型は右側の変数から型推論される。

package main

import "fmt"

func main() {
    i := 10
    var j = 3.14
    fmt.Printf("%T\n", i)
    fmt.Printf("%T\n", j)
}

//=>
int
float64

ポインタ(*と&について)

package main

import "fmt"

func main() {
    i := 10

    p := &i         // アドレス演算子「&」を使うと、任意の型からそのポインタ型を生成できる
    fmt.Println(*p) // 演算子*をポインタ型の変数の前に置くことで、ポインタ型が指し示すデータのデリファレンスができる
    *p = 20         // ポインタを通してiに値をセットする
    fmt.Println(i)  
}

//=>
10
20

スライスは配列への参照のようなもの

スライスはどんなデータも格納しておらず、単に元の配列の部分列を指し示している。
スライスの要素を変更すると、その元となる配列の対応する要素が変更される。

package main

import "fmt"

func main() {
    names := [3]string{
        "John",
        "Paul",
        "George",
    }
    fmt.Println(names)

    a := names[0:2]
    fmt.Println(a)
    a[0] = "Tsurubee"
    fmt.Println(names)
}

//=>
[John Paul George]
[John Paul]
[Tsurubee Paul George]

下は配列リテラル

[3]bool{true, true, false}

そして、これは上記と同様の配列を作成し、それを参照するスライスを作成する

[]bool{true, true, false}

スライスは長さ(length)と容量(capacity)をもつ

package main

import "fmt"

func main() {
    s := []int{1, 2, 3, 4}
    fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)

    s = s[:2]
    fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
}

//=>
len=4 cap=4 [1 2 3 4]
len=2 cap=4 [1 2]

mapに要素が入っているか確認

キーに対する要素が存在するかどうかは、2つの目の返値で確認する。
もし、keyがあれば、変数okはtrueとなり、存在しなければ、okはfalseとなる

package main

import "fmt"

func main() {
    m := make(map[string]int)
    m["Answer"] = 10

    v, ok := m["Answer"]
    fmt.Println(v, ok)
}

//=>
10 true

アサーション

インターフェースの値が特定の型を保持しているかどうかをテストするために、型アサーションは2つの値(基になる値とアサーションが成功したかどうかを報告するブール値)を返すことができる。

package main

import "fmt"

func main() {
    var i interface{} = "hello"

    s, ok := i.(string)
    fmt.Println(s, ok)

    f, ok := i.(float64)
    fmt.Println(f, ok)
}

//=>
hello true
0 false

型switch

型switchは通常のswitch文と似ているが、型switchのcaseは型(値ではない)を指定し、それらの値は指定されたインターフェースの値が保持する値の型と比較される。

package main

import "fmt"

func do(i interface{}) {
    switch v := i.(type) {
    case int:
        fmt.Println("Int type!")
    default:
        fmt.Printf("I don't know about type %T!\n", v)
    }
}

func main() {
    do(10)
    do("hello")
    do(true)
}

//=>
Int type!
I don't know about type string!
I don't know about type bool!

selectで複数のチャネルを処理する

selectは、複数あるcaseのいずれかが準備できるようになるまでブロックし、準備ができた caseを実行する

package main

import "fmt"

func main() {
    ch1 := make(chan int, 1)
    ch2 := make(chan int, 1)
    ch2 <- 1

    select {
        case <-ch1:
            fmt.Println("ch1!")
        case <-ch2:
            fmt.Println("ch2!")
        default:
            fmt.Println("default!")
    }
}

//=>
ch2!

Golangで軽量なSSHサーバを実装する

今回は、Golanggolang.org/x/crypto/sshパッケージを使って、SSHサーバを構築してみました。
かなりミニマムな実装ですが、リモートからSSH接続して、対話的にコマンドが実行できるところまで実装しました。

コード

github.com

package main

import (
    "golang.org/x/crypto/ssh"
    "log"
    "net"
    "io/ioutil"
    "fmt"
    "os/exec"
    "github.com/kr/pty"
    "sync"
    "io"
)

func main() {
    serverConfig := &ssh.ServerConfig{
        NoClientAuth: true,
    }

    privateKeyBytes, err := ioutil.ReadFile("id_rsa")
    if err != nil {
        log.Fatal("Failed to load private key (./id_rsa)")
    }

    privateKey, err := ssh.ParsePrivateKey(privateKeyBytes)
    if err != nil {
        log.Fatal("Failed to parse private key")
    }

    serverConfig.AddHostKey(privateKey)

    listener, err := net.Listen("tcp", "0.0.0.0:2222")
    if err != nil {
        log.Fatalf("Failed to listen on 2222 (%s)", err)
    }
    log.Print("Listening on 2222...")

    for {
        tcpConn, err := listener.Accept()
        if err != nil {
            log.Fatalf("Failed to accept on 2222 (%s)", err)
        }

        sshConn, chans, reqs, err := ssh.NewServerConn(tcpConn, serverConfig)
        if err != nil {
            log.Fatalf("Failed to handshake (%s)", err)
        }
        log.Printf("New SSH connection from %s (%s)", sshConn.RemoteAddr(), sshConn.ClientVersion())

        go ssh.DiscardRequests(reqs)
        go handleChannels(chans)
    }
}

func handleChannels(chans <-chan ssh.NewChannel) {
    for newChannel := range chans {
        go handleChannel(newChannel)
    }
}

func handleChannel(newChannel ssh.NewChannel) {
    if t := newChannel.ChannelType(); t != "session" {
        newChannel.Reject(ssh.UnknownChannelType, fmt.Sprintf("Unknown channel type: %s", t))
        return
    }

    sshChannel, _, err := newChannel.Accept()
    if err != nil {
        log.Fatalf("Could not accept channel (%s)", err)
        return
    }

    bash := exec.Command("bash")

    close := func() {
        sshChannel.Close()
        _, err := bash.Process.Wait()
        if err != nil {
            log.Printf("Failed to exit bash (%s)", err)
        }
        log.Printf("Session closed")
    }

    f, err := pty.Start(bash)
    if err != nil {
        log.Printf("Could not start pty (%s)", err)
        close()
        return
    }

    var once sync.Once
    go func() {
        io.Copy(sshChannel, f)
        once.Do(close)
    }()
    go func() {
        io.Copy(f, sshChannel)
        once.Do(close)
    }()
}

コードについていくつか補足します。

ローカルPCとSSHサーバのSSHセッションを確立する前に、まずTCPレイヤーのコネクションを確立する必要があります。私の中でTCPコネクションはローカルマシンとリモートマシンのソケット同士の論理的な回線というイメージで、SSHセッションというと、SSHのレイヤーすなわちアプリケーションレイヤーまで広がり、セッションの中でコネクションが管理されているイメージです。
実際のコードでは、net.ListenTCPをListenし、Acceptして返ってきたTCPコネクションを、ssh.NewServerConnに渡すことで、認証を行った後、SSHのセッションが確立されます。

SSHのセッションが確立されると、あとはSSH経由でコマンドを実行して、その結果が対話的に表示されるようにしたいです。この対話的の部分にはio.Copyを使います。
io.Copyを使うと、io.Readerインターフェースからio.Writerインターフェースにそのままデータを渡すことができます。これを使って、擬似端末(pty)とSSHチャンネル(goのchannelとは違う、SSHのコネクションのようなもの)の間で双方向にCopyしてやると、入出力が対話的にやれます。

使い方

SSHサーバのホスト認証のために、公開鍵と秘密鍵のペアを作成し、リポジトリのルートディレクトリの置きます。

ssh-keygen -t rsa -N '' -f ./id_rsa

生成された公開鍵(id_rsa.pub)はローカルPCのknown_hostsに以下のような感じで登録しておきます。

[localhost]:2222 ssh-rsa AAAAB3・・・・

あとは下のコマンドでDocker上にSSHサーバが起動します。

$ docker-compose up
Starting gosshd_gosshd_1 ... done
Attaching to gosshd_gosshd_1
gosshd_1  | ==> Installing Dependencies
gosshd_1  | go get -u github.com/golang/dep/...
gosshd_1  | dep ensure
gosshd_1  | go run main.go
gosshd_1  | 2018/07/28 12:44:31 Listening on 2222...

ローカルPCからポート2222にSSH接続すると、コンテナの中に繋がります。

$ ssh tsurubee@localhost -p 2222
root@9cd2bdaf33c0:/go/src/gosshd#

今回ユーザ認証の機能はOFFにしているのでユーザ名はなんでも大丈夫です。 あとは普通にサーバにSSHで繋いだときのようにコマンドが実行できます。

root@9cd2bdaf33c0:/go/src/gosshd# pwd
/go/src/gosshd
root@9cd2bdaf33c0:/go/src/gosshd# ls
Gopkg.lock  Makefile   docker-compose.yml  id_rsa.pub  vendor
Gopkg.toml  README.md  id_rsa          main.go

さいごに

今回はミニマム実装なので、実用的なSSHサーバになるまでには、認証周りの実装や、 SSHグローバルリクエストやチャネルリクエストの取り回しも実装しないといけないです。
これからやりたいのはRFCを読んでSSHプロトコルに関する理解を深めるのと、SSHのリバースプロキシを実装してみることです。

参考

GitHub - mattn/go-sshd: DEPRECATED: Please use https://github.com/gliderlabs/ssh instead

「Working with TCP Sockets」を読んだ

最近Golangを書いていると、自分でTCPをListenしたり、Acceptしたりする処理を書くことがよくあるのですが、何をやっているのか全くイメージが沸いてなかったので、「Working with TCP Sockets」を読んで勉強しました。

Working With TCP Socketswww.jstorimer.com

だいたいこの手のソケットプログラミング関連の本は、C言語で解説されていることが多いと思うのですが、本書はサンプルコードが全てRubyなので、非常に読みやすいです。残念ながら英語版しかありません。
以下、私がポイントだと思ったことだけをまとめた読書ノートです。

読書ノート

前半はソケットとは?から始まり、ソケットプログラミング自体の入門的な内容で、そのあたりの理解が曖昧だった私にはとても勉強になりました。
後半は、ソケットプログラミングを使った実用的なアーキテクチャパターンの話でした。Preforkとかイベント駆動とかWebサーバの実装において非常に重要な概念が解説されていましたが、今私が一番知りたいこととは離れていたため、斜め読みしました。

Your First Socket

  • IPアドレスとポート番号のペアはそれぞれのソケットでユニークでなければならない。

Establishing Connections

  • ソケットは、以下のいずれかの役割を持つ
    1.initiator
    2.listener

いわゆるクライアントとサーバ

Server Lifecycle

  • サーバソケットの典型的なライフサイクル
    1.create
    2.bind
    3.listen
    4.accept
    5.close

  • bindは、createしたソケットとlistenするポートをbind(結びつける)
    他のソケットは同じポートをbindできない。

  • acceptメソッドでサーバが受付状態になる。
    acceptは「ブロッキング」な処理である。新しい接続を受け取るまで、現在のスレッドの処理を停止させる

  • acceptの返り値としてconnectionがreturnされるが、connectionはただのSocketクラスのインスタンスである(Rubyの場合)
    connectionのファイルディスクリプタはサーバソケットのものと異なる。各connectionは新しいSocketオブジェクトとして表され、サーバソケットはそのまま残り、新たなconnectionを受け続けることができる。

  • すべてをファイルとして扱うUnixの世界ではSocketもファイルだ。

  • それぞれのTCPコネクションは、ローカルホスト・ローカルポート・リモートホスト・リモートポートの組み合わせで一意に識別される

  • ソケットは双方向通信(読み/書き)なので、いずれか片方だけを閉じることができる

Client Lifecycle

  • クライアントの典型的なライフサイクル
    1.create
    2.bind
    3.connect
    4.close

  • クライアントでは、bindを使うことはまれである。bindを使わなかった場合、クライアントソケットはランダムなエフェメラルポートが与えられる。クライアントは外部からの接続を受け付ける必要がないので、ポート番号を明らかにする必要もない。

  • connectを呼び出すと、リモートのソケットへの接続が始まる

Exchanging Data

  • TCPコネクションは、ローカルソケットとリモートソケットをつなぐ管の連なりのようなイメージ(論理的な回線)

Sockets Can Read

  • UNIXにおいては、全てをファイルとして取り扱う。read(2)やwrite(2)はシステムコールレベルで共通しているので、ファイル、ソケット、パイプ等で共通して利用できる。

  • readの呼び出しは、全てのデータが届くまで待ちを発生させる
    この問題に対する解決策は2つある

  • クライアントがEOFを送る
    EOFは、文字というよりも、状態を表すイベントである。クライアントからEOFを送る際の最も簡単な方法は、ソケットを閉じることである。

  • サーバが部分的な読み取りを使用する
    読み取るデータの最大長を指定して、ブロックせず、すぐに利用できるデータだけが返す。

Buffering

  • writeを呼び出し、エラーなく完了した場合、それは「データの送信に成功し、クライアントが受け取った」ことを意味しない。それは、OSのカーネルによってデータが処理可能な状態になったことが保証されているだけである。

  • バッファリング
    ネットワークを越えたデータ送信は遅いので、できるだけ回数を減らしてパフォーマンスを向上させたい。
    一般的には、一度に全てを書き込むと良いパフォーマンスが得られるが、メモリに載らないよう大きなデータやファイルは分割すべきである。

  • readに読み込みの最大長を渡すと、最大長を超える部分はバッファリングされる
    カーネルは、readの読み込みに指定したサイズのメモリを確保するため、そのサイズが大きすぎると、リソースの無駄が多くなる。
    一方、サイズが小さすぎると、システムコールの呼び出し回数が増えるためパフォーマンスが劣化する。
    どのくらいの読み込みサイズが適切かは、プログラムが取り扱うデータのサイズによって異なる。

Non-blocking IO

  • ノンブロッキングIOは、コネクションの多重化によって実現される。

  • ノンブロッキングIOしたあと、読み込み書き込み可能になるまでを知るにはIO.selectを使う
    selectは登録したソケットをブロッキング状態で監視し、いずれかがデータ受信するとブロッキング状態を解除する。

Multiplexing Connections

  • コネクションの多重化とは、同時に複数のアクティブなソケットを使用することである。

  • select(2)は監視対象のコネクション数が増えると、線形的にパフォーマンスが低下する
    poll(2)はselect(2)の代替だがそれほどの差はない

  • (Linux)epoll(2) または (BSD)kqueue(2) は、select(2)およびpoll(2)のモダンな代替である

SSL Sockets

  • SSLは、公開鍵暗号によって、ソケット上でデータを安全にやりとりできる仕組みを提供する。
    SSLTCPを置き換えるので飯買う、SSLによるsecure layerはTCPの上に追加されるイメージ。

Preforking

Preforkパターンでは、接続がくるたびに子プロセスをforkするのではなく、サーバ起動時にプロセスをまとめてforkしておく
ワークフローは以下の通りである

  1. メインのサーバプロセスがlistenするソケットを作成
  2. メインのサーバプロセスが一群の子プロセスをfork
  3. それぞれの子プロセスが共有されたソケットから接続を受け取り、独立して処理する
  4. メインのサーバプロセスは子プロセスを監視する

Evented (Reactor)

イベント駆動のパターンはNginxを始めとした有名なライブラリで採用されている。
このパターンはシングルスレッド・シングルプロセスで、並列性を実現する
ワークフローは以下の通りである

  1. サーバはソケットでコネクションをモニターする
  2. 新しいコネクションを受け取ると、モニター対象ソケットのリストに追加する
  3. サーバはアクティブなコネクションをモニターしながら、ソケットをlistenする
  4. アクティブなコネクションが読み込み可能になった通知を受け取ると、サーバはコネクションからデータを読み取り、関連するコールバックを実行する
  5. アクティブなコネクションがまだ読み込み可能であるという通知を受け取ると、サーバはコネクションからデータを読み取り、再びコールバックを実行する
  6. サーバが新しいコネクションを受け付けたら、モニター対象ソケットのリストに追加する
  7. サーバは最初のコネクションが書き込み可能になったという通知を受け取ったら、レスポンスが書き込まれる

Working With TCP Sockets (English Edition)

Working With TCP Sockets (English Edition)

ユーザ名から特定したホストにコマンドを実行するSSHプロキシを書いてみる

今回は、勉強のために簡単なSSHプロキシサーバを実装してみました。
動作としては、ユーザがプロキシサーバに対してSSH接続した際に、ユーザ名からプロキシ先ホストを動的に決定し、SSH接続します。そして、接続したホストに対してhostnameコマンドを実行し、実行結果をクライアント側が受け取るという感じのものです。

やりたいこと

今回の実装はまだ実用的なものではありませんが、最終的にはSSHのユーザ名ベースで接続先のバックエンドを切り替えられるSSHプロキシサーバを構築したいと思っています。
イメージは下のような感じです。

f:id:hirotsuru314:20180722151037p:plain

これは一見ProxyCommandなどを使って踏み台サーバ経由でSSH接続をしているだけのように見えるのですが、大きな違いは ユーザ側が接続先サーバのことを全く意識しない ことです。
ProxyCommandの場合、ユーザ側が接続先サーバと踏み台サーバの情報を知っていますが、私がやりたいことは、ユーザはSSHプロキシサーバにSSH接続するだけで、勝手に特定のホストに接続が振り分けられる、というものです。

実際にこのような動作をするSSHプロキシサーバはOSSとして存在します。

github.com

このsshpiperは非常によくできていて、ユーザと接続先サーバの紐付け情報をMySQLで管理して、SSH接続をトリガーに接続先ホストの情報をDBからSELECTするような処理も実装されています。
一方で、DBに投げるクエリがコードに直書きされていて、使う側がテーブル構成等を合わせないといけなかったり、若干柔軟性に欠ける実装になっています。

コード

github.com

package main

import (
    "github.com/gliderlabs/ssh"
    gossh "golang.org/x/crypto/ssh"
    "log"
    "bytes"
    "errors"
    "io"
    "strings"
)

func findUpstreamByUsername(username string) (string, error) {
    if username == "tsurubee" {
        return "host-tsurubee", nil
    } else if username == "bob" {
        return "host-bob", nil
    }
    return "", errors.New(username + "'s host is not found!")
}

func main() {
    ssh.Handle(func(sess ssh.Session) {
        username := sess.User()
        upstream, err := findUpstreamByUsername(sess.User())
        if err != nil {
            log.Fatal(err.Error())
        }
        log.Printf("Connecting for %s by %s\n", upstream, username)

        config := &gossh.ClientConfig{
            User: username,
            Auth: []gossh.AuthMethod{
                gossh.Password("test"),
            },
            HostKeyCallback: gossh.InsecureIgnoreHostKey(),
        }

        clientConn, err := gossh.Dial("tcp", upstream + ":22", config)
        if err != nil {
            panic(err)
        }
        defer clientConn.Close()

        usess, err := clientConn.NewSession()
        if err != nil {
            panic(err)
        }
        defer usess.Close()

        var b bytes.Buffer
        usess.Stdout = &b
        if err := usess.Run("hostname"); err != nil {
            log.Fatal("Failed to run: " + err.Error())
        }
        r := strings.NewReader(b.String())
        io.Copy(sess, r)
    })

    log.Println("Starting ssh server on port 2222")
    ssh.ListenAndServe(":2222", nil)
}

検証用にDocker環境を用意したのでまずはそちらを立ち上げます。

docker-compose up

これでローカルホストの2222ポートでプロキシサーバが立ち上がり、プロキシ先ホストとして、host-tsurubeehost-bobが立ち上がります。
すると、下記のように、ユーザ名tsurubeeでSSH接続したときは、プロキシサーバ経由で、tsurubee用ホスト(host-tsurubee)でhostnameコマンドを実行した結果がクライアント側に出力され、bobユーザのときはbob用ホスト(host-bob)のhostnameの結果が返ってきています。

$ ssh tsurubee@127.0.0.1 -p 2222
host-tsurubee

$ ssh bob@127.0.0.1 -p 2222
host-bob

このように同じローカルホストの2222番に接続してもユーザ名を元にプロキシされるサーバが振り分けられています。

今回、ユーザ名からプロキシ先ホストを特定するfindUpstreamByUsername関数に簡単に条件分岐を書きましたが、本来この処理はDBから取得するなり、APIサーバから取得するなりしておきたいところです。

また、SSHサーバの構築には、gliderlabs/sshを使いました。これを使うと簡単に指定のポートでListenするSSHサーバを立てられるので便利です。

gliderlabs/sshでListenしたSSHサーバにリクエストがきて、セッションが確立されると、ハンドラーが呼ばれるため、そのハンドラーはプロキシ先に対するSSHクライアントとして動作するように書いています。

最後に

本来は接続先のサーバに対して、ターミナルからサーバにログインした時のように対話的に操作できるようにしたいんだけど、いまいちセッションとかコネクションの取り回しが理解しきれていないため勉強中。
しかし、中途半端でも実装してみると、学びは多かったので、コードファーストで学んでいくスタイルは効率が良さそうに思えた。
もうちょいプロトコルに対する理解や、Golangの抽象化レイヤーへの理解を深めていって、いい感じに書けるようにしていきたい。