nakamura244 blog

所属団体とは関係なく、個人的なblog

キーワード検索をリプレイス&独立した検索サービスを立ち上げた

はじめに

ようやく、ユーザ向けにリリースが終わった様子なのでここで一つまとめておこうと思います。

どこの部分をリプレイスしたのか?

この部分です

PC

f:id:tsuyoshi_nakamura:20191008113113p:plain

SP

f:id:tsuyoshi_nakamura:20191008113147p:plain

おことわり

フロントのHTMLの組み込みのところは別のチームのエンジニアにお願いしていて、自分はバックエンド側のみになります

今までの課題感や要件的なもの

  • 今まではmysqlのLike検索だった。サービスが成長して、Like検索だとSlow Queryが多く出はじめたでこれを対応したい
  • mysqlのチューニングやプラグインで対応していく方向性でも出来ないことはないと思うけど、手数がいるし、スケール難易度が上がるのでその方向性はやめたい
  • feed検索サービスとして、既存のシステムからなるべく切り離して独立性を保って運用していきたい
    • マイクロサービス化の流れですね〜
  • せっかく、新しくするのなら、サジェストとかは入れたいな〜

対応チーム

自分一人です

どんな構成で検索システムを開発したのか? 其々説明する

まずは検索システム自体の選定

  • まぁ一人なので、可能な限りmanagedされたサービスを使うべきだなと思っていた
    • 間違っても自作なんてしちゃ...エンドレスだと思います
  • 検索エンジンとなればまぁSolrかElasticsearchだなぁ
    • まぁElasticsearchだろうなぁー
      • AWSのElasticsearch ServiceとElasticCloudを比較し始める

で、圧倒的にElasticCloudのが運用者からすると楽だと思った。 あとは今後要件によってプラグイン開発の必要性が出てきた場合はAWSのElasticsearch Serviceだと不可能(現時点)なので困るなぁと思ってElasticCloudにした

将来的な要望みたいなのを聞いているとおそらく、synonym辞書関連とかその辺りでカスタムプラグインを作る必要性が出てくる気がしたのも理由のひとつ。

今後の利用想定も含めてインデックスされるサイズや、想定リクエストを計算。当初カスタムプラグインを使わないとしたのでスタンダートプランにした。

Elasticsearchの把握

  • とにかくドキュメントがかなり充実していたのでひたすら読んだ。途中ただの英語の勉強の時間みたくなってたけど、ひたすら全てに目を通した
    • よくわからない所はlocalに構築したElasticsearchで試しながら理解に努めた
  • ちょうどタイミングよくElastic{ON}ツアー@東京が開催されたので、参加してキャッチアップに努めた

全体アーキテクチャ

今回はElasticsearch部分はElasticCloudでまるっとマネージされるのでインデックスの更新の運用だったり、既存システムとどう独立して稼働させるかという部分が考えるポイントでした。

今回の構成を考えるにあたり、日時のindexの更新が多くて約20件程度(今後数年で大きく増えるものではない)、今後の発展として、機械学習関連の要件を見越したいという事を念頭に設計しました

要件と現状の分析はざっくり下記

  • 更新頻度からしてインデックス更新に関わるワークフローは待機している時間が圧倒的に多い
  • 検索対象になる連携データは、今後の要件によって何かしらの処理・整形が必要になる事が考えられたのでfeed側の内部DBに格納した方が良さそう
    • これも現時点では常にselectやupdate,deleteが行われるものではない

という事から下記のように基本設計をしました

f:id:tsuyoshi_nakamura:20191008143440p:plain

point

Functional

  • 待機している時間が圧倒的に多いのでLambdaを活用
  • 内部DBも活用時間が少ないのでAuroraのserverlessを活用
    • Lambdaから内部DBに接続するので、connection数を食いつぶさないように一工夫を入れた
    • Data APIの利用は諦めました
  • コールドスタート + VPC内lambdaでも5~10sec程度で動き出すので全然okだった
    • ビジネス的には数分内に検索可能な状態が作り出せれば良いとの事だったので
  • Lambdaの中は全てGoでクリーンアーキテクチャにのっとり開発した

SQS

  • 検索対象となるデータ(=プロジェクト情報)が更新されたら、SQSにqueueが登録されます
  • SQSのキューを取ってくるとLambdaが稼働します。そのLambdaは適切な粒度のjobに分割していて、Step Functionsで管理しています
  • SQSを挟んでいる理由は、feedサービスを独立したものにする為とfeedサービス側のメンテナンス等で他のサービスに影響を与えない為です
    • メンテナンスを実行する場合は、dequeueをやめて、メンテをする流れを想定

Step Functions

  • 今後データの整形や機械学習関連のワークフローが具体的に出てきた時の為に柔軟に組み換えがしやすいように各ジョブを適切な粒度に分けて、Step Functionsでつなげる事にした
  • 途中で何かエラーを検知したら、Slackへ通知されます
    • AWS SNS経由のSlack通知にしとけば通知先が多彩になるが、やっていない...
  • その後、最終的にElasticsearchのドキュメント更新を行います
  • Step Functionsの中身はこんな感じです f:id:tsuyoshi_nakamura:20191009121423p:plain

