キムのテックブログ

来世つよつよソフトウェアエンジニア志望の現世ぱんぴー大学生です

voyage groupのsunriseに参加してインフラ修行をした

先日11/14,11/15にvayage groupの「sunrise」インターンに参加してきました!
2日のみのインターンでしたが、学びの多い2日間だったため記憶が新しいうちにまとめます

なにしたの?

テーマは「大規模アクセスにも耐えうるようなインフラ基盤を構築せよ」というもの。
インフラを学べる機会ってなかなかないと感じていたから、このインターンを見つけた際に速攻で応募した。 (しかもAppの実装がGoだったので尚更)

内容をちょーざっくりまとめると、

  • 1.AWS内のEC2サーバーにリクエスト投げまくる(負荷をかける)
  • 2.かけた結果をLog監視、メトリクス監視、リクエストを全て捌けているか確認(大抵うまく捌き切れていない)
  • 3.なんで捌けてないの? => チーム内で議論し仮説を立てて実験

こんな感じ。上のサイクルをひたすら繰り返す。

インフラ構成図

今回の題材のインフラ構成は下記だった。

f:id:mos692:20201119151411p:plain
sunriseインフラ構成図

  • kakeruっていうツールがLBにリクエストを大量に投げる
  • LBが各種インスタンスにリクエストをバランシング
  • EC2内のGoアプリケーションがリクエスト内容をRDSにinsert。
  • GoアプリケーションからのレスポンスがLB、kakeruに返される。レスポンスの結果をS3にアップ。
  • redashを使ってRDSに接続し、DBにデータが保存されているか確認。

この中で、インターン生がパフォーマンス向上を考える際に着目した点は大きく下記の2点だと思う。(RDS数の変更は認められていなかった)

実際に自分のチームで行った施策を書いていきます。

実際の流れ

負荷は200 req/secで3分間のものを与えました。 しかし、5xxエラーがたくさん出る。

Code Highest Rate Mean Rate Total number
200 1189.6 / sec 394.68 / sec 118481
502 1053.5 / sec 272.51 / sec 68127
504 377.4 / sec 58.29 / sec 13406

はじめはこんな感じでリクエストのうちの 1/3がエラー。しかもリクエスト数が期待される総数の360000(200 * 60 * 3)に一致しない。。
こんな感じでスタート。

DBのopenはサーバー起動時に1回

まずぱっと見で目についたGoのアプリケーションの修正。 初期実装ではhandlerの中でsql.Open()が行われていました。

