この記事はJX通信社 Advent Calendar 2019 2日目の記事です。
昨日は、たっちさんの「Kubernetes Admission Webhookでリソース作成を自在にコントロールする」でした。
こんにちは、サーバーサイドエンジニアの @kimihiro_n です。 今回は長年動かしてた Scala のマイクロサービスのリビルドを行った話をしようと思います。
TL;DR
- 新しい言語を投入するのにマイクロサービスは便利
- Scala で感じていた問題点を解消しつつ Go へ移行できた
- 消費メモリが大きく減って安定稼働できるようになった
予防線を貼っておきますと、Scala より Go のほうがいいよね、といった本旨ではありません。
Scala で書いたマイクロサービス
弊社のマイクロサービスの一つにカテゴリ分類専用のサービスが存在します。 カテゴリやキーワードを登録しておくとルールベースでカテゴリ分類を行うもので、自然言語やMLを使うほどでもない大雑把な分類を低コストで実現するためのサービスです。
持ってる機能としては
- カテゴリやキーワードを REST 形式で CRUD 操作できる
- テキストを分類エンドポイントへ POST するとカテゴリ分類が実行される
というシンプルなものです。2015年4月に作り始めたのでもう4年以上稼働しているシステムになります。
このシステムは最初 Scala で作り始めました。当時社内で Scala 勉強会というのが行われており、そこで学んだ知識の実践のために Scala を使って実装をはじめました。 独立したサービスなので新しい言語を持ち込むには最適のタイミングだったと思います。 また計算的な処理も多いのでメインで使っている Python よりもパフォーマンス出せるのではないかという期待もありました。
いろいろあったものの (本筋ではないので省略) 無事サービスとしてリリースでき、今日まで稼働してくれました。 ただ4年間動かす上でインフラ周りの大きな変遷がありました。
インフラの変遷
当初は専用の EC2 を建てて、そこに Ansible でデプロイするという形式で動かしてました。インスタンス自体の管理をしなくてはならないものの、サーバーのリソースを専有して動かせるので安定して動かす事ができていました。
その後、Docker が全社的に流行りだし、このサービスも Docker の上で走るよう変更しました。ビルドした war ファイルを Docker Image に乗っけるだけだったので移行はスムーズでした。
Docker の運用も最初は ElasitcBeanstalk を使って専有インスタンスで稼働していましたが、会社の ECS の基盤が整ってからは共有のサーバーの上で動くようになりました。
共用サーバーになって顕在化してきたのが Scala のメモリ使用量の問題です。 1タスクをメモリの割当量が256MBでは安定して動かすことが出来ず OOM が頻発してしまいました。512MBや1024MB消費することもあったので実装上のメモリリークも疑わしかったのですが、そもそも最低専有量が大きくて共用サーバーでは扱いづらいサービスとなっていました。 Docker の上に JVM を載せてその上にアプリケーションを載せる2階建ての構造になっているので、どうしてもオーバーヘッドが大きくなってしまうみたいです。 (最近は GraalVM のようなものが出てきて事情が変わりつつあります。)
その他 Scala で発生していた問題
またメモリ以外に Scala を扱う上で発生していた問題がいくつか。
コンパイルが遅い
開発のタイミングで一番の課題はこちらでした。インクリメンタルビルドがあるものの、多くの場合コンパイル待ち・テスト待ちで手が止まってしまっていました。フルにビルドが走ってしまうと10分くらい待たされることもありました。普段はインタプリタの Python メインで書いていたのでなおさら待ち時間への戸惑いを感じました。
書き方の統一が難しい
Scala はとても高機能な言語です。オブジェクト指向でありながら関数型のパラダイムを取りこんでいる特徴を持ってます。 それ故にプログラマの Scala 熟練度によって書き方が変わってしまう問題がありました。 Better Java 的な書き方から、より Scala らしい書き方、関数型を意識した書き方などいろいろな表現方法があります。 プログラムを書く上で「こういう書き方あるよ」と教えてもらってスキルアップできるのはとても楽しいですが、システムを保守する上でネックになりがちでした。 全社的に Scala の知見があって、コーディングの方針が統一できていればよかったのかもしれませんがそこまでの体制は作れませんでした。
JVM の知見が少ない
先程のメモリの話にも共通することですが、会社として JVM のサーバー運用に対して知見が溜まってなかったというのもあります。メインで使っている Python であればサーバーを動かすための知見が溜まっていて安定的に動かすことができたのですが、JVM のシステムは社内初だったため不安定になったり、問題発生時に適切な対処が取りづらかったです。
一時期新サービスは Scala で書いていこうみたいな勢いがありましたが、これらの問題のためか結局 Python メインに戻ってしまいました。
Go でリビルド
Python は個人的にもしっくりきていてとても扱いやすい言語だと思っているのですが、プロジェクトの規模が大きくなってくるにつれ「型」の重要性が増してきました。静的な型付けがあると、それ自身がコードのテストになり実行時の予期せぬエラーを減らしたり、型の情報を生かして IDE による効率的な開発が出来たりします。 Python にも Type Hints と呼ばれる型のサポートがありますが、エディタによってサポートのばらつきがあったり、間違った型付けをしていても mypy を通さない限り気づかなかったりしてイマイチです。
そこで目をつけたのが Go でした。 静的型付け言語の候補はいくつもあるのですが、コンパイルが速く Docker とも相性がよい言語を探してるうちに Go がよさそうなのでは、と思うようになってきました。 言語仕様もシンプルで書き手に左右されづらかったりと Scala のときに感じていた課題点を解消できそうな点もよさそうでした。あとコンパイルの速さとかコンパイルの速さなんかも大事ですね。 最終的に Go やろうと決め手になったのは社内で Go を利用しているプロジェクトがすでにある点でした。社内にすでに触れる人が複数名いるというのは非常に心強いです。
Tour of Go をこなして基本を覚えたあと、言語を覚えるには実践が一番ということで Go でかけそうなシステムを探してました。 そこで出てきたのが例の Scala のマイクロサービスです。独立したサービスで改修しやすく、APIの仕様も固まっており規模もそんなに大きくないという理由から、カテゴリ分類サービスを書き直してみることにしました。
やったこと
仕様が固まっていたので BDD (振る舞い駆動開発) を採用してテストコードを充実させながら進めていきました。
BDD のフレームワークを使うと
シナリオ: カテゴリ一覧がみれる 前提 insert_categories.sqlの中身がDBにセットされている 前提 メソッドがGET もし /v1/categoriesへアクセスする ならば ステータス200が返ってくる かつ レスポンスのカテゴリが1件入っている
みたいな形で振る舞いを記述することができます。 この形式自体は Gherkin と呼ばれる形式で、プログラムの実装言語によらず共通して定義することができます。 日本語のドキュメントのような形なので非エンジニアでも何をしたいのかが明確になるメリットがあります。
Go の場合 DATA-DOG/godog
gucumber/gucumber
といった BDD のテストフレームワークが存在します( Python だと behave
というものがあります)。
今回はDATA-DOG/godog
を利用してテストを書いていきました。
実際テストを書くときは、上記のシナリオの 1 行 1 行(=step) に対応するコードを準備します。
たとえば「ステータス200が返ってくる」に対応するコードだと以下のようになります。
func (c *Context) CheckStatus(status int) error { if c.resp.Code != status { return fmt.Errorf("status_code %d is not %d", c.resp.Code, status) } return nil }
200 の部分はパラメータとして利用できるので汎用的なテストコードを実現することができます。 BDD のフレームワークを利用すると嬉しいのが、振る舞いの記述を追加してもその分だけテストコードが増えるわけではないという点ですね。同じ名前の step は同じ関数が再利用できるので、開発が進むほどテストコードに割く時間が減ってくる特徴があります。
Web のフレームワークは Echo を使ってみました。Go の場合フレームワークを使わなくてもいいみたいな話を聞きましたが、REST API の場合、GET や POST, PUT... といったメソッドレベルでのルーティングが必要となってくるため、フレームワークを利用して見通しをよくするようにしました。
Go を書いていて思うのはやはりコンパイルが速くて快適ということですね。テスト込みでも数秒で終わってしまうので TDD、BDD といった手法と相性がいいです。 あとは Go 言語は筋肉が要求されるというのが実感できました。例外処理もないので、愚直にエラーをチェックして返り値にセットするみたいなのを頻繁に書く必要があります。 ただ慣れてくると返り値を多値にして error を返すという仕様がとても自然に思えてくるようになってきました。 例外で横道から抜けられてしまうよりはちゃんと関数の結果として返ってくるほうが考慮漏れずに記述できる感じがします。 記述が長くなってしまうことについては「筋肉、筋肉…」と思うことで納得しています。
サービスリプレース
業務の合間の気分転換にちょっとづつ進めること約半年、ついにサービスが完成しました。 DB まわりのテストどうするか悩んだりきれいな設計になるようリファクタしてたりしてたら結構かかってしまいました。
Docker 化
FROM golang:latest as builder ENV CGO_ENABLED=0 \ GOOS=linux \ GOARCH=amd64 \ GO111MODULE=on WORKDIR /opt/app COPY . /opt/app RUN go build # runtime image FROM alpine COPY --from=builder /opt/app /opt/app CMD /opt/app/basic_classifier
ECS に載せるため、Docker のイメージを作ったのですが Dockerfile がとてもシンプルでした。 バイナリを生成できてしまうと強いですね…。最終的なイメージサイズもかなり軽量です。 ECS だと起動のタイミングでイメージの Pull が走るので、イメージが軽いことは起動の高速化にも繋がります。
デプロイ
いよいよ実戦投入です。ECS で別タスクとして作っておき簡単にロールバックできるようにして切り替えました。
問題なく動いてる…と思いきや大きな問題が。 カテゴリ分類サービスを利用してるシステムの一部で予期せぬ挙動が起こってしまいました。 原因を精査してみたところ、API の仕様が変化しているのが元凶でした。 パラメータ名がスネークケースではなくキャメルケースという…。 サービス作成時に策定した仕様書に沿って実装していたのですが、Scala 版の実装のほうが間違っていて実装しており、利用側も実装を正に進めていたため、切り替えのタイミングでトラブルが起きてしまってました。
切り戻してシステムを改修したところ無事動くようになりました。
パフォーマンスの変化
実際 Scala から Go に置き換えてみてメモリ消費量がどうなったかというと
のようにリリースを境に大きくメモリ消費量を減らすことが出来ました。
(Scala 版右肩上がりなのでメモリリークも疑わしいです…)
クラスタに余裕ができたので台数減らしたり、その分他のサービスを載せることができそうです。
一応 Apache Bench を使って簡易的なベンチマークもとってみました。
軽めのテキストの分類 Scala: Requests per second: 97.24 [#/sec] (mean) Go: Requests per second: 118.89 [#/sec] (mean) 重めのテキストの分類 Scala: Requests per second: 20.37 [#/sec] (mean) Go: Requests per second: 29.34 [#/sec] (mean)
Go 書くときパフォーマンスはあまり意識せず書いていたので、Scala より性能が上がるのは嬉しい誤算でした。 分類結果は一致しているので大きくロジックを変えたつもりはないですが、コード的に完全移植したわけではないのでこのベンチマークは参考程度に見てもらえると。 Scala 強い人が書いたら十分逆転もありえそうです。
おわりに
1機能が独立して動くマイクロサービスみたいな構造だとこういった置き換えが気軽にできていいですね。 サービスまるまる実装してみて Go への理解がだいぶ進んだように思います。 パフォーマンスや使い勝手も悪くないのでこれから Go どんどん触っていきたいです。
明日は@shinyorkeさんの「データをいい感じに活用するためのアジャイルな言語化とその取り組み」です。