API Gateway

実際の検索をさせる時に直接Elasticsearchに接続して検索でも良かったが、API Gatewayを挟む事にした。理由は下記

  1. 利用する他のチームの人がElasticsearchのクエリー記法を覚える必要が出てきてしまう
    • 独立したサービス(マイクロサービス文脈)で考えると、クエリーは簡単な形式が望ましい
    • クエリーによってはリニアな検索になってしまう事もあり得るのでそのあたりの制御が難しくなってしまうだろうと思った
  2. 検索されたキーワードを今後の為に集計しときたいな〜と思った
    • 検索キーワードをCloudWatchに一定期間残すようにして、それをLambdaでキャッチして再びElasticsearchに入れている
      • そうする事でこの機能によって検索クエリーのレスポンスに影響が出ないようにした
    • 検索されたキーワード自体はいつまでも必要なデータではないので、Elasticsearch側のindex-lifecycle機能を使って古いものは削除するようにした
  3. もし、想定外の検索リクエストがきて、やばいとなった時に一時的にthrottle機能でしのぎたいと思った
    • throttle機能発動中にElasticsearchのクラスターのupdateしたいと考えていた
  4. Elasticsearchへの接続元をセキュアに保ちたかった
    • IAMだったり、headerのAPIkeyだったりを活用してセキュアな接続を実現しようとした
    • Elasticsearch側ではBasic認証での接続制限しか提供されてなかった事も背景にある
  5. API Gatewayの所でのキャッシュ機能もorigin=Elasticsearchを守る意味でもアリだなと思った
  6. ロキシーがいる事のメリットはあるだろう

致し方なく妥協した所

f:id:tsuyoshi_nakamura:20191008143843p:plain

  • 検索対象となるデータを取ってくる所ですが、makuake側の内部のエンドポイントが存在しない為、直接DB参照に致し方なくしました(妥協)
    • これによってmakuake側のDBスキーマの変更を気にしなければいけなくなったが、やむなし。
  • 今後、疎結合のエンドポイントが出来上がったら参照を切り替え予定

ちょっとハマった点

Lambdaから内部DBに接続する所でLambdaがスケールしていっぱい稼働するとmysqlのconnectionを食いつぶしてしまう所でした。

Data APIがまだ出たばかりでちょっと不安だったので、代わりにStep Functionsの実行状況を見ながら次のjobの実行を制御するようにした

DescribeExecution - AWS Step Functions

または、Aurora Serverlessの部分をDynamoDBにしたり、Elasticsearch内に同様の役割のindex(=ストレージ)を作ってしまうというような代替もありだと思う

この辺りはAWS LOFTにちょっと通って相談させてもらいました。担当頂いたソリューションアーキテクトの方にはお世話になりました🙇🏻

パフォーマンス検証

検索クエリー

f:id:tsuyoshi_nakamura:20191009132647p:plain

  • 秒間NアクセスをN秒間続けてという一般的な負荷試験を実施した
  • どこにクリアすべき基準を持っていくるかによって、Elasticsearchの組むクラスターのスペックは決まりますが、割と最小構成にした。
  • API-Gatewayでのキャッシュ、Elasticsearch側でのキャッシュをそれぞれ試したが、前者のAPI-Gatewayでのキャッシュさせた方が結果は良かった
    • そりゃそうなんだけど
  • 当然だけどindexの設計やクエリーによっても結果は変わるのでその辺りは注意でその情報も一緒にまとめた

結果はまとめてesa

f:id:tsuyoshi_nakamura:20191009133838p:plain ↑イメージ

今の構成でどこまでのリクエストは耐えれるかを把握できた

ワークフロー

f:id:tsuyoshi_nakamura:20191009132703p:plain

  • SQSのqueueを大量に貯めてから、一気にワークフローを動かす試験を実施しました
  • 何かしらの障害が発生して、SQSのqueueが溜まってしまった状態や、更新頻度が想定よりかなり増えた時の事を想定したテストを実施した
  • SQSのqueueがN件でN分ほどかかるというだいたいの処理時間が把握できました
  • 試験中エラーがCloudWatch上に出力されていないかを確認しました
  • 最終的なSQSの更新件数がElasticsearch側の更新件数と一致したかどうかを確認しました

セキュリティ関連

  • 基本的にAWS上に構築されている部分はIAMベースで且つVPC内で利用が制限されている
  • ElasticsearchもKibanaを用いたユーザ管理をしている(ElasticCloud内で提供されている機能)
    • このユーザはselectしかできないユーザ、このユーザは更新も可能なユーザといった感じ

モニタリング関連

f:id:tsuyoshi_nakamura:20191009134837p:plain

  • 会社の方針に基本合わせる事にした
    • mackerel+data dog,slack
  • あとはKibanaのMonitoringを見てる
  • KibanaのWatcherは今のところ使っていない