hakaruHandler := func(w http.ResponseWriter, r *http.Request) {
    DB, err = sql.Open("mysql", dataSourceName)
    if err != nil {
        panic(err)
    }

リクエストの度にsql.Open()を呼んでいますが、sql.Openは同じドライバを使い続ける限りサーバー起動時1回で十分です。
そのためinit()中にdbの接続を行わせるように変更。

ただこれだけではerrorは消えず。。

DBにアクセスが集中している

Goアプリケーションのインスタンスで下記のログが出ていたため、「dbへのアクセス過多でerrorが発生しているのでは?」と推察。 f:id:mos692:20201116162709p:plain

そこで、負荷付与中においてどれくらいのconnectionが貼られているのかをnetstatコマンドで調査。

[root@ip-10-1-11-242 ~]# netstat -na | grep 3306 | wc -l
2658
[root@ip-10-1-11-242 ~]# netstat -na | grep 3306 | wc -l
3893
[root@ip-10-1-11-242 ~]# netstat -na | grep 3306 | wc -l
4687
[root@ip-10-1-11-242 ~]# netstat -na | grep 3306 | wc -l
5131
[root@ip-10-1-11-242 ~]# netstat -na | grep 3306 | wc -l
5634
[root@ip-10-1-11-242 ~]# netstat -na | grep 3306 | wc -l
6174
[root@ip-10-1-11-242 ~]# netstat -na | grep 3306 | wc -l
6720
[root@ip-10-1-11-242 ~]# netstat -na | grep 3306 | wc -l
7362

port3306に5000~7000のアクセスが来ている状況。 メンターの社員の方にDB connectionは多くても数十程度という事を伝えられたので、 DBが捌ける量を遥かに超越した数のアクセスが来ていると判断。

DBのconnection数をGo側で制限

Go側で上記の対策を行いました。 具体的にはDB.SetMaxOpenConns、DB.SetMaxIdleConnsを設定し、dbアクセス数を制限するというものです。

DB.SetMaxOpenConnsはコネクションの最大数を表し、DB.SetMaxIdleConnsはidleなコネクションの最大数を設定します。

[参考]
パフォーマンス向上のためのsql.DBの設定 - 技術メモ

Go database/sql(コネクションプール/タイムアウト) - Qiita

コネクション数の管理に関しては上記の記事が参考になりました!
今回はRDSに来るconnectionが最大でも40 ~ 50になるように設定しました。

DB, err = sql.Open("mysql", dataSourceName)
    if err != nil {
        panic(err)
    }
    DB.SetConnMaxLifetime(time.Minute * 3)
    DB.SetMaxOpenConns(10)
    DB.SetMaxIdleConns(10)

これで再度実験。 f:id:mos692:20201116171642p:plain port3306へのconnection数はかなり減った。

Code Highest Rate Mean Rate Total number
200 1628.3 / sec 641.24 / sec 192441
502 6 / sec 0.98 / sec 167
504 624.4 / sec 91.37 / sec 21928

しかしerrorは消えていない・・

インスタンス数を増やす

これまではインスタンス数を2つで回していた。ただ、2つだとDBからの処理が遅れた際にGoに来たリクエストがタイムアウトしてしまうのでは?(ここは正直仮説)という話になり、インスタンス数を5台に増やして検証。

Code Highest Rate Mean Rate Total number
200 1894.6 / sec 1217.31 / sec 290796

ようやく秒間200reqを捌けました。 数の暴力ってすげーなーってのと、ロードバランサーくん優秀だなあって感じでした。

学び

各種メトリクスの見方

AWSのcloudwatch等を使って、アプリケーションの状態を監視する基礎的な手段が身についた。 満たしたい用件に対して、どのメトリクスに着目すべきかっていう判断の部分も鍛えらたと思う。

DBへのconnection poolingとか最大connection数に関して

最大コネクション数とかほとんど気にした事なかった。ただ結果的にGoのapplicationコードに

DB.SetConnMaxLifetime(time.Minute * 3)
    DB.SetMaxOpenConns(10)
    DB.SetMaxIdleConns(10)

この3行を追加しただけで5xxがかなり減ったので、大規模サービスではconnection数の管理も大事なんだあと知った。

try and errorを繰り返す

インターン中は、「仮説、実験、結果、考察」のサイクルをworkという単位で定めて、これをひたすら1日中繰り返すみたいな感じだった。(アジャイルでいうスプリントに近い感じ?)
これが大体1~2時間くらいの単位なんだけど、この時間設定が個人的に良かった。

長すぎると検証できる事が増えすぎてまとまらなくなりそうだけど、1時間という時間だと1つの仮説に対して「実験、結果、考察」のサイクルを回すのが丁度良かったように思う。

世間は狭い

twitterで知ってる人とか、別のインターンで一緒だった人がちょこちょこいて、「世間狭い!」ってなった。数として、やっぱ学生エンジニアって少ないやなあという実感を得た()

まとめ

AWSはEC2を使ってアプリデプロイした事がある程度の経験だったけど、監視周りについての基礎的な部分がかなり吸収できた。

あと、インターン中ではあんま触れなかったけどインターンのプロジェクトフォルダが.tfとMakefileで溢れてて「これがインフラの現場かー」ってなったので、その辺りも時間ある時に調べつつ読んで吸収していきたい。

kubernetesについてまとめ

ほぼ自分用。 kubernetes(以下k8)について調べたので、それに関するメモ。

k8とは?

  • コンテナオーケストレーションツール
  • 別々の役割を持ったコンテナを連携させ、提供したいサービスを実現させる

k8を使う手段

大きく2つあります。

自前でk8環境を作成する

k8環境を全て自分で用意するという手段。 インフラ知識が十分あるわけじゃない場合は割と重い選択かも。
今回の自分のケースだと、あまりいい選択肢ではない。

クラウドで提供されている各種マネージドサービスを利用する

GAE, EKSをはじめとしたk8のマネージドサービスがいくつかあり、これを利用するというものです。
一般的にk8は各コンテナをマネージドするマスターと、実際にサービスを提供するワーカーという2つのノードに分けられますが、これらマネージドサービスを利用する場合は マスターノードはほぼ考えなくてもよくなるらしいです(各種設定をマネージドサービス側でやってくれる)。

自前でk8を構築する場合はこのマスターノードの構築も必要になるため、深いインフラの知識が必要になる。

k8学習のためのlocal環境は?

- Docker for macのk8機能を用いる

・docker for macからkube環境を作れる
Docker for Mac で Kubernetes をちょっと試す - Qiita

- minikubeを用いる

vmを入れるとminikubeという簡易的にk8環境を作れるツールが使える。
Kubernetesを手元で試せる「Minikube」「MicroK8s」とは (2/3):これから始める企業のためのコンテナ実践講座(4) - @IT

まとめ

はじめはマネージドサービスで動かしてみて、慣れてきたら自前で環境作ってみるって方向性が良さそう。

参考

www.atmarkit.co.jp

【Golang】Graceful Shutdownでお行儀よくサーバーを閉じる

ctrl + cなどでサーバーを閉じてしまうと、後続処理を無視したままプロセスを終了することになります。不慮の事故を避けるためにも、シグナルのハンドリングはなるべくしておきたいところです。 今回は特にhttpサーバーの起動を例にして「graceful shuttdown」、行儀の良いサーバーのとじかたの実装をメモしておきます。

ポイントは2つあります。

実行中にシグナルを受け取れるようにする。

http.ServerのShutdownメソッドを使って、安全にserverを閉じる。

上記をそれぞれ見ていきます。

シグナルを受け取れるようにする。

package main

import (
    "fmt"
    "os"
    "os/signal"
)

func main() {
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt)
    fmt.Printf("\nsignal %d が受信されました。\n", <-quit)
}

シグナルを受け取る、最も簡単な例です。 os.Signal型のchanを定義してしておき、signal.Notifyにそのchanと、キャッチするシグナル(上記の例だとos.Interrupt)を渡します。 signal.Notifyは、指定したsignalが飛んできた時にchanに通知する動きを実現してくれるので、上記は起動してからctrl + cを叩くまで 処理をブロック、シグナル受領後にprintが実行されます。

Shutdownメソッドを使う

http.Server構造体のShutdownメソッドを使用します。 これはまさに安全にserverを落とすために用意されたメソッドで、Go 1.8から追加されたメソッドだそうです。 アクティブなコネクションはシグナルが送られたあともハンドリングを続け、新規のコネクションを閉鎖するようになります。

    s := &Server{Addr: addr, Handler: handler}
    s.ListenAndServe()

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := s.Shutdown(ctx); err != nil {
        logger.Fatal(err)
    }

上記のように、引数にはcontextを渡します。

これらを組み合わせよう

この二つを組み合わせて「シグナルハンドリング&graceful Shutdown」を実現しましょう。 ここで、先に自分がハマったアンチパターンを先に書いておきます。

func main() {
    s := http.Server{}
    s.ListenAndServe()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt)
    fmt.Println("blocking until getting signal")

    fmt.Printf("\nsignal %d が受信されました。\n", <-quit)
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := s.Shutdown(ctx); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("server shutdown gracefully!!\n")
}

これを実行して、ctrl + cでシグナルを送ると下記のようになります。

go run .
^Csignal: interrupt

シグナルがうまくハンドリングされていません。。。
これは、サーバーを起動している際はs.ListenAndServe()の段階で処理がブロックしており、シグナルを送るとsignal.Notifyの処理に届く前にプログラムが終了してしまうからです。

サーバー起動時にもシグナルハンドリングできるようにするためには、サーバーをgo routineで起動してやります。

func main() {
    s := http.Server{}
    go s.ListenAndServe()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt)
    fmt.Println("blocking until getting signal")

    fmt.Printf("\nsignal %d が受信されました。\n", <-quit)
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := s.Shutdown(ctx); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("server shutdown gracefully!!\n")
}

シグナルが受け取れました! server shutdown gracefully!!🎉

近況と、最近感じた理想のエンジニア像みたいな話。

近況と、最近感じた理想のエンジニア像みたいな話。

開発ハッカソンに参加

1週間ほど前から、下記の2ヶ月間ほどのハッカソンに参加させて頂いている。

プロダクト開発ブートキャンプ - 元Google,YouTubeでも活躍のトップエンジニアから最新のプロダクト開発手法を学ぶ -|IT勉強会ならTECH PLAY[テックプレイ]

プロダクトを作る際の重要な考え方や、流行っている開発手法等をtimさんっていう元googleでソフトウェアエンジニアをされていた方々から教わり、最終的にチームで実際にプロダクトを考案、開発しリリースを目指すってのが、このハッカソンの概要。
参加者は12人ほどいて、社会人の方が7割学生が3割くらいの比率だ。社会人ではL○NE社の方やG○nosyのエンジニアの方も参加されてて、後でも書くけど本当に勉強になることが多い。日程は平日は毎日昼の1時間(恐らく社会人の方はお昼休みに参加してることになる)、休日は土曜日に10時~14時にオンラインで活動するので結構ボリュームはある。
初週は開発というより、これからのハッカソンの進め方や学習の進め方、個人で作りたいプロダクトの案出しと言った部分を中心に行っていった。