データライフサイクル

  • CloudWatch内に出るログ関連は基本的にN日で削除設定をしている
  • キーワード検索ログのインデックスを日付ごとに作成し、index life sycle 設定でindexを自動削除で運用
  • ElasticsearchのSnapShotは1日1回で3日分だけを保存するようにしている
    • 作成しているインデックスがゼロから10min以内で作れてしまうぐらい小さい点や、ビジネス的な要件によって変わると思う

Dev / CI / CD

f:id:tsuyoshi_nakamura:20191009135635p:plain

このあたりも自身での運用サーバはなくて、外部サービスで完結するようにしている

しかし、最近apexのメンテが終了したらしく、デプロイツールの変更をしなきゃいけない...

GitHub - apex/apex: Build, deploy, and manage AWS Lambda functions with ease.

最近であればGitHub Actionsを使ったフローもできると思われる

infrastructure as code

他のチームはterraform使っていて、正論だけで言えばterraformでcode化すべきなんだけど、正直一人チームではToo Matchだし、先に進めづらい...😰

設定jsonesagithubにcommitしておく事で対応している

人が増えるような事があれば対応をまた考える

今のところ得られた効果

1

まぁmysql Like検索から比べたら可用性や負荷耐性などはもちろんアップ

2

サーバ負荷が目に見えて改善した f:id:tsuyoshi_nakamura:20191009144954p:plain

  • mysqlのLike検索してた分がごそっとなくなって、レスポンスが早くなっている事が確認できる

3

コスト面。多分費用対効果は良いはず

STG環境1つと本番環境を1つを運用していて下記のコストが毎月発生している

  • ElasticCloudは計算上、過去2年間で最大時の負荷に耐えれるスペックのインスタンスで構成して約月190ドル
  • AWS Lambdaで約月3.5ドル
  • AWS StepFunctionsで約月0.5ドル
  • AWS Aurora Serverless 約月72ドル
  • AWS API Gateway 約月55ドル(その月のアクセス数による)

トータルで321ドルで監視のmackerelを入れたとしても+10ドル程度で331ドルになります。

1ドル107円計算で変換すると約35,542円になります。約4万円弱で単なる検索システムだけでなく、独立した1つのマイクロサービスのシステムを構築できたということはそれなりに満足している

可視化してる

1

f:id:tsuyoshi_nakamura:20191010215324p:plain 検索にかかっている時間(ms)をグラフにしたら平均で見てたりします

よく検索されるクエリーTOP20の検索時間(ms)はちょっと気にしてたりします

この辺の数値はSLAの一つに入れて日々運用していく形になるかと思います

2

f:id:tsuyoshi_nakamura:20191010215343p:plain どんなキーワードで検索されている事が多いのか等を眺めてたりします。

ちょっとしたホットワードがわかったりします

ElasticCloudへの要望点もあったりする

  • ElasticCloudのログインアカウントが一つしか設定できない。チームでのログイン管理が無理。なのでセキュアなログイン設定ができない
    • 本当はGoogle Authenticatorとか使いたいけど、ログインアカウントが一つしかないので... 他の人がログインできなくなる
  • 問い合わせサポートだけの上位サブスクリプションプランが欲しい
    • 現状の仕組みだとサポート対応だけを迅速に行いたい場合でもゴールド以上のサブスクリプションプランに移行いないといけない。
  • トライアルで試していたが、カスタムプラグインをアップロードできるが、消すことができない
    • なので不要となったカスタムプラグインがいつも画面に存在してて見にくくなる時がある
  • アラートのメールはどうやらstatusがgreenじゃない時に発泡されるが、diskのサイズやメモリの使用具合で閾値決めれて、自分でアラートが出せるようになるとありがたい
    • Advanced watchとか使ったらやりたい事できるのかなぁ...

今まで運用してきての踏んだ不具合

1: Lambdaでのエラー

下記のようなエラーを出力した

"Error": "Lambda.SdkClientException",
 "Cause": "Unable to execute HTTP request: The target server failed to respond"

内容は下記のものらしい。retryを設定

Lambda サービス例外の処理 - AWS Step Functions

2: Apexの終了

メンテが終わると途中で知り、デプロイツールを今後変えて行かなきゃいけない...

今後の予定

検索に評価指数の導入

キーワード検索の結果からリンクをクリックした場合、下記のようなURLになっている

https://www.makuake.com/project/perfect_beer04/?from=keywordsearch&keyword=%E3%83%93%E3%83%BC%E3%83%AB&disp_order=1

GETパラメータでfrom=keywordsearch&keyword=ビール&disp_order=1となっている。

これはキーワード検索ページからビールというワードで検索して1番目に表示された結果をクリックして遷移してきたという意味になります

これをアクセスログベースで集計してDiscounted cumulative gainという指標に照らし合わせて向上させていこうと考えています

Discounted cumulative gain - Wikipedia

Google Analytics側にイベントを送って集計でも良いんだが、一番確実な方法を取ろうとしている