んで、今日でちょうど1週間が終わったタイミング。正直この1週間だけでも、周りの社会人の方と接する中で「こういうエンジニアいいな」って思えるタイミングが幾度かあったので、メモとして書き残しておきたい。

失敗も成功もぼやきも全てシェアする「オープンソースマインド」

「どんなことでもチームにシェアするマインド」がすさまじい。
とにかくslackがすごい。なんかあったらすぐslackに書く。slackでこんなに通知が来るチャンネルは初めて、ってくらい。 わからない事、有益な記事、プロジェクトに関係ありそうな動画、環境の構築周りで詰まった点などなど、ありとあらゆる情報がslackに流れてくる。

バンバンシェアしてくれるのは社会人の方が多いので「単純にslackというツールの使い方に慣れている」って言われると確かにそれもあるかもしれない。でも、自分が今まで触れてこなかった文化を確実に感じてる。
「他の人が自分と同じミスをしないようにシェアしよう」「自分以外の人もこの記事を読むかもしれない」「自分が考えた部分をシェアしよう」みたいな、チームの利益を意識した文化だ。

わかりやすくslackの例をあげたけど、毎日のミーティングでも情報シェアを意識しているなーって感じる瞬間はめちゃくちゃある。

んで同時に、この「シェア」の思想って、ソフトウェアの世界における「オープンソース」の考え方とすごい近いなあって感じた。
ソフトウェアの世界ってある種異質だと思ってて、著名なプロジェクトでも誰もがそのソースを確認できたり、コードが正しければ誰だってそのプロジェクトに口出しだってできる。一度オープンに公開すれば、他者がライブラリとしてそのプロジェクトを使うことができる。またDRY原則に基づいて、同じコードや過ちを繰り返す事は許されない。 このように、一人一人の開発者が自分の成果物や失敗等ををオープンに公開する事によってソフトウェアの世界は高速に発展してきたって背景がある。

元々このオープンソース的な考え方は好きだったんだけど、これまではやっぱ個人で作ることが多くて、正直半ば忘れかけてた概念だった。 けど今回の1週間でやっぱこの思想好きだなーって再確認したし、将来は個人開発者ではなく、やっぱチームでプロダクト作りたいって思ってるから、まさにこのオープンソースを体現したソフトウェアエンジニアで在りたいなあ、と。

一緒に参加されてるエンジニアの方を見ててそう感じた。

まだまだ続く

まだ始まって1週間しかたってないけど、結論から言うと本当に参加できて良かったと感じてる。 これは自分がハッカソンに参加したいと感じた理由にも関係するんだけれど、「チームで動く」事の経験の少なさが特に自分の中で弱みに感じていた。 自分自身、ソフトウェアエンジニアの企業インターンに参加したのが今年初とかだったのでソフトウェアエンジニアってどうやってチームでプロジェクトを動かしているのかすごく不透明な部分があり、チーム開発みたいな部分は特にもっと経験しておきたい部分だった。
その点で言えば、今はかなり絶好の機会かなあと感じている。

今回はマインド的なメモ話だったけど、ハッカソンで得た技術的な知識に関しても時間があるときに書いていきたいと思う。

もちろん、ブログ執筆もオープンソースマインドで、発信していく姿勢を途切らせないようにしていきたい。。(戒め)

【Golang】sync.Mutexって?

sync.Mutex。カッくいー名前してますよね。
このsync.Mutex、標準パッケージでもちょくちょく見かけます。

あれ?「たまに」動かない。。

sync.Mutexが解決する課題を見るために、下記プログラムを考えます。
このプログラム、「たまに」エラーを起こします。

package main

import (
    "fmt"
)

func main() {
    s := map[string]int{"score": 0}

    done := make(chan bool)
    go func() {
        for i := 0; i < 5000; i++ {
            s["score"]++
        }
        done <- true
    }()

    go func() {
        for i := 0; i < 5000; i++ {
            s["score"]++
        }
        done <- true
    }()

    <-done
    <-done
    fmt.Println(s["score"])
}

play.golang.org

上記は5000回のforのループですが、ループ回数を増やすとエラーになる確率が上がるはずです。
この「たまに」ってのが怖いですよね。開発中に気づかないと本番環境でエラーなんてこともあり得そうです。