これはこれでAWS Glueあたりをうまく活用していきたいと思っている

最後

  • いかに手数をかけずにコストも抑え、独立したマイクロサービスの1つの検索システムを作れるかという点においては満足している
  • 2ヶ月ほどで出来たこともちょっと満足している
  • ElasticCloudの中の製品はまだもっと面白いの機能があって、十分にプロダクトに活かしきれていないという点はこれから改善していきたい
  • 評価指数を元にこれから精度の改善に努めたい
    • ただし、UIの部分も範囲になってくるので、その辺りをどう進めたら良いのだろうかとちょっと考えている。私はデザインできない。。。
  • 他社さんでもキーワード検索をリソースの関係上ひとりで担当する場合がまぁまぁあるんじゃないかと思います
    • その際に少しでもこの情報が役に立てば幸いです

おまけ

  • バックエンドが完了した後、最終的なフロントエンドの組み込みは他のチームが担当してくれた。しかし、そのチームの事情により、3ヶ月の保留になってしまった
    • マイクロサービス化の流れで他のチームに自分が影響を与えることが出来ず、致し方なく3ヶ月もの間待つことになった。こんな歪みも発生するんだんぁーと実感
  • 実は自分が構築した構成と似たパッケージ=functionbeatというものがあった

CourseraでMathematical Thinking in Computer Scienceというビギナーコースが終了したので残しておく

はじめに

最近、いつもと違う側面から自分のエンジニアリングを鍛え直そうと考えていて、その一つにCourseraで少し興味を持ったものを受講している

その中で一つコースをパス出来たので残しておこうと思う

パスしたコース

f:id:tsuyoshi_nakamura:20191011011422p:plain

少し振り返って見て

内容的にはビギナー向けということもあり、そこまで難しいものではなかったと思う。

だいたい、毎日2~3時間ぐらいは使っていて約1ヶ月程度で終了した。

なによりも、授業がall 英語で且つロシア訛りの英語でその聞き取りに苦労した。英語字幕がなかったらかなり厳しかったと思う。

まぁ入り口としてはよかったと思う

Go Conference Fukuoka 2019から刺激を受けてanalysis toolを作った

はじめに

先週Go Conferenceに参加させてもらいました。そこで良い刺激をもらい、その勢いでtoolを作ったのでblogに残しておこうと思います

Go Conference'19 Summer in Fukuoka - Go Conference'19 Summer in Fukuoka

聞いたセッション

たくさんのセッションを聞かせてもらった。がタイトルの通りその中でもより良い刺激を受けたセッションが下記です

Goによる静的解析のはじめかた - Google スライド

Go Conference'19 Summer in Fukuokaで登壇してきた | おそらくはそれさえも平凡な日々

このセッションを聞いて、よし自分もanalysis tool作ろうっと思った

作ったtool

github.com

どんなtoolか?

go fileのimport Pathを抽出して、あらかじめ定義した依存ルールに違反をしていないかを静的解析するツールです

会社のプロダクトでCleanArchitectureを採用しているものがあります。そのArchitectureの一つのポイントがDIP(Dependency Inversion Principle) = 依存関係逆転の原則 というものがあります。

このルールにちゃんと則っているかのチェックが、go fileのimport部分を見れば簡単にチェックができると思ったので作りました

しかし、実はこの手のツールは既にありました。

GitHub - roblaszczak/go-cleanarch: Clean architecture validator for go, like a The Dependency Rule and interaction between packages in your Go projects.

最初はこのツールを使っていました。が、下記の不満点が自分の中で出てきました

感じた課題感

  • golang.org/x/tools/go/analysisを使ったらもっと簡単にできるなぁ
  • プロジェクトによってレイヤー名が異なるのでその辺はそれぞれにconfigで可変できるようになると使いやすいなぁ
    • このツールでは決めうちされていた
  • それぞれのレイヤーでディレクトリを掘っていくとpackage名が必ずしもレイヤー名とならない場合があって、その場合困った
├── infrastructures
│   ├── a
│   │   ├── b.go

上記の場合、bというファイルのpackage名はaとしている場合とinfrastructuresとしている場合があると思う。前者のパターンにしている場合、このツールではうまく解析ができなかった

  • 自分で作ったパッケージの依存関係はチェックできるけど、ビルドインのパッケージ、サードパーティーのパッケージの依存のチェックはできなかった。
    • CleanArchitectureだとexternalレイヤー以外では外のpackageのimportは禁止されてるけど、そのチェックができていない
    • 例えば、CleanArchitectureでいうinterfaces層からビルドインパッケージのimportの良し悪し。domain層からのビルドインパッケージのimportの良し悪し。等はおそらくチームの規約を決めて運用する必要があるが、その辺りも規約をカスタムしてチェックしたいなーと思っていた

モチベーション

上記の課題感を自分で解決してみようと思い、勉強がてら作った

話を戻して今回作ったtool(dependency-check)はどういう風に使うのか

readmeにも色々書いたが改めて。

1. まずはinstallしてください

go get github.com/nakamura244/dependency-check

2. configを配置します

pathはどこでも大丈夫ですが、外部のCIツールでも活用する場合は、該当のレポジトリ内に設置する方が良いかと思います

ex:

base:
  innerPath: &innerPath github.com/xxx/xxx  # チェックをしたいレポジトリのパスを記載

layer1:
  layerName: infrastructures     # レイヤー名を任意でつけます
  packageNames:
    - github.com/xxx/xxx/infrastructures          # layerNameに属するパッケージ名をパスから記載(複数設定可能)
  innerPath: *innerPath                           # エイリアス
  allowDependPackages:                            # 依存して良い(import可)パッケージ名をパスから記載(複数設定可能)。ただし同じリポジトリ内のパッケージに限る。nullの場合は同リポジトリ内のパッケージの依存を許さないという設定になる
    - github.com/xxx/xxx/domain
    - github.com/xxx/xxx/interfaces
    - github.com/xxx/xxx/config
    - github.com/xxx/xxx/infrastructures/iface
  allowDependBuildIn: yes                             # infrastructuresレイヤーはgoのbuildinパッケージに依存しても良い(import可)かどうかの設定。値は`yes` or `no`
  allowDependOutside: yes                             # infrastructuresレイヤーはgoの外部のパッケージに依存しても良い(import可)かどうかの設定。値は`yes` or `no`

上記はlayer1の設定ですが、layer4まで設定可能です

下記のexampleを見てもらっても良いかと思います

https://github.com/nakamura244/dependency-check/blob/master/testdata/src/valid/.dependency-check/config.yml

3. Run

実行したいリポジトリに移動して下記のコマンドを実行

dependency-check -ignoreTests=true -config=./.dependency-check/config.yml ./...
  • -ignoreTests=trueはtestファイルは無視するかどうかの設定
  • -config=./.dependency-check/config.ymlは2で作成したconfig yamlのパスを設定
  • ./...再帰的に解析したいパス

4. Result

失敗した時はexit status 1で終了します。

出力は成功の場合、失敗の場合は下記のように標準出力されます https://github.com/nakamura244/dependency-check/blob/master/README.md#run

注意!!

CircleCIで組み込んで利用する場合、environmentGOROOTを指定しないと期待値通りに動きません。自分の環境下ではそうでしたというだけかもしれないけど

https://github.com/nakamura244/dependency-check/blob/master/.circleci/config.yml#L63

https://github.com/nakamura244/dependency-check/blob/master/README.md#caution

実際に会社のプロダクトに適用してみた

CircleCIを使っていてそのJOBの一つに加えました f:id:tsuyoshi_nakamura:20190719112345p:plain

workflowの一部にdepcというjobを追加して、dependency-checkを実施しています。このjobがfaildすると次のjob(deploy)ができない感じになっています

最後に

運営の方々には良い機会を提供いただき感謝しかありません。お疲れ様でした。

やっぱり実際に足を運んで、技術に触れる事は成長につながるなーと改めて思いました。

おまけ

スペシャルゲストで福岡市長が来てくれました。政治家ってプレゼンテーションが超絶うまいんだなーと体験しました

福岡市長高島宗一郎 GO conference'19 summer in fukuokaに出席しました - YouTube

Goのdatabase周りのテストではinterfaceを最大限に活用しようという話

はじめに

  • Goで色々とアプリケーションを書いていてストレージ(主にRDB)との結合部分のテストを書く事があると思います
  • だいたいは実際のストレージにアクセスするテストを実行して、テストをするかと思います
  • 最近、自分はこのストレージ部分とのテストをより良くするにはどうすべきかと考える時があります
  • そこでdatabaseの結合部分のテスト周りで最近改善してみた事をまとめたいと思います

今までどうしてたか

localの場合

  • まず、実際のテスト用のHOST,DB,USER,PASSWORDをconfigなり、環境変数化しておいて、起動時に切り替えれるようにしておく
  • unit test用のDBを用意しておく
  • テーブルスキーマのmigrationを流しておく
  • test用のconfigで起動してgo testを流す。
  • 実際にテスト用のDBに対して、select,update,deleteのテストを行う

circleciの場合

  • まず、実際のテスト用のHOST,DB,USER,PASSWORDをconfigなり、環境変数化しておいて、起動時に切り替えれるようにしておく
  • mysqlをimageから立てる
  • テーブルスキーマのmigrationを流す
  • test用のconfigで起動してgo testを流す。
  • 実際にテスト用のDBに対して、select,update,deleteのテストを行う
  • (全て.circleci/config.ymlに書く)

まぁ、これでも良いのですが、下記の課題を感じていた

感じてた課題感

1. 何回かに1度落ちる

  • goのテストはGOMAXPROCSの値がデフォルトの同時実行数になって起動する
  • DBへfixtureデータを投入する時におろらく上記の同時実行の副作用でfixtureデータセットが予期しない値になることがあるっぽい
    • それでテストが落ちることがあった
    • fixtureデータをセットする時にtransactionを用いてデータセットしても改善はされず、rerunでワークアラウンドしてた
  • 同時実行数を1つにするのも一つの手ではあるが、なんかせっかくの良さが失われてもったいない気が...