データ競合

上記のエラーはどうして発生するのでしょうか?
それは、2つのgoroutineで「同じタイミングで同じデータにアクセスする」という、データ競合が発生することがあるからです。
お互いの5000回のループの中で、どっかの時点で同じタイミングでmapを見に行ってしまうとerrorが起きます。

複数のgoroutineで同じ値を参照する、なんてことは往々にしてあるので、たとえ確率は低くてもこのようなエラーが起こることは致命的です。 これをsync.Mutexは解決してくれます。

sync.Mutexを使うと

package main

import (
    "fmt"
    "sync"
)

func main() {
    s := map[string]int{"score": 0}
    mux := sync.Mutex{}

    done := make(chan bool)
    go func() {
        for i := 0; i < 50000; i++ {
            mux.Lock()
            s["score"]++
            mux.Unlock()
        }
        done <- true
    }()

    go func() {
        for i := 0; i < 50000; i++ {
            mux.Lock()
            s["score"]++
            mux.Unlock()
        }
        done <- true
    }()

    <-done
    <-done
    fmt.Println(s["score"])
}

これでerrorがさっぱり消えるはずです。 mapへのアクセスの前後にmux.Lock()とmux.Unlock()を追加しました。
mux.Lock()を行うと、他のgoroutineでLock()の先に進めなくなります。 そのため、Lockの先に進めるgoroutineは1つだけになり、同時にmapにアクセスすると言ったことが防げる訳です!
その後Unlockでロックを解除し他のgoroutineがLock()の先に進めるようになります。

まとめ

そもそも「データ競合」って概念を知らなかったので、こんなエラーがあるんだなあと。。 その割に、goroutineで同じ値を監視するっていう動き自体はよくあると思うので今後はしっかり競合が起こりそうなポイントにはLockを取ろうと思いました(反省)

【Golang】クライアントにjsonレスポンスを返す際のアンチパターン

ハマり解決したのでとりあえずメモだけ。原因はわかり次第追記します。

http.ResponseWriterにどのように書き込んでいますか?

・fmt.Fprintf(writer, "(書き込む内容...)")
・writer.Write()

僕はこの主に2パターンでresponse bodyを書いてました。
しかし、jsonでデータをやりとりする場合、fmt.Fprintfだとうまくいかない。

NGパターン
type UserScore struct {
	Name    string `json:"name"`
        Score     int      `json:"score"`
 
}
responseData := UserScore{"hoge", 22}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "%v", responseData)
}))

/*  ~~~  */

var us userScore
resp, err := http.Get(ts.URL)
byteArray, _ := ioutil.ReadAll(resp.Body)
err = json.Unmarshal(byteArray, &us) /* ここでerror発生 */ 
OKパターン
type UserScore struct {
	Name    string `json:"name"`
        Score     int      `json:"score"`
 
}
responseData := UserScore{"hoge", 22}
data, _ := json.Marshal(responseData)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write(responseData)
}))

/*  ~~~  */

var us userScore
resp, err := http.Get(ts.URL)
byteArray, _ := ioutil.ReadAll(resp.Body)
err = json.Unmarshal(byteArray, &us) /* ここでerror発生 */ 
まとめ

responseWriterであるwへの書き込みがw.Write(data)か、fmt.Fprintf(w, "%v", responseData)かの違いで起こったので、それぞれの実装を詳しく見ると原因がわかりそうです。余裕ある時また調べます。
あとこれ一応testingのサーバーで発生したものなんですが、そこは恐らく無関係だよね?

【Golang】Goのnilについてnilは"空っぽ"ではなく、nilという実態

nilについて今まで詳しく考えてこなかったのですが、今日初知りポイントがあったのでメモ。

問題

以下のプログラムの実行結果、標準出力には何が出力されるでしょうか?

package main
import (
	"errors"
	"fmt"
)

func hoge() error {
	return nil
}

func main() {
	err := errors.New("error1")
	if err != nil {
		err = hoge()
	}
	fmt.Printf("%v", err)
}
答えは.....

nil」ですね。僕ははじめerrが表示されると思っていました。。

処理の流れですが、main関数内のはじめでerrorを生成しているので、その次のifは条件式がtrueとなり、hoge()が実行されます。
問題はこの次です。

hogenilを返しますが、「nilは何も返さない」ということではなく「nil」という実態を返します。
したがって、errの変数には「nilという値」が代入され、最後のprintではnilと表示されるのです。


nilは「空っぽ」を表す訳でなく、きちんと実態をもつんですね。。