2. error時のテスト

例えば下記のコード

   tx, err := db.Begin()
    if err != nil {
        return err
    }
    // sql query
    const sqlstr = `UPDATE .... WHERE id = ? `
    result, err := tx.Exec(sqlstr, id)
    if err != nil {
        tx.Rollback()
        return err
    }
    err = tx.Commit()
    if err != nil {
        return err
    }
    .
    .
    .

わりとよくあるコードだと思います Begin(),Commit()が失敗した時のテストって、実際にDBアクセスしたテストを実施していると意図的にエラーを起こさせるのが難しい、またはかなり面倒だったりします

んでどうしたか? ... 題名の通り、interfaceをフル活用した

色々と説明するよりはコードにした方が伝わると思ってサンプル書きました

github.com

  • mysqlなしで、database周りのテストが実行できるサンプルコード
  • 割とちゃんと書いた!! ので、下記の解説と一緒に参考にしてもらえると良い

解説

f:id:tsuyoshi_nakamura:20190630134042j:plain

かんたんな説明

  • db層,interface層,repository層にまずレイヤーわけします
  • db層
    • buildinではいっているdatabase/sqlのメソッドを使ったロジックを構築します
    • database/sqlのメソッドを使う時はinterface経由で使います
    • そうすることによってmock化が可能になりtestableになります
    • returnはinterfaces層のinterfaceを返却します ... ※1
  • interfaces層
    • 同階層にあるinterfaceを利用して、メソッドを作ります ... interfaces.sql_repository.go
  • repository層
    • interfaces層で作られたメソッド(interfaces.sql_repository.go)のinterface登録をします
  • mainからはdb/sql.goからmysql connectionを取得、各ディレクトリのinterfaceに連続登録
  • mockテストで使うScanの実装の最低限使いそうな部分だけ移植してきた

memo

※1 ... database/sqlの作り的に1つのstructメソッドだけで完結していないのでこのような形になると思われる

実際にやって見ての発見

1. エラーを握りつぶしているパターンに気づけた

例えば下記のコード

func (d *db) Getxxx(id int64) (i []*models.xxx, err error) {
    sql := `SELECT ...WHERE id = ? `
    rows, err := db.conn().Query(sql, id)
    if err != nil {
        return
    }
    defer func() {
        err = rows.Close()
        if err != nil {
            return
        }
    }()
    .
    .
    .
    for rows.Next() {
        err := rows.Scan(
            ....
        )
        if err != nil {
            return
        }

    }
  • Scanの時にエラーが発生した場合、returnのerr変数にScan時のエラーがセットされる
  • しかし、defer func()rows.Close()の結果が、err変数に再設定されてしまう
  • そうすると、エラーがに握りつぶされ、処理が先に進み、後続処理に悪影響を及ぼす

2. ハンドリングしなくても良いかなと思えたメソッドを発見

下記のコード

func (repo *SQLRepository) InsertUserWithTx(u *User) (uint, error) {
    tx, err := repo.Begin()
    if err != nil {
        return 0, err
    }

    const sql = `INSERT INTO users ( ` +
        `email ` +
        `) VALUES (?) `
    res, err := tx.Execute(sql, u.Email)
    if err != nil {
        tx.Rollback()
        return 0, err
    }
    lastID, err := res.LastInsertId()
    if err != nil {
        tx.Rollback()
        return 0, err
    }

    rowsAffect, err := res.RowsAffected()
    if err != nil {
        tx.Rollback()
        return 0, err
    }
    if rowsAffect != 1 {
        tx.Rollback()
        return 0, err
    }

    err = tx.Commit()
    if err != nil {
        return 0, err
    }
    return uint(lastID), nil
}
  • transactionをもちいたinsertメソッドのサンプルコードになります
  • 何かエラーが発生した時にtransactionをRollbackします
  • このRollback()はハンドリングしなくても良いかなと思いました
  • Rollback()のメソッドを見るとerrorを返す場合があるので本来であればハンドリングが必要
  • 仮にハンドリングをするのであれば、リターン値を(uint, error, error)としなければ多分厳密にはダメなんだと思った
    • rollbackメソッドのエラーなのか、rollbackしなきゃいけなくなった前のエラーなのかを見分けないと行けないので
    • でもそのようにしているコードをあまり見ない。(自分の視野が狭いだけかもしれないが)
  • 再度考え直すと、Rollback()メソッドはBeginが実行された時、go tx.awaitDone()というメソッドも実行されて、connectionがcloseされた時にrollbackを実行するという実装になってるっぽい
  • なので極端なはなしRollback()がなくても良いのだが、不要になったものは早めに解放してあげる事は良い事だと思うので、ハンドリングなして実行でも良いのかなと思った
  • 上記のコードにはconnectionのcloseメソッドは書いていないが、dbのconnectionはlifetimeで一定期間が過ぎれば、closeして再度connectが走るので想定通りの動きになるはず ... (dbのconnectionは再利用して構築するのが多いですからねー)

まとめ

  • とは言え、実際にデータがselectできるか、updateできるか、deleteできたかは実際のmysqlに対してテストをしないと担保はでいないので、使い分けが必要だと思う
  • databaseに関わらず、他の外部APIをコールする時でも同じ要領で実現できると思います。その時はdatabase/sqlnet/httpに置き換わるだけで考え方は同じで行けると思う
  • これでよりテストが行き届いたコードになるのではないかと思うと同時にライブラリに対する知識が増した
  • 試しにcoverageをとってみた結果が下記になります f:id:tsuyoshi_nakamura:20190630144642p:plain f:id:tsuyoshi_nakamura:20190630144630p:plain sql.Openのところはテストしていないが...他は全部通過したtestableなコードとなりました

Containerで動かすGoの常駐worker

前提

前のプロジェクトの時にAWS SQSのキューをポーリングして稼働するworkerをgoで作った時のお話になります。 goのworkerはcontainerで動かすといった感じです

課題

その時に課題だったのが、goのworkerを改修してリリースする時にcontainerを破棄して、新しく再構築します。

workerは常に稼働しているのでworkerが何か処理をしている時にOSレベルで破棄され、強制終了するとデータが不整合な状態になってしまうという課題があった

sudo kill -SIGTERM 終了シグナルが送られた時、そのままworkerが終了してしまう事の弊害です

上位にLoad Balancerなるものがある場合は対応が楽になりますが、今回はポーリングしてるworkerなのでちょっと状況が異なります

よくある形

常駐化の役割はsupervisorを活用するパターンがあります。

GitHub - Supervisor/supervisor: Supervisor process control system for UNIX

supervisorが、go workerの終了までwaitしてくれて...とかという感じでうまく立ち回ってくれる事を期待しましたが、検証の結果ダメでした。

この時もsudo kill -SIGTERM 終了シグナルを送られると、Supervisorは即座に終了してしまい、workerも即座に落ちてしまうのでこちらも不整合データが発生する可能性があります

課題に対するアプローチ

  • まず、処理の途中を記憶して、再構築されたら、途中から処理を再開というようなリジューム機能のようなものはちょっと考慮すべき点がありすぎて回避した
  • 構築するworkerの処理自体は何分もかかるものではなかったので、破棄される際は途中の処理が最後まで完了するまで待つようにした
  • アプリケーション(go)レベルではsignalを監視して制御するようにした

という方針で解決する事にしました

実際に解決した時のコードをサンプルとして残しておきます github.com

ちょっと解説

  1. mainスレッドでは1つgoroutineを発生させて、OSのsignalを監視しています
  2. 終了シグナルをキャッチするとstopChチャネルで伝達します
  3. taskA(),taskB()は本来のworkerの仕事を行っています
    1. func task()taskA(),taskB()の共通メソッドをdryにしただけのものです
    2. 常駐させるため、forを活用します。SQSのキューのポーリングが切られたらdefaultのところで再度立ち上がり、無限ループします
    3. 終了シグナルのstopChを気にしながら動きます selectの記載
    4. 終了シグナルをキャッチすると処理が終了したらreturnして、タスク終了チャネルtaskADoneCh,taskBDoneChに伝達します
  4. mainスレッドは各タスクの終了 = taskADoneCh,taskBDoneChを待って終了します

代替も考えてみる

f:id:tsuyoshi_nakamura:20190611103719j:plain

  • ポーリングとworker部分を切り離して構築してしまうパターンです
  • ECSのgoのところがworker部分になります
  • go workerのリリースの時にまず、SQSのポーリングしているlambdaを一時的に切ってしまいます
  • そうすると後続処理は終了さえ待てば、次に動く事はないので、安全にリリースが可能になります
  • このパターンだとgo worker側では終了シグナルとかを気にしなくてよくなる分、codeがシンプルになると思います。
  • ただし、deployの自動化を考えるとちょっと手数が入りますね...
    • 意外とこれはこれでネックかもしれません

最後

まぁどちらのパターンにせよ、安全にworkerを動かせれると思います。

Dynamic Array : 配列へのappendでちょっとハマったのでメモ (panic: runtime error: index out of range)

はじめに

www.hackerrank.com

この問題を解いている時にハマったのでメモしとく

1次回答

上記の課題に対する回答で最初下記のように書いていた

func dynamicArray(n int32, queries [][]int32) []int32 {

    var ret []int32
    var lastAns int32 = 0

    s := make([][]int32, len(queries))

    for _, q := range queries {
        qType := q[0]
        x := q[1]
        y := q[2]

        index := (x ^ lastAns) % n

        if qType == 1 {
            s[index] = append(s[index], y)
        }

        if qType == 2 {
            elIndex := y % int32(len(s[index]))
            lastAns = s[index][elIndex]
            ret = append(ret, lastAns)
        }
    }
    return ret
}

これでsubmitするとコケるテストケースが存在したerrorは下記

panic: runtime error: index out of range [recovered]
        panic: runtime error: index out of range

s[index] = append(s[index], y)の所でエラーを出すテストケースが存在する様子

んでtest caseデータを実際にダウンロード(2.1M もあった!!)して手元で試した

s := make([][]int32, len(queries))に考慮が足りていなかった事に気づいた

2次回答

問題を色々と読んでいると配列の要素にappendしたくなるんだが、s["xxx"] = []int32{}のような形で格納しても問題ない事に気づいた。

そうすれば最初にsの長さとか計算しなくても良い。というかsの長さを最初に計算できるのか自分には不明だった😰

計算できるとより早く動くと思うんだけど...

最終回答

func dynamicArray(n int32, queries [][]int32) []int32 {
    
    var ret []int32
    var lastAns int32 = 0
    var s map[string][]int32 = map[string][]int32{}

    for _, q := range queries {
        qType := q[0]
        x := q[1]
        y := q[2]

        index := (x ^ lastAns) % n
        strIndex := strconv.Itoa(int(index))

        if qType == 1 {
            s[strIndex] = append(s[strIndex], y)
        }

        if qType == 2 {
            elIndex := y % int32(len(s[strIndex]))
            lastAns = s[strIndex][elIndex]
            ret = append(ret, lastAns)
        }
    }
    return ret

これでとりあえず全テストケースがクリアできた😃

予想以上に時間を費やしてしまったのでついでにblogに書いておいた

最後

  • 思っても見ない所でハマるもんだなぁ
  • 英語で問題の内容を理解する事に一番時間を使っている気がする😰😰

ML前のデータ前処理系の学習メモ

主に学習できた点

特徴量をどう見極めて行くかという一つの手法を学んだ

題材にしたデータ

House Prices: Advanced Regression Techniques | Kaggle

kaggleのstarterがとっつきやすそうなdatasetがあるやつを選んだ

自分はローカルで動かして理解したいので、データセットが小さめだと学習しやすくてありがたい

さっそくやっていく

可視化

まずは、どういったデータが揃っているのかということを見極めることからはじめていくわけですが、pandas-profilingというものを使うとあっという間に可視化してくれる

GitHub - pandas-profiling/pandas-profiling: Create HTML profiling reports from pandas DataFrame objects

f:id:tsuyoshi_nakamura:20190428202622p:plain

とても長くなっているのでファーストビューの所だけをcapった

でも

pandas-profilingだけだといっぱい色々ありすぎて正直ビギナーにはわからない。学習のためにも自分で色々といじって可視化してみよう思った

先人たちのkernelを参考にした

このkagglerのkernelのコード Blend&Stack LR&GB = [0.10649] {House Prices} v57 | Kaggle で下記の部分に注目した

1

train['YrBltAndRemod'] = train['YearBuilt'] + train['YearRemodAdd']
train['TotalSF'] = train['TotalBsmtSF'] + train['1stFlrSF'] + train['2ndFlrSF']

train['Total_sqr_footage'] = (train['BsmtFinSF1'] + train['BsmtFinSF2'] + train['1stFlrSF'] + train['2ndFlrSF'])

train['Total_Bathrooms'] = (train['FullBath'] + (0.5 * train['HalfBath']) + train['BsmtFullBath'] + (0.5 * train['BsmtHalfBath']))

train['Total_porch_sf'] = (train['OpenPorchSF'] + train['3SsnPorch'] +
                           train['EnclosedPorch'] + train['ScreenPorch'] +
                           train['WoodDeckSF'])

与えられた学習データには存在しない項目を作り出している点がなるほどって思った。

2

train = train[train.GrLivArea < 4500]
train.reset_index(drop=True, inplace=True)

外れ値と思われるデータをdropしてしまう

自分で可視化した結果

相関マトリックスを作ってみた

# correlation matrix
corrmat = train.corr()
plt.figure(figsize=(15, 11))
k = 15   #number of variables for heatmap
cols = corrmat.nlargest(k, 'SalePrice')['SalePrice'].index

cm = np.corrcoef(train[cols].values.T)

sns.set(font_scale=1.25)
sns.heatmap(cm, cbar=True, annot=True, square=True, fmt='.2f', yticklabels=cols.values, xticklabels=cols.values, cmap='Blues')

plt.savefig('./heatmap.jpg')

f:id:tsuyoshi_nakamura:20190428204447j:plain

こうして眺めてみるとSalePriceに相関関係が高いのがTotalSF,OverallQual,Total_sqr_footage,GrLivAreaとわかるのかな

さいご

  • 今回はここまでにしましたが、これらの相関関係の高いデータを特徴量として実際に学習モデルに食わせていって予測を導き出すのかなという大まかな流れを理解できた
    • これだけでは精度が上がらないのはわかっているが、ひとまずは理解が進んだということで🍺