Amazon EKS アップグレードにてこずった話

こんにちは。Enigmoインフラエンジニアの夏目です。

この記事は Enigmo Advent Calendar 2020 の13日目の記事です。

なんだか競馬関連のエントリがいっぱいですが、弊社の主要サービスは競馬予想サイトではありませんので誤解なきよう。僕は競馬のことはさっぱりわからないのですが、先月末のジャパンカップは大変熱いレース展開でしたね。着順自体はまったく面白みがなく収支マイナスになってしまいましたが。

さておき。1年前と同様、今年もKubernetesクラスタ運用に翻弄される日々を過ごしておりまして、今日の記事はそんなKubernetes...というかAmazon EKSクラスタに関するお話です。

Kubernetesのリリースサイクルに乗り遅れるな

皆さんご存知の通りKubernetesのマイナーバージョンはおよそ3ヶ月ごとにリリースされ、各マイナーバージョンは最新バージョンとの差異が3以上になった時点でコミュニティのサポート対象外となります。

https://github.com/kubernetes/community/blob/master/contributors/design-proposals/release/versioning.md#release-versioning

つまり、同一バージョンを1年以上利用することはほぼ不可能に近く、どうしても1年のうち最低でも1回はアップグレード作業が必要となります。これはKubernetesを本番環境で利用する上で避けることができない、インフラエンジニアの頭を悩ませる問題のひとつです。

(注:1.19以降は1年間のサポートがアナウンスされていますが、バージョン差異は4つも進んでしまうのでどのみちアップグレードしないわけにはいきません)

エニグモの一部サービスではAmazon EKSクラスタを利用しており、春先に1.15がリリースされたタイミングで1.13からアップグレードを実施しました。

EKSはKubernetes本家のリリースからおよそ半年遅れでリリースが行われ、本家よりも3ヶ月長い1年程度のサポートが保証されています。

そのため、1.15は2021年3月…よりも少し先の5月までがサポート予定となっており、年内はさらなるアップグレードは当面必要ないかな、と高を括っていました。

というのも、EKSは本家への追従に時間がかかりなかなかリリースされないこともあり、サポート期間はさておき年内に1.17や1.18がリリースされるかどうかも疑わしいものだ、と半ばAWS開発チームの対応スピードを侮っていました。

ところが、1.15のリリースから2ヶ月も経たないうちになんと1.16がリリースされました。3ヶ月ごとじゃないじゃん!話が違うよ!と憤慨しながらGithubのAWS Container Roadmapを眺めていると、こんな頼もしいコメントが寄せられているではありませんか。

https://github.com/aws/containers-roadmap/issues/487#issuecomment-597444626

but one of priorities for EKS this year is to reduce the gap between upstream releases and EKS support, which will require a temporary release schedule that is sooner than every 90 days.

ということで、本家リリースから10ヶ月も経ってからようやく公開されたEKS 1.15はたった1ヶ月半で旧バージョン扱いになってしまったのでした。ひどい。

1.16へアップグレードする前に

さて、本家で1.16がリリースされてからもう1年以上経過しているため、GKEなどで常に最新バージョンのKubernetesを利用されている方には遠い昔のことのように思われるかもしれませんが、1.16では一部のAPIが非推奨となりました。

https://github.com/kubernetes/kubernetes/blob/release-1.16/CHANGELOG/CHANGELOG-1.16.md#deprecations-and-removals

非推奨となったものの中でも最も広範かつ影響が大きいのは、Deployment , DaemonSet , ReplicaSet リソースが対象となる apiVersion:extensions/v1beta1 グループです。

これらのリソースを apps/v1 へ変更するために、現在クラスタで稼働しているアプリケーションを確認してmanifestを修正して……といった作業工数を考えると、1.15のサポート期間終了直前に慌てて1.16へアップグレードすることはあまり現実的ではないと判断し、早々に1.16へのアップグレード準備作業を始めることとなりました。

APIバージョン変更対応

対象リソースの洗い出し

1年前の記事でご紹介したように、アプリケーションのmanifestは基本的にGitで管理しているので、apiVersion:extensons/v1beta1 のリソースの有無はGitリポジトリを確認すればよいのですが、アプリケーション以外の一部のモジュールはGithubリポジトリのkustomizationファイルを直接参照してデプロイしているため、 kustomize build コマンドを実行しないとmanifestを確認することができません。

すべてのモジュールを確認して回るのも手間だしどうしたものか、と思っていたところkube-no-trouble:kubentという便利なスクリプトを見つけました。

https://github.com/doitintl/kube-no-trouble

$./kubent
6:25PM INF >>> Kube No Trouble `kubent` <<<
6:25PM INF Initializing collectors and retrieving data
6:25PM INF Retrieved 103 resources from collector name=Cluster
6:25PM INF Retrieved 132 resources from collector name="Helm v2"
6:25PM INF Retrieved 0 resources from collector name="Helm v3"
6:25PM INF Loaded ruleset name=deprecated-1-16.rego
6:25PM INF Loaded ruleset name=deprecated-1-20.rego
__________________________________________________________________________________________
>>> 1.16 Deprecated APIs <<<
------------------------------------------------------------------------------------------
KIND         NAMESPACE     NAME                    API_VERSION
Deployment   default       nginx-deployment-old    apps/v1beta1
Deployment   kube-system   event-exporter-v0.2.5   apps/v1beta1
Deployment   kube-system   k8s-snapshots           extensions/v1beta1
Deployment   kube-system   kube-dns                extensions/v1beta1
__________________________________________________________________________________________
>>> 1.20 Deprecated APIs <<<
------------------------------------------------------------------------------------------
KIND      NAMESPACE   NAME           API_VERSION
Ingress   default     test-ingress   extensions/v1beta1

このスクリプトapiVerison:extensions/v1beta1 のリソースを洗い出して順次APIバージョンの変更を実施しました。

安全にリソースAPIバージョンを変更するには

リソースのAPIバージョン変更と言っても、単純に apiVersion:apps/v1 へ変更するだけではありません。 spec.selector フィールドの追加も必要なのですが、このフィールドはimmutableとして定義されているため、既存のリソースに追加しようとしても以下のようなエラーが出力されてしまいます。

The Deployment "sample-application" is invalid: spec.selector: Invalid value: v1.LabelSelector{MatchLabels:map[string]string{"app":"sample-application", "app.kubernetes.io/name":"sample-application"}, MatchExpressions:[]v1.LabelSelectorRequirement(nil)}: field is immutable

このエラーを無視して kubectl apply --force コマンドで強制的にmanifestの変更を適用することはできるものの、整合性が取れないため既存のリソースは一度完全に削除されてから、 apps/v1 のリソースが新たに作成される形になります。つまり、稼働Pod数が一時的に完全に0になってしまうのです。

Pod数が0になってしまえば当然サービスに影響が生じてしまうため、以下のようなフローでAPIバージョンを変更しました。

  1. 既存のリソースと同一構成のmanifestを作成し、 metadata.name のみ変更してクラスタへデプロイ
    • 例) Deployment: sample-app を複製し、 Deployment: sample-app-temp を作成
  2. 複製したリソースが Service に紐づき、トラフィックが振り分けられていることを確認
    • kubectl get endpoints コマンドで、ServiceとDeploymentの紐付けを確認できます
  3. 既存のリソースに対してAPIバージョン変更及び spec.selector を追加するmanifestを強制的に適用
  4. 既存リソースのDeploymentが削除され、 apps/v1 で再作成されてアプリケーションが正常に動作していることを確認
  5. 一時的に作成したリソースを削除

少々泥臭いですが、ブルーグリーンデプロイメントの亜種のようなやりかたですね。サービスに影響なく安全にリリースできる安心感はありますが、同様の要件が発生した際に都度こういった対応をするのも手間なので、今後はArgo Rolloutなどのモジュールも試してみたいところです。

ノードグループのマネージド化

非推奨APIバージョンのリソースは一新できたため、あとはコントロールプレーンとノードグループをそれぞれ1.16へアップグレードするだけです。

ただ、これまで利用していたノードグループはEKS 1.13の頃にAWSドキュメントに従ってEC2 LaunchTemplate と AutoScalingGroupで作成していたため、実際にアップグレードしようとすると、以下のように複数フェーズで対応をする必要がありました。

  1. 新規バージョンのAMIを利用するLaunchTemplate, AutoScalingGroupリソースを作成し、コントロールプレーンへ紐付け
  2. 既存のノードグループにTaintを付与し、Drainを実行してPodを新規ノードグループへ退避させる
  3. 既存のノードグループを削除

さきほどのAPIバージョン変更時と同じような作業ですが、これをCloudFormationで対応しようとすると、3度もスタックの変更セットを適用するはめになり大変面倒です。

このため、EKS 1.15へアップグレードしたタイミングでマネージドノードグループへ移行しようとしたのですが、リリース当初のマネージドノードグループはSecurityGroupの割当てをすることができず、RDSやElastiCache, ALBなどのAWSリソースとPod間の疎通設定ができないため踏み切ることができませんでした。

ノード単位でSecurityGroupを割り当てなくてもSecurityGroup for Podを利用すれば良いでしょ?と思っても、対応クラスタバージョンはEKS 1.17以降のため使うこともできない、といった具合で数カ月間スタック状態でした。

それがこの夏ようやくLaunchTemplateに対応し、SecurityGroupを自由に割り当てられるようになったため、1.16へのアップグレード前にマネージドノードグループへ移行することにしました。

移行作業は前述のアップグレードフローとほぼ同様で、マネージドノードグループを新規作成して旧ノードグループからPodを退避させたうえで旧ノードグループを削除する、という流れですんなり終わりました。

これでアップグレード作業もスムーズに……と思いきや、マネージドノードグループに移行したタイミングでちょっとしたトラブルが発生しました。

Podが停止できない!

マネージドノードグループを作成し既存のノードグループからPodを移行してから2,3日経ったところで、唐突にPodが再起動を繰り返したり、Podが正常に停止できずにいつまでも残り続けたりと不安定な状態になり、ノードのCPUやメモリが高負荷となってクラスタ上からもノードが利用できなくなってしまいました。

厄介なことにPodの移行が済んだことで既存のノードグループは削除済み、という状況のためPodを切り戻すこともできず、騙し騙しPodを動かしていたところ以下のバグを踏んでいたことが判明しました。

Pods stuck in terminating state after AMI amazon-eks-node-1.16.15-20201112

つまるところバグが含まれるAMIでマネージドノードグループを作成したことが原因だったため、慌ててCloudFormationで旧バージョンのAMIに変更しようとしたところ、今度は以下のようなエラーが。

Requested Nodegroup release version 1.15.11-20201007 is invalid. Allowed release version is 1.15.12-20201112 (Service: AmazonEKS; Status Code: 400; Error Code: InvalidParameterException; Request ID: ----; Proxy: null)

なんとマネージドノードグループは最新バージョンのAMIしか利用できないという仕様のため、最新バージョンのAMIにバグがあるとどうすることもできないのです。詰んだ。

結局そうこうしているうちに修正バージョンのAMIがリリースされたため、マネージドノードグループのAMIバージョンを変更して一件落着……と思いきや、そうは問屋がおろしません。

前述したとおり、Podが正常に停止できない ということは、すなわちマネージドノードグループの更新処理における Tainの付与Drainの実行PodのEviction の流れで、最終的にPodのEvictionに失敗してしまい、ノードグループの更新処理も失敗してしまうのです。そんな。ひどい。ひどすぎる。

二重に詰んだ状況になってしまったため、最終的に取った手段は

  1. CloudFormationでマネージドノードグループの更新を実行
  2. kubectl get pods --all-namespaces でPodの稼働状況を注視
  3. Terminating のまま一定時間変化がないPodを見つけたら kubectl delete pods <pod name> --grace-period=0 --force コマンドで殺して回る

という、いったいこれのどこが マネージド なんですか?と聞きたくなるような対応をする羽目になりました。

いたずらに過去バージョンのAMIを使うことで不要なトラブルが発生することを防ぐ、という目的であれば最新バージョンのAMIしか利用できないという方針もわからないでもないですが、じゃあちゃんと動くものをリリースしてくれ……という気持ちでいっぱいです。

バージョンスキップができない!

前記した問題が解消し、ではKubernetesnのバージョンアップをしましょうということでコントロールプレーンをEKS 1.16へアップグレードしました。

ここでマネージドノードグループも1.16へアップグレードすると、最新バージョンであるEKS 1.18まで3回も更新処理が必要となり、都度PodのEvictionが発生します。サービスに直接影響が出ないようReplicaSetで冗長化はしているものの、そう何度も実行したいタイプの作業ではありません。

幸い、コントロールプレーンとノードグループ間のバージョン差異は2バージョンまで許容されるため、コントロールプレーンをEKS 1.17へアップグレードし、ノードグループは1.15から1.17へスキップさせようと考えました。

さきほど1.16へアップグレードしたばかりのコントロールプレーンにCloudFormationで再度アップグレード処理を実行しようとしたところ、以下のようなエラーが発生しました。

Update failed because of Nodegroups EKSNodeGroup-,EKSNodeGroup-,EKSNodeGroup- must be updated to match cluster version 1.16 before updating cluster version (Service: AmazonEKS; Status Code: 400; Error Code: InvalidParameterException; Request ID: -----; Proxy: null)

なるほどなるほど、コントロールプレーンとマネージドノードグループのバージョンがね、一致してないからクラスタのアップグレードはできませんと。なるほどなるほど。えっ、なんで?????非マネージドノードグループを使っているときは1.13から1.15へアップグレードできたのに?なんで????

といった具合で理屈はわかるものの、ドキュメントのどこにも書いていない制約にひっかかり、エラーの内容からするとマネージドノードを使っている以上は避けられないようなので渋々マネージドノードもアップグレードをすることになりました。仕方がないこととはいえ、やはり少々納得がいっていません。

アップグレードをイベント化しないために

こんな調子で、ほぼマネージドなはずのAmazon EKSのアップグレード作業にずいぶんと工数をかける形になってしまいました。

ただ、Amazon EKSないしKubernetesを利用している限り、クラスタ本体はもちろんモジュール類(Ingress Controllerなど)のアップグレードは常に意識し続けなければなりません。安定稼働しているからといってしばらく放置していると、唐突に破壊的変更を含むリリースがアナウンスされることも珍しくありません。

四半期ごとや月次のタイミングなどで各種モジュールの最新リリースをチェックし、定期的にアップグレード作業を実施することで、サービスへの影響を最小限にして運用作業を行う方法を模索していけたら良いなと考えています。


明日の記事の担当は ハイアマチュアトレイルランナー の 嘉松 さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

自社サービスのプロジェクトを推進する上で大事にしていること

こんにちは、ディレクターの神吉です!
この記事は Enigmo Advent Calendar 2020 の12日目の記事です。

エニグモでは様々なプロジェクトに関わることが多いですが、自社サービスのプロジェクトを推進する上で大事にしていることを書いていきたいと思います。

今回はプロジェクトマネジメントの体系的なテクニックの話ではなく、プロジェクトを推進する上でのマインド面中心の記事になります。
またクライアントワークのプロジェクトとは違う部分もあるかと思います。

今思っていることや感じていることなので、今後変わっていくかもしれないのでそこはご了承ください。

f:id:enigmo7:20201207231035p:plain

そもそもプロジェクトとは?

プロジェクトって何か大変そう、難しそうみたいイメージがあるかもしれませんがプロジェクトの定義をWikipediaで確認してみました。

プロジェクトマネジメント協会が制定しているPMBOK(第5版)の定義では、「プロジェクトとは、独自のプロダクト、サービス、所産を創造するために実施する有期性のある業務」とされている。つまり、会社などの通常業務や、継続的な運用管理、あるいは改善活動などは、特に開始と終了が定義されていないので、「プロジェクト」とは呼ばない。ただし、特定の期限までに特定の建築を行う、製品を開発する、システムを構築する、などは個々のプロジェクトになりうる。

独自の目標がある、期限がある業務のことなのかなと思います。
いつもやっているルーティンワークは該当しないことになります。

今までやったことない業務でスケジュールも決まっていて多部署、または社外も巻き込んだ大きなプロジェクトを推進するとなるとなかなか大変です。

そんな中大事にしていることは以下になります。

大事にしていること

1. どうすべきか常に考える。

AなのかBなのか選択を迫られることが多いです。
また関係者全員が100%満足するような決定ができないことも多く、バランスをとることも必要とされます。
自分だけで判断できないことも多くありますが、どうすべきかどうあるべきか常に検討し積み重ね、小さなことでも丸投げしないで一つ一つ考えることは大事です。

2. 自分ごと化する。

他に企画者がいる場合でも自分の企画ぐらい真剣に考えることは必要だと思います。
これをやらないと検討事項も浅くなり、関係者と話す時につじつまが合わなくなったり情熱を伝えることができません。
かなり厳しい状態でプロジェクトにアサインされることもありますがそれをどうするかが腕のみせどころだと思います。
私自身、最初は自分ごと化できずに苦しんでしまうこともよくありました。。

3. 逃げないこと。

逃げないこと、あきらめないことは非常に大事です。
プロジェクトへの情熱が失われると一気に物事が進まなくなります。
あー失敗したーと思うこともありますがだいたい大丈夫です。
プロジェクトは日々の積み重ねなので地道にやるしかないです。

4.最前線にいってみる。

何が課題なのか人から聞いたり報告書を見たりするより、実際に経験してみるほうが良いと思います。
意外と聞いていたこととは違う課題でつまずいていたり、すごく重要なことを汲み取れていないこともあります。

5.他のプロジェクトにも協力する。

人が進めているプロジェクトにも協力することは大事なことです。
自分以外に情熱をもって行動してくれる人がいると諦めずプロジェクトを進めることができたりもします。
いつか自分が大変な時に助けてくれるかもしれません。

6.細かいところも覗いてみる。

開発、デザイン、データ分析、ユーザからの声 などなど細かいところも覗いたり、また人にまかせていた業務もたまには自分でやってみたりしています。
やっぱり業務の基本的なところは大事。
地味で細かいところも多いですがこうしたところ見ていると何かあった時の瞬発力につながると思っています。
私の周りではマネジメント層の方でも細かいタスクを大事にしている方も多いので尊敬です!

7.よく分からなくなったらとりあえず寝る。疲れてきたらとりあえず寝る。

いろいろ考え過ぎてどうして良いか分からなかった時は寝ましょう。
寝てみると意外と頭がすっきりして解決することも多いです。
プロジェクトは長期間になることもあるので持久力も大事です。

f:id:enigmo7:20201209112229p:plain

いろいろ上げてみましたが、キリがなさそうなのでこれぐらいにしておこうと思います。

私も最初は分からないことだらけで失敗ばかりでしたが(今でもまだまだ勉強することばかりですが。。)いろんな経験の積み重ねで少しずつプロジェクトを推進していけるようになってきたかなーと思っています。

今までないものを形にするのは非常に楽しいことなので何か新しくやりたいことがある方は失敗を恐れずぜひそのプロジェクトに挑戦してみてください!!

明日の記事の担当はインフラエンジニアの夏目さんです。宜しくお願いします!


株式会社エニグモ 正社員の求人一覧

hrmos.co

複雑さを相手に抽象化を盾にしましょう

こんにちは、サーバーサイドエンジニアの Steven です。

この記事は Enigmo Advent Calendar 2020 の 11 日目の記事です。

抽象化という単語とその議論をそれほど目にすることがありませんが、設計においては極めて重要な概念だと思いますので、ここで抽象化は何を指すのか、何のためのものなのか、どうやるのかを説明してみます。

ソフトウェア・エンジニアリングとは

それが明確となっていないと、どうして抽象化が必要なのかは曖昧となってしまうこともあるかと思いますので、まずは方針にしていることについて語ります。

解釈は複数あると思いますが、一つの文章で表すと、ソフトウェア・エンジニアリングとは人間のアイデアアルゴリズムに変換することだと思います。 人間の観点で、不確定で無限とも取れるアイデアを、限り有る計算関数の組み合わせで有限なものに変えるとも取れます。 決まった特定な目的を果たすために、有限なものだけを使って何かを作ることから、問題解決とも取れると思います。レゴ作りやパズル解決という比喩は気に入ってます。 以上の説明のキーワードは「変換」と「問題解決」です。

一つ一つ個別なものと捉えると、ウェブ業界の通常の仕事では実に難しい問題はそうそうないと思います。業界として今まで見たことのない新しいアルゴリズムの発明はまず必要ないです。 ただし、仕事で求められるのは人間の言葉で表現される高レベルな問題の解決がほとんどで、その一つの大きな問題を解けるにはお互いに影響し合うたくさんな小さな問題を同時に解決しないといけないです。 なので、そういう問題は本質的に難しいというより、複雑という方が相応しいと思います。 したがって、エンジニアの主な評価基準はどれだけ難しい問題を解決できるかというより、如何に複雑さを抑えて、大きい問題を簡単に解決する・保つかの方です。

高校時代に歴史と地理学の先生から聞いた言葉ですが、「難しくするのは簡単。簡単にするのは難しい。」というのが印象的で今にも覚えています。パラダイムシフトとも言えたかもしれません。 「やっぱりわからないから、すごい」のではなく、「思ってたより、ぜんぜん簡単でわかりやすい」の方を目指すべきです。 解決策が簡単だったのはもともとの問題が簡単だったからというのはまずなく、エンジニアが頑張ったから、最終的には簡単な解決になったという方が正しい解釈だと思います。

その中で抽象化はものを簡単に保つための手段となります。

抽象化とは

抽象化という概念自体は抽象的なので、一つの文章で具体的に説明しきるのは難しいですが、以下のように解釈しています。 抽象化とは、特定の問題を概念として分析と分割し、単一の要素として扱えるようにした上で、その要素を組み合わせることでより大きい問題の解決に汎用的に使えるようにすることです。 抽象化をトピックごとにより細かく説明します。

抽象化の目的

大きい問題はいつもより細かい問題で構成されてます。 最上層にある、ユーザーに提供したい高レベルな結果(ボタンで操作できるカート画面)と、最下層にある、実装上の低レベルな詳細(カートの SQL テーブルに適用ポイントを保存する)は両方意識しやすくて、それだけをベースにそのまま開発に入ることがあったかもしれません。 ただし、その両端の間にはとんでもない距離があり、最下層からそのまま最上層を実装しようとすると、結果が凝ったものであればあるほど、開発の効率が下がり、目標は達成しにくくなります。 なぜなら、最下層から最上層まで一気に何かを実装しようとすると、さまざまな、関係のない詳細を一気にかつ同時に気にしないといけなくなるからです。開発の負担が単純に大きすぎるものになってしまいます。

なので、その残念な、非効率な開発環境を避けるために、大きい問題をより小さい問題に分割し、問題を部分的に解決できるようにします。 解決の負担が減った小さい問題を解けられたら、結果の要素を繋ぐことで大きい問題は簡単に解決可能となります。

馴染みのある例に例えるなら、車を作るのが目的な場合、構成が曖昧なまま車を一気につくるより、動力機関、燃料容器、収納スペースなどと概念として分析してから、それぞれの要素を設計・作成し、最終的に全部を組み合わせる方が効率的です。 以上で問題をものに例えたのですが、ことにも例えられます。 生き物としてエネルギーを得るということを分析するのでしたら、食べ物の探し、入手、調理、飲食、分解、摂取などと、ステップにも分けられます。

それが抽象化です。大きい問題をそれぞれより小さい問題に分けて、構造を見出すことです。 どうしても解決できない問題が相手の場合、その問題をそれぞれの概念として分析し、より細かい問題として分割した上で、再度挑戦するのがいいかと思います。 そうすれば、実に難しい問題は意外と少ないかもしれません。

抽象化の特徴

役に立ついい抽象化にはいくつかの特徴があります。大きく分けて、以下の2つにまとめてみました。

問題の構造化

問題を分割するとしても、正しいやり方と正しくないやり方があります。

純化が目的なので、そもそも簡単というのはどういうものなのかを明確にする必要があります。 簡単なものは本質までさかのぼった場合、一つだけなものとして考えるものです。一つの概念、一つのパターン、一つの責任など。複数な概念を合わせることで初めて成立するものであれば、もはや簡単ではないです。 ただし、その一つなものがより小さい複数なもので構成されていても、簡単じゃなくなるわけではないです。一つとして考えられれば、その時点で簡単です。

なので、問題を分割するに当たって、同じように、分割されたものをそれぞれ独立した、一つ一つとして考えられるものにするべきです。 複数な概念がオーバーラップするような、曖昧なものが分割の結果でしたら、それほど問題の単純化には貢献しないものとなるからです。

大きい問題を分割する際は、木構造の要領でものを分けて、それぞれの部分の大きさを抑えながら、大きいものからどんどん小さいものに構造化するのが望ましいです。 そうすることで、一つ一つの問題の解決は同程度の難易度になって、全体の単純化に繋がります。高レベルな問題は低レベルな問題と同じぐらいの努力で解決可能となります。 同時に、特定の細かい問題がどの問題の一部となってるのかも明白になって、把握がしやすくなります。

木の一つのノードを分けるとして、枝の間に共通点が少ない場合は、枝の数も抑えるべきです。 なぜなら、実装で5つの枝を一つのノードに集約するのがそれほど難しくなくても、共通点のない枝が 20本もあれば、集約がそれなりに難しくなります。 どうしても枝の数が多い場合は、共通点となる概念をベースに、一部の枝を一つにまとめて、新しい子ノードで問題をまた分割すれば大丈夫です。

最初は少なくても、改修で一つのノードの枝の数が少しずつ増えないようにするには、最初から分割の結果を、元の問題の 100% をカバーする、同じ抽象レベルのものにするのがいいと思います。 商品の購入過程はかならず選択、購入、受け取りの3つのステップに分けられますので、はじめからそうと分割すれば、後から枝の数が増える可能性が低いです。

まとめると、問題の構造化において、いい抽象化なら、問題は - 独立した概念として分割される - 木構造として構造化される - 同程度の大きさとして分けられる - 木として各ノードの枝の数が抑えられる - 木として同じノードの枝は同じ抽象レベルにある

そんな風に問題を分割すれば、それぞれの問題の解決は実装しやすくなります。

インターフェイスの単純化

問題がうまく分割されれば、その時点で簡単になります。 ただし、それだけではそれぞれの問題の解決策のつなぎ方が簡単になるとは限らないので、インターフェイスの面でも複雑さを抑える必要があります。 この項目ではより具体的な説明になるので、問題の解決として「機能」という単語を使います。

特に考慮せず、機能一つ一つをそのまま実装するだけだと、その機能を使うためのインターフェイスは機能よりになってしまいます。 ただし、そのそれぞれの機能は皆違いますので、インターフェイスの間の互換性がいいものにならず、機能を繋ぐだけでかなりな努力が必要となります。 なので、それを避けるため、インターフェイス自体を簡単なものに保ち、共通言語でそれぞれの機能をつなげるようにする必要があります。

いいインターフェイスには以下の特徴があります。

  • インターフェイスは包まれてる機能と同じ抽象レベルで表現されてます
    • 名称(クラス名、メソッド名、引数名など)がその抽象レベルに合わされてます
    • その抽象レベルに合わない実装詳細は表に出ません
  • インターフェイスのエンドポイントは最小限に抑えられてます
    • 機能を活用するために必要なオペレーションのみが公開されてて、利用方法が明白です
  • インターフェイスが必要とする引数の数が抑えられて、少ない加工でもその引数を簡単に提供できます
    • 必要のないデータまでを求めませんが、呼び出し元で準備が必要となってしまう細かすぎるデータも求めません
    • 機能と同じ抽象レベルのデータを引数にします
      • たとえば、商品の価格を計算する機能では、商品モデルを受け取るだけでも問題ありません
  • インターフェイスの返り値も引数と同じルールに従って、他の機能でそのまま活用できます
  • インターフェイスは基本的にステートレスです
    • メソッドをどんな形でどれだけ呼んでも、内部ステートが変わらず、機能の結果に影響しません
    • 最終的のステートを格納するモデルクラスは例外です
  • インターフェイスの実装詳細が隠蔽されてます
    • 呼び出し元が実装の詳細を気にする必要がありません
    • 後から実装が変わっても、変更なく機能をそのまま利用できます(実装の詳細が漏洩しません)
  • インターフェイスはコンテキストには必要以上に依存せず、他のコンテキストでも再利用できます
    • 活用する場合に、必ず他の機能と併用しないと使えない状況に陥ることがありません
    • コンテキストがなくても機能をそのまま理解できます

その特徴を持つインターフェイスを実装するのが難しい時がありますが、どれだけインターフェイスを高レベルなものに保てたかによって複雑さが決まることが多いです。 インターフェイス設計の過程で機能の実装自体が難しくなることがありますが、難しい実装と比べて難しいインターフェイスの方は影響が大きいので、選ぶ必要がある時は実装よりインターフェイスの方を簡単に保つべきです。

抽象化のメリット

うまく抽象化できれば、様々なメリットが現れます。

単純性

  • 全体的にわかりやすくなるので、調査にかかる時間が短縮されます
    • もともと実装した人にとっても、触ったことがない新人にとっても
  • 抽象化を考慮する時間が必要となりますが、実装自体にかかる時間は減ります
    • 問題をそれぞれ個別として扱えるようになるので、一つの問題のみに集中できるようになります

柔軟性

  • 各機能は明確に隔離されるので、一つの機能の修正が他の機能に与える影響が減ります
    • ものをより自由に変更できるようになります
  • 各機能は高レベルなインターフェイスで包まれるので、機能の間に新しい機能を追加するのが簡単になります
  • 各機能のコンテキストへの依存も抑えられるので、リファクタリングがよりやりやすくなります
    • 機能の再編など
    • 機能の実行順番の変更など

保守性

  • 単純性と柔軟性の改善から、保守性もそのまま向上されます
    • バグ発生時にどこを修正すればいいのかがより早くわかります
    • 該当箇所を修正したら、漏れが発生しにくくなります
      • 一つの問題が一つのところで対応されるので
    • 密結合状態が避けられるので、リファクタリングの必要性も減ります

安定性

  • 同じ理由で安定性も改善されます
    • 問題の分割で漏れにはより早く気づくので、仕様漏れやバグの発生率は減ります

テスト性

  • 機能一つ一つは独立するので、ユニットテストも実装しやすくなります
    • コンテキストとテストデータの準備で必要となる努力は減ります
    • 単一責任に重点が置かれるので、複数の関係のないものを同時にテストする頻度も減ります

抽象化をするには

うまく抽象化をするには何を気にするべきか、どのステップを取るべきかを紹介してみます。

概念の分析

抽象化と関係がないことですが、まずするべきなのは対象の案件を具体的なものにすることだと思います。 道標となるメインな仕様があるとして、エッジケースがあるのか、コンテキストが何なのか、未定なところがあるのか、というところを洗い出します。

それができたら、その案件を概念として分析します。 問題を解決するために必要となるデータ(モデルなど)には何があるのかを、「問題の構造化」で紹介した問題の分け方を活かして、分析します。 データを細かく分析できたら、その次に処理(関数など)の分析をします。 問題解決のためにどの処理が必要なのかを洗い出します。

ドメイン層(ビジネスロジック)とアプリケーション層(フレームワーク)を明確に分けることも望ましいです。 うまく分けて、問題をそれぞれの層の独立したものとして分析できれば、全体が単純化されることが多いです。

簡単に実装できそうな大きさの、曖昧なところのない、一つ一つな要素になるまで、データと処理の分析を繰り返します。 抽象化の一番難しい作業は以上の分析になるので、クリアできたら、残りのステップは簡単です。

Tips

分析結果で不可分と見える一つのデータか処理がやはり大きいという印象を抱くことがあるかもしれません。 一見では不可分ですが、大きいと見えたなら、おそらく複数な違う要素でさらに構成されてます。 その要素を暴き出すために、質問を問いて、そのデータか処理の本質を探し出すのがいいかと思います。 そのものは何なのか、何が目的か、実装するには何が必要かなど。

同じく、分析でうまく表現できないデータか処理が現れるかもしれません。 そのものをどう実装できるのかがよくわからない時は新しい概念の導入を検討します。 商品というのはそのまま概念として成り立ちますが、一部の商品のみを扱える処理があるとわかったなら、商品には種類という概念を導入する必要があるかもしれません。 扱い方が全然変わってしまうなら、商品モデルにステートを表すメソッドかカラムを追加するだけのではなく、ラッパークラスを通して、モデルを抽象化するのが妥当な可能性があります(たとえば、購入できない商品対購入できる商品など)。 当然、処理のほうにも新しい概念の導入が必要となる場合があります。

概念としては、ステート、ポリシー、イベント、エラー、アクション、プレゼンター、ストラテジー、エクストラクター、ノーマライザー、セレクター、ヒストリーなど、ものとことのどちらにも無限とあります。 プログラムに自由に新しい概念を導入しましょう。

インターフェイスの用意

その次に、分析されたものに一つ一つインターフェイスを与えます。

インターフェイスの単純化」で紹介した特徴を意識して、簡単なインターフェイスの設計を目標とします。 簡潔にまとめると、以下の特徴を目指します。

  • 用途が伝わる抽象的な名称
  • 数の抑えられたエンドポイント
    • パブリックな関数やメソッドなど
  • 単純な引数と返り値
  • ステートレスなインターフェイス
  • 実装の詳細を隠蔽したカプセル化
  • 抑えられたコンテキストへの依存

最初から完璧なインターフェイスを設計することが難しい時があります。 そういう時はまず用途を果たすものを作ってから、そのインターフェイスを少しずつ改善していく方が効率的です。

基本的にインターフェイスの設計が終わってから、実装に入るべきです。 そうすれば、実装に左右されず、簡単なものが作りやすくなります。 ただし、実装で曖昧なところが多い時は実装をある程度進めてから、インターフェイスを設計するのもありです。

Tips

名称としては、要素の実装を必要以上に具体的に表さないながら、用途や目的を明確にした、周りと同じ抽象レベルなものが望ましいです。 クラス名、メソッド名、変数名など、どのものにも以上のルールを適用します。 高レベルなコンテキストで、WriteProductIdToRedisAddProductToCartの間で後者の方が望ましいでしょう。 なぜかというと、WriteToRedisProductIdは実装を直接表すものでありながら、その用途を表してないです。 クラスの実装と呼び出し元を調べないと、用途が何なのかがわからないという問題もあれば、実装が変わった場合、クラス名がその実装と合わなくなります。

簡単なインターフェイスを作るには、実装の詳細とコンテキストを一旦全部忘れて、設計したいデータや処理をブラックボックスとして考えるのがいいと思います。 そのインターフェイスでしたら、触ったことのない、コンテキストに疎い新人にとって、そのまま意味をなすものなのかを確認します。 本当に簡単なものであれば、インターフェイスを見るだけで、大体なことは理解できるはずです。

QA

実装が一つ完成したら、結果を振り返って、抽象化としての質を確かめるのがいいでしょう。

  • インターフェイスも実装もわかりやすいか
  • 用途と使い方に関してどこかに違和感がないか
  • 単一責任が保たれてるか
  • 実装が顕になってないか
  • その抽象レベルで不可分であるか
  • コンテキストへの依存が少ないか

何かよくないところを発見したら、概念の分析を確認するか、インターフェイスを調整します。

適用例

初期状況

現在進めている React プロジェクトでは、アナウンスという、特定の条件下で画面に表示される注意事項というものがあります。 同時に、エラーという、サーバーから受け取る動的に変わる説明事項もあります。 画面のデザイン上では、色を除いて、アナウンスとエラーは大体一緒です。

エラーの仕組みはすでに実装されていて、React コンポーネント内でエラー配列から該当エラーをタグでフィルターして、そのままレンダーするようになっていました。

const renderErrors = () =>
  errors.filter(Error.match({ tag: 'totals' }))
        .map((error, index) => <Error key={index} error={error} />)

return (
  <div>
    {renderErrors()}
    ...
  </div>
)

一方で、アナウンスはコンポーネント内で直接表示すべきかを計算して、そのままレンダーするようになっていました。

const renderCashOnDeliveryMethodAnnounce = () => {
  if (!(hasDeliveryMethodWithPrepaidFees && isPayOnReceipt)) {
    return
  }
  return <Announce title="着払いを選択しました。" details="..." />
}

ただし、そのやり方だと、コンポーネント一つ一つに表示条件とメッセージの定義を行わないといけなくて、DRY ではないところから保守性が下がります。 エラーの仕組みと似てるところも複数あったので、共通化ができるのではないかと思いました。

改善策

メッセージという新しい概念を導入

まず気づいたのは<Error /><Announce />というコンポーネントが大体一緒だったということです。 もとを辿れば、エラーとアナウンスはユーザーに何かを伝えるためのものなので、抽象化して<Message />としてエラーとアナウンスを再定義しました。 <Message />はただのメッセージであって、エラーやアナウンスの用途を考慮しないものなので、インターフェイスは汎用的です。

<Message importance={} title={} details={} />

importanceはメッセージの重要度を表しています。値としてはinfodangerがあります。 そのimportanceを使って、メッセージの色が決まりますので、<Message />が特に考慮していなくても、呼び出し元でアナウンスとエラーの両方をそのまま表せます。 概念にオーバーラップがないので、疎結合となります。

アナウンスという概念を明確に

このままでは、アナウンスという概念はコードには明確に現れず、表示条件と組み合わされたメッセージ以上のものにはならないです。 それだと、保守性は上がらず、すべてのアナウンスの改修が必要となれば、箇所の一つ一つを修正しないといけなくなるのと、アナウンスに関するルールも明確になりません。 アナウンスはどこからどこまでのものなのかが曖昧になってしまいます。

なので、その状況でアナウンスを明確なものにするため、アナウンスの定義、略してアナウンスの概念を導入しました。 アナウンスには重要度、文章、表示箇所と表示条件がありますので、定義でそれを明示的に表現します。

// announces.js

const ANNNOUNCES = {
  cash_on_delivery_method_selected: {
    importance: 'warning',
    title: '着払いを選択しました。',
    details:
      '商品価格に含まれていた送料分が引かれますが、別途、着払い料金が必要です。',
    tags: ['totals'],
    when: ({
      product: { hasDeliveryMethodWithPrepaidFees },
      deliveryMethod: { isPayOnReceipt }
    }) => hasDeliveryMethodWithPrepaidFees && isPayOnReceipt
  },
  // ...
}

ANNOUNCESオブジェクトのバリューはアナウンスの定義となります。

  • 重要度はimportance
  • 文章はtitledetails
  • 表示箇所はtagsで対象オブジェクトを間接的に指定します
  • 表示条件はwhenでカート商品というモデルを引数に定義します

アナウンスはすべて一つのファイル内で定義されてるので、アナウンス横断の修正は簡単になります。 タグでアナウンスが対象にするエリアを定義していますが、どのタグがどのエリアに当たるのかを決めるのはコンポーネントなので、疎結合です。 表示条件も、特定のコンポーネントでのみアクセスできるデータを使わず、どのアナウンスにも渡される汎用モデルを引数としているので、コンテキストには依存しません。 あとからアナウンスのタグや表示条件を変えても、コンポネントの方で何も変更なく、アナウンスがそのまま更新されます。

この修正でアナウンス機能は他のものから独立して、一つのものとして扱えるようになりました。

アナウンスとエラーのレンダリングを抽象化

アナウンスとエラーは両方メッセージとなりました。 または、アナウンスの表示箇所はエラーと同じくタグを使って指定できるようになりました。 なので、レンダー処理はアナウンスとエラーの間で抽象化可能となります。

return (
  <div>
    {renderMessages(cartItem, { tag: 'totals' })}
    ...
  </div>
)

以上のコードでは結局エラーかアナウンスかを意識せず、ただカート商品の、特定のタグのメッセージをレンダーするように単純化されました。 エラーはcartItem内にあるerrors配列が使われて、メッセージがレンダーされます。 アナウンスはannounces.js内の定義を対象に、cartItemtagを使うことでマッチするものを抽出して、メッセージがレンダーされます。 ただし、呼び出し元ではその内部処理を意識せず、より高い抽象レベルでメッセージをレンダーしてるだけです。 あとから、また違う種類のメッセージを自由に追加できます。

低レベルの詳細が抽象化されて、プログラムの単純性と柔軟性が改善されました。

終わりに

誰もが、ある程度の抽象化は意識せずにできてしまいます。ただし最大までにその概念を活かすには努力と経験が必要となります。 抽象化の目的でライブラリーを活かすのも重要であれば、ビジネスロジックの抽象化も必要不可欠です。

抽象化をうまく活かせたプロジェクトはリリース後でも修正が容易で、時間が経っても追加開発で特に難しくならないです。 ただし、抽象化されたものが少しずつ具体化して、どんどん変更しにくくならないように、気をつけて常に努力する必要があります。

バランスにも気をつけないといけないです。最大まで抽象化したものが逆に理解しにくくなることもあります。 抽象化と具体化の間のいい中間点を見つけるのが目的となります。ただし、高い抽象レベルでも名称がしっかりしていれば、大体問題にならないと思います。

抽象化は科学的な手順に沿って行うのも可能でしょうが、感に頼って抽象化するのが基本だと思います。 その感を育てるには経験を重ねないといけないですが、メリットが実に大きいので、コストパーフォーマンスがいいです。 新人と経験者の違いの一つは、どれだけ抽象化をうまくできるかというところにあると思います。

最後に、一見では難しいと見えた問題は、抽象化をうまく活かせれば、意外と簡単になります。 概ね、対応中の実装で一つや2つの概念が見えていないからこそ、複雑と感じてしまいます。 プログラム内でその概念を明確に表せれば、複雑さは大体解消されます。

明日の記事の担当はディレクターの神吉さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

Rails アプリケーションに gRPC を導入したときの話

Rails アプリケーションに gRPC を導入したときの話

こんにちは、エンジニアの齊藤です。
この記事は Enigmo Advent Calendar 2020 の10日目の記事です。

本日は、Kubernetes にデプロイした複数の Rails アプリケーション間のデータのやり取りに
gRPC を採用した開発について ruby の実装を中心にいくつか共有したいと思います。

ruby を使った gRPC の開発という内容はオフィシャルのスタートガイドで細かく説明されているものがありますので、そちらを踏まえて実際どのように Rails アプリケーションに組み込んだのかという内容でまとめてみました。

まずは Protocol Buffers についてです。

Protocol Buffers によるサービスの定義

gRPC を利用するためには Protocol Buffers という IDL を使ってサービスの内容を定義します。

以下は Seller という出品者情報のエンティティとなるメッセージを定義した上でそれらを操作するためのプロシージャ名とそれぞれのリクエストパラメーターとレスポンスをメッセージとして定義 したサービスの実装(sellers.proto)の一部です。

サービスの定義でのポイントはエンティティとなるデータ構造は必ず定義して、これを直接レスポンスのメッセージとしては利用せずにリクエストパラメーターとなるメッセージとレスポンスとなるメッセージを必ずそれぞれ定義するようにしました。 以下の実装でいうと UpsertSellerResponse では単一の Seller をレスポンスで返却し、GetSellers では複数の Seller を返すような実装になります。 例えば同じ内容のリクエストパラメーターやレスポンスを複数のプロシージャで共有することは可能だとは思いますが、冗長でもプロシージャごとにそれぞれリクエストとレスポンスメッセージを用意しておくほうが API としてわかりやすく定義できるのではと思います。

今のプロジェクトの Protocol Buffers を振り返ると一つのサービスにたくさんのプロシージャを定義してしまっているケースも見受けられ、そういった場合は package の記述で ruby のネームスペースを利用することができるのである程度階層化することで解消できたのではと思いっています。

syntax = "proto3";

package Buyma.RPC.PersonalShopper;

import "google/protobuf/timestamp.proto";

service Sellers {
  rpc UpsertSeller(UpsertSellerRequest) returns (UpsertSellerResponse) {} // 出品者情報作成・更新
  rpc GetSellers(GetSellersRequest) returns (GetSellersResponse) {} // ページング可能な出品者一覧を取得
}

message Seller {
  uint64 seller_id = 1;
  string nickname = 2;
  google.protobuf.Timestamp created_at = 4;
  google.protobuf.Timestamp updated_at = 5;
}

message UpsertSellerRequest {
  unit64 seller_id = 1
  string nickname = 2;
}

message UpsertSellerResponse {
  Seller seller = 1; // 新規に登録または更新された Seller を返す
}

message GetSellersRequest {
  int32 page = 1;
}

message GetSellersResponse {
  repeated Seller sellers = 1; // 複数の Seller を返す
  int32 current_page = 2;
  int32 total_pages = 3
  int32 per_page = 4
}

サービス定義からのコード生成

gRPC は Protocol Buffers でサービス内容を定義したファイルからサーバー/クライアント向けのコードを自動生成するツールが提供されています。
それらを利用することでサーバーとクライアントを任意の言語で実装できるようになっています。

今回のケースでは定義(.proto)ファイルと自動生成された ruby のコードを Gem としてパッケージしそれぞれの Rails アプリケーションで利用することにしました。

ちなみに .proto 自体は開発言語に依存しないので複数の言語で利用されることを想定した場合は別の独立したリポジトリで管理するのが良いと思います。
今回は利用するアプリケーションのスコープと開発言語が ruby に限定されていたので Gem に内包する形で問題ないと判断しました。

ruby で利用するために必要なライブラリは同じく Gem として提供されている grpcgrpc-tools でこれらを gem スペックに記述して導入します。
gruf というライブラリについては後述します。

# 一部省略
Gem::Specification.new do |spec|
# 一部省略
  spec.add_dependency 'activerecord', '6.0.0'
  spec.add_dependency 'activesupport', '6.0.0'
  spec.add_dependency 'google-protobuf', '3.9.0'
  spec.add_dependency 'grpc', '1.22.0'
  spec.add_dependency 'gruf', '2.7.0'
# 一部省略
  spec.add_development_dependency 'bundler', '~> 2.0'
  spec.add_development_dependency 'grpc-tools', '1.22.0'
end

Gem の開発環境は ruby を導入した docker コンテナを利用しました。 以下の grpc_tools_ruby_protoc というコマンドを実行することで必要なコードを生成できます。
Stub と呼ばれるこれらのコードの生成についてはこちらに詳しく説明がされています。

$ docker-compose exec -w $PWD/lib ash bundle exec grpc_tools_ruby_protoc --ruby_out . --grpc_out . $(cd lib && find . -name '*.proto')

このコマンドを実行することによって lib/sellers_pb.rb (定義したすべてのメッセージを含む Protocol Buffers の実装), lib/sellers_service.rb (サービスを実装するための基盤となるクラスとサービスに接続するための Stub と呼ばれるクラス) という2つのファイルが生成されます。
これらを require することでサーバーとクライアントを実装することが可能になります。

今回は更に Gruf という Gem を使ってこれらのコードを Rails アプリケーションに導入しました。

Gruf を使ったサーバー実装

こちらに説明があるように生成されたコードを使ってサーバーの実装を行い gRPC サーバーのプロセスを起動することは可能ですが、既存の Rails アプリケーションにうまく組み込んでいくにはある程度の設計と共通化するための機能や設定周りの実装が発生します。
今回はその問題を解消してくれる Gruf というライブラリを使って利用できる実行環境を用意しました。 Gruf についてはこちらの Wiki に詳しい説明があります。

Gruf を使った RPC サーバーの実装は app/rpc/sellers_controller.rb といった RPC 向けのコントローラーファイルを使い Rails に馴染んだ設計でサーバーの実装をすることが可能になります。

以下のコントローラークラスの例は先程の Sellers サービスのコントローラーです。生成された Buyma::RPC::PersonalShopper::Sellers::Service を bind することでコントローラーのメソッドとマッピングされます。

upsert_seller メソッドは先程の定義ファイルの rpc UpsertSeller の処理を実装したものです。 Buyma::RPC::PersonalShopper::UpsertSellerResponse を返しています。

各コントローラーメソッドには自動的に request というオブジェクトが参照できるようになっており、request.message でリクエストの RPC メッセージが参照できるようになっています。 これは Rails コントローラーでリクエストパラメーターを参照できる params の仕組みに類似した設計だと思いました。

class SellersController < ::Gruf::Controllers::Base
  bind Buyma::RPC::PersonalShopper::Sellers::Service

  def upsert_seller
    Buyma::RPC::PersonalShopper::UpsertSellerResponse.new(
      seller: Buyma::RPC::PersonalShopper::Seller.new(seller.to_grpc_hash.slice(:seller_id, :nickname, :created_at, :updated_at))
    )
  end

  def get_sellers
    Buyam::RPC::PersonalShopper::GetSellersResponse.new(sellers: sellers, ...
  end

  private

  def seller
    params = request.message.to_rails_hash
  end
# 一部省略
end

seller: Buyma::RPC::PersonalShopper::Seller.new( 部分の実装について Seller メッセージのパラメーターは AcitveRecord のインスタンスである seller から生成していますが seller.to_grpc_hash という実装は先程の Gem のなかで ActiveRecord::Base に追加しているメソッドです。google.protobuf.Timestamp 型のフィールドには Time 型の値以外をアサインできない制約があるためこのメソッドで to_time への変換を行うことでその問題を回避しています。

[1] pry(main)>Buyma::RPC::PersonalShopper::Seller.new(created_at: Time.current)
Google::Protobuf::TypeError: Invalid type ActiveSupport::TimeWithZone to assign to submessage field 'created_at'.
from (pry):32:in `initialize`
[2] pry(main)> Buyma::RPC::PersonalShopper::Seller.new(created_at: Time.current.to_time)
<Buyma::RPC::PersonalShopper::Seller: seller_id: 0, nickname: "", created_at: <Google::Protobuf::Timestamp: seconds: 1607499175, nanos: 658255400>, updated_at: nil>

Gruf は Interceptor と呼ばれる gRPC が提供している、メソッドの前後に処理を行うための仕組みもサポートしています。

今回のプロジェクトでは ActiveRecord::RecordInvalidActiveRecord::RecordNotFound などの例外を各サーバーメソッドで共通してハンドリングするために利用しています。

require_relative '../error/handler'

module Buyma
  module RPC
    module PersonalShopper
      module Interceptor
        class HandleError < Gruf::Interceptors::ServerInterceptor
          def call
            yield
          rescue StandardError => e
            Buyma::RPC::PersonalShopper::Error::Handler.call(self, e)
          end
        end
      end
    end
  end
end

インターセプターの登録は config/initializers で行います。

config/initializers/gruf.rb

Gruf.configure do |c|
  c.interceptors.use(Buyma::RPC::PersonalShopper::Interceptor::HandleError)
end

サーバーの起動は以下のコマンドで、これで bind したすべてのコントローラーのハンドリングが可能になります。

$ bundle exec gruf

Gruf を使ったクライアント実装

クライアント側の実装は Gruf::Client に実行したい RPC サービス名(ここでは Buyma::RPC::PersonalShopper::Sellers) を指定してサービスに接続するスタブのインスタンスを生成します。
.call メソッドでプロシージャ名とパラメーターを指定して実行することができます。

[1] pry(main)> client = Gruf::Client.new(service: Buyma::RPC::PersonalShopper::Sellers, options: { hostname: ENV['GRUF_SERVER'] })
[2] pry(main)> client.call(:UpsertSeller, seller_id: 1, nickname: 'Foo').message.inspect

calling personal-shopper-api-gruf-server:9001:/Buyma.RPC.PersonalShopper.Sellers/UpsertSeller
"<Buyma::RPC::PersonalShopper::UpsertSellerResponse: seller: <Buyma::RPC::PersonalShopper::Seller: seller_id: 1, nickname: \"Foo\", created_at: <Google::Protobuf::Timestamp: seconds: 1597815761, nanos: 131317000>, updated_at: <Google::Protobuf::Timestamp: seconds: 1607500796, nanos: 652204000>>>"

レスポンスメッセージについても Gem で Google::Protobuf::MessageExts に追加した to_rails_hash メソッドで必要な型変換を行った Hash として取得し以降の処理 (ActiveRecordインスタンスの生成等)でデータ型による問題を回避する工夫を行いました。

[1] pry(main)> client.call(:UpsertSeller, seller_id: 490652, nickname: 'Foo').message.to_rails_hash

calling personal-shopper-api-gruf-server:9001:/Buyma.RPC.PersonalShopper.Sellers/UpsertSeller
{
    :seller => {
         :seller_id => 490652,
          :nickname => "Foo",
        :created_at => Wed, 19 Aug 2020 14:42:41 JST +09:00,
        :updated_at => Wed, 09 Dec 2020 16:59:56 JST +09:00
    }
}

gRPC サーバーの HealthCheck

Kubernetes にサーバーをデプロイする場合 readinessProbelivenessProbe の設定に必要になるヘルスチェックの実装についても共有します。

ヘルスチェックの実装にはこちら 記事を参考に実装しました。 gPRC にはヘルスチェックのためのプロトコルとそれを実行するための grpc-health-probe というツールがあるのでそちらを導入します。

ツールは gRPC サーバーを起動する Rails アプリケーションコンテナの Dockerfile でインストールしました。

RUN GRPC_HEALTH_PROBE_VERSION=v0.3.1 \
    && wget -qO/bin/grpc_health_probe \
        https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 \
    && chmod +x /bin/grpc_health_probe

アプリケーションには Grpc::Health::V1::Health::Service を バインドしたプロシージャを実装をします。
実装内容はアプリケーション上のヘルスチェックを行った上で適切なレスポンスメッセージを返すだけです。 こちらも Gruf のコントローラーとして実装しています。

require 'grpc/health/v1/health_services_pb'

class HealthCheckController < ::Gruf::Controllers::Base
  bind Grpc::Health::V1::Health::Service

  def check
    if alive? # アプリケーション上のヘルスチェックを実行する
      Grpc::Health::V1::HealthCheckResponse
      .new(status: Grpc::Health::V1::HealthCheckResponse::ServingStatus::SERVING)
    else
      Grpc::Health::V1::HealthCheckResponse
      .new(status: Grpc::Health::V1::HealthCheckResponse::ServingStatus::NOT_SERVING)
    end
  end
end

動作確認をします。

正常な場合

$ docker-compose exec personal-shopper-api-gruf-server grpc_health_probe -addr=:9001
status: SERVING

問題がある場合

$ docker-compose exec personal-shopper-api-gruf-server grpc_health_probe -addr=:9001
service unhealthy (responded with "NOT_SERVING")

あとは Kubernetes の deployment でヘルスチェックコマンドを導入して完了です。

livenessProbe:
  exec:
    command:
    - grpc_health_probe
    - -addr=:9001
  failureThreshold: 3
  initialDelaySeconds: 20
  periodSeconds: 10
  successThreshold: 1
  timeoutSeconds: 5
readinessProbe:
  exec:
    command:
    - grpc_health_probe
    - -addr=:9001
  failureThreshold: 3
  initialDelaySeconds: 10
  periodSeconds: 10
  successThreshold: 1
  timeoutSeconds: 5

最後に

Rails アプリケーションに gRPC を導入した際のポイントをいくつか共有させていただきました。 今回実際に導入してみて gRPC は IF の仕様が明確にできアプリケーション間で相互にデータをやり取りするようなケースに適している点やそれを仕組み化する基盤としての便利さを体感することができました。 今後の開発でさらに利用する機会を広げて行ければと思いました。

明日はマッドサイエンティストステェーヴェン・ル・ボエデック氏です。
よろしくお願いします。

機械学習で競馬必勝本に勝てるのか? 〜Pythonで実装するランク学習〜

こんにちは。データサイエンティストの堀部です。

この記事は Enigmo Advent Calendar 2020 の9日目の記事です。

何か社外のデータを使っていい感じのことができないかなと思っていたところ、3日目の竹本さんの記事がおもしろく、パクリ二次創作しました。

短期間で実装したので汚いコードで見苦しいかもしれないですがご了承ください。ちなみに、私は競馬は簡単なルールを知っているくらいでズブの素人です。

目次

使用したライブラリ

import urllib.parse
import urllib.request as req
from time import sleep

import category_encoders as ce
import lightgbm as lgb
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from bs4 import BeautifulSoup
from selenium import webdriver
from tqdm.auto import tqdm

インストール手順は割愛します。*1

データ取得

オッズだけでモデルを組むのはつまらないので簡単に取得できる範囲で下記を追加しました。

  • 馬連
  • 馬名
  • 斤量
  • 騎手
  • 厩舎
  • 馬体重とその増減
  • 年齢
  • 性別

データ取得にあたり、下記の関数とクラスを用意しました。 sleep関数で1秒以上の間隔を空けてnetkeibaからスクレイピングしています。

def get_raceids(date):
    url = "https://race.netkeiba.com/top/race_list_sub.html?kaisai_date=" + date
    res = req.urlopen(url)
    racesoup = BeautifulSoup(res, "html.parser")
    sleep(1)
    racelist = racesoup.select(
        "#RaceTopRace > div > dl > dd > ul > li > a:nth-of-type(1)"
    )
    raceids = [
        urllib.parse.parse_qs(urllib.parse.urlparse(race.get("href")).query)["race_id"][
            0
        ]
        for race in racelist
    ]
    return raceids


def set_selenium():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    driver = webdriver.Chrome("chromedriver", options=options)
    driver.implicitly_wait(15)
    return driver

class HorceRacing:
    def __init__(self, race_id, driver):

        self.race_id = race_id
        self.driver = driver
        try:
            self.result = pd.read_html(
                "https://race.netkeiba.com/race/result.html?race_id=" + self.race_id
            )
            sleep(1)
        except BaseException:
            print("no result yet")

        self.odds = self._get_odds()
        self.info = self._get_info()

        self.dict_columns = {
            "馬番": "horse_no",
            "枠": "gate",
            "馬名": "horse_name",
            "斤量": "burden_weight",
            "騎手": "jockey_name",
            "厩舎": "stable",
            "馬体重": "horse_weight",
            "馬体重_増減": "horse_weight_change",
            "性別": "sextype",
            "年齢": "age",
            "オッズ": "odds",
            "着順": "target",
        }

    def _get_odds(self):
        self.driver.get(
            "https://race.netkeiba.com/odds/index.html?type=b1&race_id="
            + self.race_id
            + "&rf=shutuba_submenu"
        )
        html = self.driver.page_source.encode("utf-8")
        tanhukusoup = BeautifulSoup(html, "html.parser")
        tanhuku_df = pd.read_html(str(tanhukusoup.html))[0].loc[:, ["馬番", "オッズ"]]
        sleep(1)
        return tanhuku_df

    def _get_info(self):
        info_df = pd.read_html(
            "https://race.netkeiba.com/race/shutuba.html?race_id=" + self.race_id
        )[0]
        sleep(1)
        info_df.columns = [col[0] for col in info_df.columns]
        info_df = info_df.loc[:, ["馬番", "枠", "馬名", "性齢", "斤量", "騎手", "厩舎", "馬体重(増減)"]]
        info_df["馬体重"] = (
            info_df["馬体重(増減)"].str.split("(").str[0].replace("--", np.nan).astype(float)
        )
        info_df["馬体重_増減"] = (
            info_df["馬体重(増減)"]
            .str.split("(")
            .str[1]
            .str.replace(")", "")
            .replace("--", np.nan)
            .replace("前計不", np.nan)
            .astype(float)
        )
        info_df["性別"] = info_df["性齢"].str[0]
        info_df["年齢"] = info_df["性齢"].str[1:].astype(int)
        info_df.drop(["馬体重(増減)", "性齢"], axis=1, inplace=True)
        return info_df

    # 同着があり複数パターンある場合は1番初めのパターンだけ取得
    def result_sanrentan(self):
        _result = self.result[2].set_index(0).loc["3連単", [1, 2]]
        return (
            int(_result[2].replace(",", "").split("円")[0]),
            list(map(int, _result[1].split(" "))),
        )

    def result_sanrenpuku(self):
        _result = self.result[2].set_index(0).loc["3連複", [1, 2]]
        return (
            int(_result[2].replace(",", "").split("円")[0]),
            list(map(int, _result[1].split(" ")))[:3],
        )

    def result_tansyo(self):
        _result = self.result[1].set_index(0).loc["単勝", [1, 2]]
        return (
            int(_result[2].replace(",", "").split("円")[0]),
            list(map(int, _result[1].split(" "))),
        )

    def get_df(self):
        df = self.info.merge(self.odds, on="馬番").merge(
            self.result[0].loc[:, ["馬番", "着順"]], on=["馬番"]
        )

        # カラムが日本語だとモデルの学習ができないので置換
        df.columns = df.columns.map(self.dict_columns)

        df["race_id"] = self.race_id

        # 着順が数値以外のものを除外・置換
        df = df.loc[~df["target"].isin(["中止", "除外", "取消"]), :]
        df["target"] = df["target"].replace("失格", 20).astype(int)

        # オッズが数値以外のものを置換
        df["odds"] = df["odds"].replace("---.-", np.nan).astype(float)

        # lighgbmのlambdarankは数値が大きい方がランクが高いという定義なのでtargetを変換
        df["target"] = df["target"].max() - df["target"] + 1
        return df

データの取得期間は下記のように分けました。

  • 訓練データ:2020年9月5日〜2020年10月31日(540レース分)
  • 検証データ:2020年11月1日〜2020年11月23日(252レース分)
  • テストデータ:2020年11月28日〜2020年11月29日(48レース分)*2

モデルの訓練に使えるようなデータ形式で取得し、払戻金の計算が後ほどできるように当たった場合の金額を取得しています。*3

list_date_train = [
    "20200905",
    "20200906",
    "20200912",
    "20200913",
    "20200919",
    "20200920",
    "20200921",
    "20200926",
    "20200927",
    "20201003",
    "20201004",
    "20201010",
    "20201011",
    "20201017",
    "20201018",
    "20201024",
    "20201025",
    "20201031",
]

list_date_val = [
    "20201101",
    "20201107",
    "20201108",
    "20201114",
    "20201115",
    "20201121",
    "20201122",
    "20201123",
]

list_date_test = ["20201128", "20201129"]

list_train_df = []
dict_train_result = dict()
for date in tqdm(list_date_train):
    race_ids = get_raceids(date)
    for race_id in tqdm(race_ids):
        hr = HorceRacing(race_id, driver)
        train_df = hr.get_df()
        train_df["date"] = date
        list_train_df.append(train_df)
        dict_train_result[race_id] = {
            "sanrentan": hr.get_result_sanrentan()[0],
            "sanrenpuku": hr.get_result_sanrenpuku()[0],
            "tansyo": hr.get_result_tansyo()[0],
        }

list_val_df = []
dict_val_result = dict()
for date in tqdm(list_date_val):
    race_ids = get_raceids(date)
    for race_id in tqdm(race_ids):
        hr = HorceRacing(race_id, driver)
        val_df = hr.get_df()
        val_df["date"] = date
        list_val_df.append(val_df)
        dict_val_result[race_id] = {
            "sanrentan": hr.result_sanrentan()[0],
            "sanrenpuku": hr.result_sanrenpuku()[0],
            "tansyo": hr.result_tansyo()[0],
        }

list_test_df = []
dict_test_result = dict()
for date in tqdm(list_date_test):
    race_ids = get_raceids(date)
    for race_id in tqdm(race_ids):
        try:
            hr = HorceRacing(race_id, driver)
            test_df = hr.get_df()
            test_df["date"] = date
            list_test_df.append(test_df)
            dict_test_result[race_id] = {
                "sanrentan": hr.result_sanrentan()[0],
                "sanrenpuku": hr.result_sanrenpuku()[0],
                "tansyo": hr.result_tansyo()[0],
            }
        except BaseException:
            pass

train = pd.concat(list_train_df)
train = train.sort_values("race_id")
train.reset_index(inplace=True, drop=True)

val = pd.concat(list_val_df)
val = val.sort_values("race_id")
val.reset_index(inplace=True, drop=True)

test = pd.concat(list_test_df)
test = test.sort_values("race_id")
test.reset_index(inplace=True, drop=True)

前処理

文字列はそのままだとモデルに入れられないので数値に置き換えます。 今回モデルはlightgbmを使うので、OrdinalEncoderを利用しました。 また、重要そうなオッズを中心に特徴量を追加しました。(関数:add_features)

lightgbmのランク学習(lambdarank)の場合、回帰・分類予測と違い上から順に○行は同じレースだよというqueryを用意する必要があるので作成しておきます。

categorical_cols = ["horse_name","jockey_name","stable","sextype"]

ce_oe = ce.OrdinalEncoder(
    cols=categorical_cols, handle_unknown="return_nan", handle_missing="return_nan"
)

train = ce_oe.fit_transform(train)
val = ce_oe.transform(val)
test = ce_oe.transform(test)

def add_features(df):
    odds_min = df.groupby("race_id")["odds"].min()
    # オッズの最小値との差分
 df["odds_diff"] = df["odds"] - df["race_id"].map(odds_min)
    # オッズの最小値との倍率
    df["odds_ratio"] = df["odds"] / df["race_id"].map(odds_min)
 # オッズの偏差値
    df["odds_deviation"] = (df["odds"] - df["odds"].mean()) / df["odds"].std()
    # 斤量 + 馬の体重
    df["all_weight"] = df["burden_weight"] + df["horse_weight"]


add_features(train)
add_features(val)
add_features(test)

# レースIDを後で参照できるように保持
arr_train_race_ids = train["race_id"].unique()
arr_val_race_ids = val["race_id"].unique()
arr_test_race_ids = test["race_id"].unique()

# ランク学習に必要なqueryを作成
train_query = train.groupby("race_id")["horse_no"].count().values.tolist()
val_query = val.groupby("race_id")["horse_no"].count().values.tolist()
test_query = test.groupby("race_id")["horse_no"].count().values.tolist()

# 学習に不要なカラムを削除
drop_cols = ["race_id","date"]

train.drop(drop_cols, axis=1, inplace=True)
val.drop(drop_cols, axis=1, inplace=True)
test.drop(drop_cols, axis=1, inplace=True)

# 目的変数を分離
target = train_df.pop("target")
val_target = val_df.pop("target")
test_target = test_df.pop("target"

学習

モデルはlightgbmのscikit-learn APILGBMRankerを利用しました。評価指標はNDCG)*4です。

lgb_params = {
    "objective": "lambdarank",
    "metric": "ndcg",
    "n_estimators":2000,
    "boosting_type": "gbdt",
    "num_leaves":31,
    "learning_rate":0.01,
    "importance_type": "gain",
    "random_state": 42,
}
lgb_fit_params = {
    "eval_metric":"ndcg",
    "eval_at":(1,2,3),
    "early_stopping_rounds": 50,
    "verbose": 10,
    "categorical_feature": categorical_cols,
}

lgb_model = lgb.LGBMRanker(**lgb_params)

lgb_model.fit(
    train,
    target,
    group=train_query,
    eval_set=[(train,target),(val,val_target)],
    eval_group=[train_query,val_query],
    **lgb_fit_params
)

学習結果です。出馬情報だけだと予測が難しそうですね。

training's ndcg@1: 0.550162  training's ndcg@2: 0.608893 training's ndcg@3: 0.649182
valid_1's ndcg@1: 0.444402  valid_1's ndcg@2: 0.513605  valid_1's ndcg@3: 0.550583

特徴量の重要度(feature_importance)を算出してみました。オッズ関連が予測に効いていますね。

fti =pd.Series(lgb_model.feature_importances_,index=train.columns).sort_values()
fti.plot(kind="barh")

f:id:enigmo7:20201204172819p:plain

予測・評価

検証データ(val)とテストデータ(test)、それぞれに対して予測結果から単勝・3連複・3連単での的中率と100円ずつ買った場合いくら儲かるのか?を計算してみます。比較としてオッズの低い順(人気順)に買ってみた場合も出してみます。

def get_result(
        target, pred, query, race_ids, dict_result, is_higher_better=True, bet_yen=100
    ):
        ind = 0
        correct_first = 0
        refund_first = 0

        correct_sanrenpuku = 0
        refund_sanrenpuku = 0

        correct_sanrentan = 0
        refund_sanrentan = 0

        for q, race_id in zip(query, race_ids):
            _true_first = np.argmax(target[ind : ind + q])
            if is_higher_better:
                _pred_first = np.argmax(pred[ind : ind + q])
            else:
                _pred_first = np.argmin(pred[ind : ind + q])
            if _true_first == _pred_first:
                correct_first += 1
                refund_first += dict_result[race_id]["tansyo"] / 100 * bet_yen
            else:
                refund_first -= bet_yen

            _true_sanren = np.argsort(target[ind : ind + q].values)[::-1][:3]
            if is_higher_better:
                _pred_sanren = np.argsort(pred[ind : ind + q])[::-1][:3]
            else:
                _pred_sanren = np.argsort(pred[ind : ind + q])[:3]

            if len(set(_true_sanren) & set(_pred_sanren)) == 3:
                correct_sanrenpuku += 1
                refund_sanrenpuku += dict_result[race_id]["sanrenpuku"] / 100 * bet_yen
            else:
                refund_sanrenpuku -= bet_yen

            if _true_sanren.tolist() == _pred_sanren.tolist():
                correct_sanrentan += 1
                refund_sanrentan += dict_result[race_id]["sanrentan"] / 100 * bet_yen
            else:
                refund_sanrentan -= bet_yen

            ind += q
        print(
            f"単勝  的中率: {correct_first / len(query) * 100 :.2f}%, 払戻金-賭け金: {refund_first}"
        )
        print(
            f"3連複 的中率: {correct_sanrenpuku / len(query) * 100 :.2f}%, 払戻金-賭け金: {refund_sanrenpuku}"
        )
        print(
            f"3連単 的中率: {correct_sanrentan / len(query) * 100 :.2f}%, 払戻金-賭け金: {refund_sanrentan}"
        )
        print("-------------------------------------------")
        print(
            f"合計  回収率: {((refund_first+refund_sanrenpuku+refund_sanrentan) / (3 * bet_yen * len(race_ids))+1) * 100 :.2f}%, 払戻金-賭け金: {refund_first+refund_sanrenpuku+refund_sanrentan}"
        )

pred_val = lgb_model.predict(val,num_iteration=lgb_model.best_iteration_)
pred_test = lgb_model.predict(test,num_iteration=lgb_model.best_iteration_)

print("■検証データ(val)")
print("lightgbm モデル")
print("-------------------------------------------")
get_result(
    val_target,
    pred_val,
    val_query,
    arr_val_race_ids,
    dict_val_result,
)
print("")
print("オッズ低い順")
print("-------------------------------------------")
get_result(
    val_target,
    val["odds"],
    val_query,
    arr_val_race_ids,
    dict_val_result,
    is_higher_better=False,
)
print("")
print("■テストデータ(test)")
print("lightgbm モデル")
print("-------------------------------------------")
get_result(
    test_target,
    pred_test,
    test_query,
    arr_test_race_ids,
    dict_test_result
)
print("")
print("オッズ低い順")
print("-------------------------------------------")
get_result(
    test_target,
    test["odds"],
    test_query,
    arr_test_race_ids,
    dict_test_result,
    is_higher_better=False,
)

VSオッズ低い順

検証データ、テストデータ共にオッズ低い順にかけたよりも回収率が高くなりました!検証データでは残念ながらボロ負けですが、テストデータでは回収率100%超えました。特に3連複・3連単の的中率がオッズ低い順より高くなっているのがおもしろいです。

■検証データ(val)
lightgbm モデル
-------------------------------------------
単勝  的中率: 30.95%, 払戻金-賭け金: 4660.0
3連複 的中率: 6.75%, 払戻金-賭け金: -11460.0
3連単 的中率: 1.59%, 払戻金-賭け金: -17060.0
-------------------------------------------
合計  回収率: 68.44%, 払戻金-賭け金: -23860.0

オッズ低い順
-------------------------------------------
単勝  的中率: 32.14%, 払戻金-賭け金: 2450.0
3連複 的中率: 5.56%, 払戻金-賭け金: -15660.0
3連単 的中率: 0.40%, 払戻金-賭け金: -22270.0
-------------------------------------------
合計  回収率: 53.07%, 払戻金-賭け金: -35480.0

■テストデータ(test)
lightgbm モデル
-------------------------------------------
単勝  的中率: 37.50%, 払戻金-賭け金: 1360.0
3連複 的中率: 14.58%, 払戻金-賭け金: 1060.0
3連単 的中率: 4.17%, 払戻金-賭け金: -900.0
-------------------------------------------
合計  回収率: 110.56%, 払戻金-賭け金: 1520.0

オッズ低い順
-------------------------------------------
単勝  的中率: 37.50%, 払戻金-賭け金: 1310.0
3連複 的中率: 12.50%, 払戻金-賭け金: -1040.0
3連単 的中率: 2.08%, 払戻金-賭け金: -3360.0
-------------------------------------------
合計  回収率: 78.54%, 払戻金-賭け金: -3090.0

VS競馬必勝本

竹本さんの記事では検証データと同じ期間にレースを選択して、3連複のみ購入していました。

金額としては14630円負けてしまいました。

競馬必勝本は本当に当たるのかを検証!〜Pythonで実装する馬券自動選択ツール〜 - エニグモ開発者ブログ

そこで検証データの3連複のみ比較すると、

-11,460円(lightgbmモデル × 全レース) > -14,630円(競馬必勝本) > -15,660円(オッズ低い順 × 全レース)

ということで、まぐれかもしれないですが機械学習で競馬必勝本に勝てたと言ってもよいのではないでしょうか?

感想

なかなか簡単には儲かりませんね。

まだまだ改善の余地がありそうなので、気がむいたら趣味で続けてみたいと思います。

  • 特徴量の追加:コースの情報、天気、血統
  • 取得データ期間の延長
  • どのレースに賭ける/賭けないべきか?の予測
  • どの種類の馬券を買うべきか?の予測

参考資料

Welcome to LightGBM’s documentation! — LightGBM 3.1.0.99 documentation

LightGBMでサクッとランク学習やってみる - 人間だったら考えて

馬券の種類:はじめての方へ JRA

レース情報(JRA) | 出馬表やオッズ、プロ予想などの競馬情報は netkeiba.com

最後まで読んでいただきありがとうございました。

明日の記事の担当はエンジニアの齊藤さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

*1:私はpoetryを利用しました。

*2:後から気づいたのですが、正しく比較するにはテストデータを2020年11月1日〜2020年11月23日にすべきでした。

*3:3位以内に同着がある場合は、前処理の簡略化のためサイト上に一番初めに掲載されているパターンのみ取得しています。

*4:NDCGは0〜1の値をとり、高いほど精度がよいことを示します。

100日後に入社する新卒のエンジニア、コロナ禍での就活を振り返る

こんにちは。2021年度より株式会社エニグモに新卒で入社することになりました、岡本です。

普段はMac使いですが、10月にうっかり注文してしまったLenovo ThinkPad X13 Gen 1 (AMD)がもうすぐ届きます。2ヶ月待ちました。WSL2の使い心地をチェックしたいと思います。

この記事は Enigmo Advent Calendar 2020 の7日目の記事です。

私が就職活動を行った2020年は新型コロナウイルスが流行し、全世界が多大な影響を受けました。そうした中で私がどのように就職活動を行ってきたか、そして内定先企業である株式会社エニグモに入社した経緯について、その他諸々綴ります。

いわゆる就活体験記です。

5000字超えの問わず語り、ぜひお付き合いください。

目次

自己紹介

2020年11月よりアルバイトとしてサービスエンジニアリング本部に所属し、海外通販サイト『BUYMA』のWebアプリケーション開発に携わっています。

大学在学中にプログラミングの学習を始め、企業でWebアプリ開発の業務を行ったりインターンに参加していました。

エンジニアを目指したきっかけ

幼い頃から自宅のパソコンでインターネットを閲覧したり創作することが好きでした。小学校にもパソコンがあり、タイピング速度は学校で一二を争うほど速かったです。しかし中高生になった段階でスマートフォンを手に入れ、パソコンを使う機会は激減しました。

高校生の頃、部活動をせずに学校が終わったら急いで家に帰って音楽や深夜ラジオを聞いたり映画やコントのDVDを見たりカルチャー雑誌や文学作品を読んで一人の時間を楽しんでいました。地方で文化に飢えていた私、ライブや劇場に通うために大学進学を機に上京し、更にカルチャーに塗れた生活をしたいなとぼんやり考えていました。

が、紆余曲折あって上京を断念することになり、地元関西の大学の経済学部に進むことになりました。その時「困難に直面しても自力で立ち向かっていける武器を身に付けたい」と思いました。そんな中、書店で手に取った本に「これからの時代はプログラミングスキルを身に付けている人が重宝される」と書いてありました。すぐさまプログラミングについて調べ、Progateで学習し始めました。そこでプログラミングは面白いと思い、できるようになりたいと思いました。

プログラミングの学習を始めてから数ヶ月経過し、その中でスタートアップという世界があることを知りました。また、当時(2017年)は仮想通貨・ブロックチェーンがトレンドで、それらの技術に興味がありました。SNSを駆使し、仮想通貨に関する事業を行うスタートアップを見つけてインターンすることになりました。

僕が任されたのはエンジニアリング業務ではなく仮想通貨メディアのライター業務でした。エンジニアをやりたいと思ったものの、ライターの業務もやりがいがあり楽しかったです。しかし、社内にいたエンジニアたちがホワイトボードを囲んで議論したり黒い画面に向かってコードを書いているのを横目で見て、やはり彼らのように働けるようになりたいと思いました。また、別のスタートアップの社長の方からもエンジニアになるよう後押しをいただいたこともあり、再びプログラミング学習に力を入れました。

ある程度学習が進んだ大学2年の終盤にさしかかる頃、先輩の紹介でWebエンジニアのバイトを始め、サマーインターンハッカソンにも参加するようになりました。

就職活動について(どういった軸で行い、エニグモと出会ったか)

満を持して3年時に参加した複数の企業のサマーインターンで、全国から集う優秀なエンジニア学生たちを目の当たりにし、自分の無力さを痛感しました。さらに逆求人形式のイベントに参加した際にも企業から辛辣な評価を受け、完全に心が折れ、自分の行先を見失っていました。情報系の大学/大学院を受験することも考えましたが、色んな人に相談し、エンジニアとして就職する決意を固めました。

コロナ禍での就活

年が明け、企業の本選考に応募し始めた矢先、新型コロナウイルスが流行し始めました。エニグモを始め、各社オンラインでの面接が基本となったため、地方に住んでいる身としては移動のコストが掛からず、大変助かりました。

内定を貰い、アルバイト入社した現在もなお、オフィスに出社したことがありません。社員の方とも直接お会いしたことがありません。しかし、ZoomやSlackでコミュニケーションが取れるため、特に不便は感じていません。(が、せめて直接挨拶したい)

こういったことはIT企業、なおかつエンジニアという職だから実現するのだな…と、しみじみありがたく思います。

選考について

選考を受ける過程で、大事だなと思ったことや、準備をしていたことがいくつかあるのでご紹介します。

聞かれたことについて答える

面接を受ける中で一番大事だと思ったのが「聞かれたことについて答えること」です。

簡単なことだと思われるかもしれませんが、意外と難しいことだと思います。

聞かれたことについて無目的に答えるのではなく「事実と意見を区別して答えること」が重要だと感じています。事実について答えるように質問をされているのに自分の意見を話してしまったり、余計なことを答えてしまうことがあるので、聞かれたことについて的確に答えることを普段から意識しています。

内観し、自己を見出す

面接の終盤、面接官の方から「最後に質問はありませんか」と問いかけられることが多いと思います。私はよく面接全体のフィードバックを貰っていました。「僕のことどう思いましたか?」という感じで。指摘されたことはすべて紙のノートやNotionにメモしていました。

例えばある時は「淡々と抑揚のない感じで喋っている」と指摘されたので、次回から少し声を張ったり表情を緩めて話すことを意識するようにしました。

Notionには「就活」というディレクトリを作り、その下に「就活の軸」「自己紹介」「フィードバック」「想定される質問、それに対する回答」「企業の情報、面接の記録」などのファイルを作って、日々言語化を繰り返し、自分は心の中で何を思っているのかを言葉に起こす作業をしました。

心の中で思っていることを言葉にすることの有効性については、T. ウィルソン 村田光二 (訳)『自分を知り、自分を変える―適応的無意識の心理学』(新曜社、2005年)の第8章が参考になりました。

会社選びの基準としては「社内のコミュニケーションは活発になされているか、技術の面で自分が貢献できそうか」などを焦点に置いていました。今後リモートワークが継続される可能性は高いため、各社コミュニケーションの仕方をどう工夫しているか面接で尋ねていました。

今までに作ったアプリや関わったサービスについて説明できるようにしておくのは重要です。自分がどんな役割を果たしたのか、こだわりポイントやつまづいたポイント、問題をどう解決したかなどを語れる準備をしました。「喉元過ぎれば熱さを忘れる」という言葉があるように、遭遇した問題やバグは解決してしまえば案外忘れてしまうものです(よね?)。いざ聞かれると答えられないということが分かったので事前に思い出していつでも言えるように準備していました。また、普段使っているWeb技術についても説明できるようにしていました。

技術試験

コーディングテストを課す企業があるので、AtCoderを使ってA~C問題を解けるように練習していました。未だにコーディングテストは苦手です…。

Webエンジニアの場合は、まずアプリケーション作りを頑張って、使っている技術について説明できることを優先すべきだと思います。

エニグモの採用(説明会・面接・面談)への印象

就活を始めた当初、エニグモを受ける予定はありませんでした。ある日の選考で受け答えがうまくいかず、失望の眼差しで求人サイトを眺めていたところ、偶然エニグモの新卒採用の募集が目に留まりました。事業が画期的で技術スタック的にも一致すると思い、軽い気持ちでエントリーしました。

エントリー後の人事面談を経て、1次・2次面接では各回エンジニア2名との面接を行い、最終面接で社長を含む役員3名・人事部長1名との面接を行いました。いずれも面接の翌日には結果をお知らせいただいていました。意思決定の素早さに驚きました。

面接期間は人事の方と連絡を取ることが多いですが、迅速かつ丁寧に対応していただき、好印象を抱きました。

そのほかにも、歳の近い複数の社員の方とのカジュアル面談を組んでいただきました。エニグモではBUYMAという安定した基盤がある中で、若手でも柔軟に裁量をもった仕事が出来ると聞き、自分の志向に合っていると感じました。

エニグモに入社した理由

過去にアルバイトで開発していたC2CのECサービスがあるのですが、クローズすることになりました。会員登録数は増えても購買数が伸び悩んでいて、エンジニアも施策に関して意見を求められる場面がありました。自分なりの提言はしてみるものの目に見える成果は出ませんでした。エンジニアのアルバイトとは言えども、少しでも力になりたかったです。

そこで、利益をあげているECサービスにはどんなエンジニアがいて、どんな開発をしているのか、どうやってビジネスサイドと連携を取っているのか、知りたくなりました。

そんな自分にとって『BUYMA』のエニグモは魅力的でした。会社自体はかねてから官報ブログやStrainerを通じて認知していました。

テックブログを読んだりサービスをチェックし、使われている技術が自分のスタックに一致していることやOSSに関わる方がいることから、ここならエンジニアとしての腕を磨けて、自分が抱える課題にも向き合えると思い入社することにしました。ちなみに内定を承諾するまで1ヶ月ほど検討しました。早期承諾を迫られることはなく、いつまでも待つと仰って頂きました。

現在インターンとしてどのような業務を行っているか

現在大学の卒業を控えており、卒業までの間は11月よりエニグモインターンとして勤務しています。

入社して1ヶ月程度なので、BUYMAの開発フローの理解に励んでいます。BUYMAは運用年数が長きに渡る大規模サービスであり、開発の中でいろいろ複雑な点があると感じます。簡単なチケットを割り当ててもらい業務に慣れている段階です。

新卒入社の先輩である平井さんにメンターとしてお世話になっています。チーム長の大川さんも含めて3人で朝会を行い業務をスタートし、疑問があればSlackで伝えたりZoom/Google meetをつないでペアプロをします。業務の終わりに日報を書き、作業内容を整理しています。

普段気をつけていることは「抱え込まない、自分で考える」です。分からないことは速やかに相談しますが、検索したりソースコードを読んで理解できそうことは自力で解決します。

今後の抱負

4月から社員として本格的に開発に携わる予定です。事業に貢献できるエンジニアになりたいと思います。業務に加え、情報技術者試験の受験、OSSへのコミットも目標に技術力を磨いていきたいです。

エンジニアを目指す就活生へのメッセージ

エンジニアとして生きていく以上、常に勉強をすることが大切です。(自戒を込めて)

初心者あるあるですが、こんなWebサービスを作りたい!と意気込んで大きいものを作ろうとして、結局何も出来ずに終わることがあります。

何か作りたいけど何をすれば良いかわからないという場合にはCRUD操作ができるアプリケーション、CLIツールや電卓など、地味で小さいものから作ることをお勧めします。進歩が目に見えてモチベーションが持続しやすいです。Ruby gemやnpmパッケージの自作もお勧めです。

そして何より、プログラミングを楽しみましょう。エンジニアに限らないことですが、仕事をしたり人と付き合う中でつらいと感じることがあると思います。でも、コードを読み書きすることにすら喜びを感じられないのはもっとつらいです。

リーナス・トーバルズLinuxカーネルを作ったのは世のためでも人のためでもなく「僕にとって楽しかったから」です。楽しくやりましょう。

Just for Fun: The Story of an Accidental Revolutionary

Just for Fun: The Story of an Accidental Revolutionary

僕から以上です。最後までお読み頂きありがとうございました。

明日の記事の担当はエンジニアの大川さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co

半年間使って便利だったVSCodeの機能紹介

はじめに

こんにちは、今年の6月にエニグモに入社したサーバーサイドエンジニアの橋本です。 この記事は Enigmo Advent Calendar 2020 の6日目の記事です。

みなさんはテキストエディタは何を使っていますか? 会社を見渡すとVimが一番多いような気がしますが、私はVSCodeを使ってます。 正直、エニグモ に入社するまではツールを入れる程度でそこまでカスタマイズしていなかったのですが、入社してからは諸先輩方の開発スピードに圧倒され、これはツールやショートカットキーを駆使して速く開発できるようにならなければ、、、という必要性に駆られ、少しずつカスタマイズを加えてきました。 この記事では初期設定でも使える便利機能やカスタマイズを加えてよかったショートカットキーやツールをピックアップして紹介していきたいと思います。

ショートカットキー

まずは使ってよかったショートカットキーについて紹介します。VSCodeではデフォルトでショートカットキーが予め設定されていますが、下記の手順でショートカットキーを追加できたり、コマンドを変更できたりします。
①下記画像のようにcmd + p でコマンドパレットを開き、>keyboardで検索し、Keyboard Shortcutsを開きます。 f:id:enigmo7:20201202100517p:plain ②下記画像のようにショートカットキー一覧が表示されます。画面上部のバーでショートカットキーが検索でき、編集したい項目をクリックすればショートカットキーを変更することができます。またwhenカラムではショートカットキーを実行するタイミングを設定することもできます。 f:id:enigmo7:20201202100753p:plain

デフォルトの設定でよく使うショートカットキー

それではデフォルトの設定でよく使ったショートカットキーを紹介していきます。 (※ショートカットキーはmacOSのキー配置に基づいて書いてます)

操作 コマンド
フォルダを開く ⌘+O
ファイル検索 ⌘+⇧+F
ファイルに移動 ⌘+P
ファイルを閉じる ⌘+W
一行選択 ⌘ + L
単語ごとに移動 ⌥+矢印キー
単語ごとに選択 ⇧+⌥+矢印キー
画面移動 ⇧+⌘+ 矢印キー
windowを右へ移動(windowが無い場合は分割) ⌃+⌘+→
windowを左へ移動(windowが無い場合は分割) ⌃+⌘+←
指定の行数まで移動 ^+G+ 行数
一番上まで移動 ⌘+↑
一番下まで移動 ⌘+↓
一番右に移動 ⌘+→
一番左に移動 ⌘+←
window1にフォーカス ⌘+1
window2にフォーカス ⌘+2
windowの切り替え ^+W

カスタマイズしたショートカットキー

次によく使うのに配置が使いにくかったり、元々割り当てられていなかったりしてカスタマイズを加えたショートカットキーの紹介です。 デフォルトの設定で既に入力しやすいショートカットキーはほとんど割り当てられているので探すのが大変ですが、自分で使いやすいようにカスタマイズを加えると一気に使いやすくなりました。

操作 コマンド
ターミナルへフォーカス ^+E
ターミナルの分割 ^+V
エディタへフォーカス ^+E
相対パスのコピー ⌘+⇧+C
絶対パスのコピー ⌘+⇧+A
windowを拡張 ^+⌘+矢印キー

相対パス/絶対パスのコピーはデフォルトの設定だと使いにくいので是非変えてみてください。私はターミナルにフォーカスしても使えるようにwhenカラムは空で設定しています。

導入してよかったツール

導入してよかったツールについて紹介します。

Ruby

Rubyを始めた人ならみんな入れると思いますが、一応。 Rubyのコードをハイライトしてくれます。

Ruby Solargraph

こちらもRubyを始めた人なら知っているとおもいますが、メソッドを予測して補完してくれます。 gemのinstallが必要です。

$ gem install solargraph

Endwise

このツールはendを自動補完してくれるツールでendの書き漏れを防いでくれると同時にコードを書くスピードを上げてくれます。

GitLens

GitLensはVSCodeでGitをより扱いやすくするツールです。色々な機能があるのですが、このツールで一番便利なのは過去のコミットが追いやすくなることだと感じました。 例えばGitで管理しているファイルで特定の行にフォーカスすることでその行の過去のコミットが表示されます。 f:id:enigmo7:20201202100841p:plain

またVSCodeの左側のBarのGitのマークをクリックすることで現在開いているファイルの過去のコミットを表示させることもできます。 f:id:enigmo7:20201202100854p:plain

上記のことはgit blameでも確認することはできますが個人的にはこっちの方が使いやすかなと思いました。

REST Client

PostmanのようにAPIにリクエストできるツールです。 使い方は簡単で拡張子がrestとなるようなファイルを作ります。 そして今回は例としてhttp://localhost:7700/user_authentication/jsonをpostするリクエストを書いていきます。

POST http://localhost:7700/user_authentication/
Content-Type: application/json

{
  "user": "user_hoge",
  "pass": "test_hoge"
}

ツールを導入している場合、下記画像のように2~3行目の間にSend Requestボタンが表示されます。 f:id:enigmo7:20201202100953p:plain

このボタンを押すことでリクエストすることができ、下記画像のようにリクエスト結果が表示されます。 f:id:enigmo7:20201202101018p:plain

導入してみて面白かったツール

VSCodeでこんなこともできるんだーと面白かったので紹介です。

Browser Preview

このツールを導入するには事前にChromeを入れる必要がありますが、なんとVSCode上でブラウザを開くことができます。 f:id:enigmo7:20201202101035p:plain

ブラウザと行き来しなくてよくなるのでフロントの開発がしやすくなるかもしれませんが、Chromeにあるような開発者ツールが無いのが残念。。。

その他やってよかったこと

フォルダを統合してワークスペースを作る

通常、VSCodeでは1windowにつき1つのフォルダしか開くことができませんが、ワークスペースを作成することで複数のフォルダを1windowで開くことができます。 今回は/配下に存在するadventとcalendarの2つのフォルダでワークスペースを作成し、1つのwindowで開けるようにしていきたいと思います。

$ ls
advent          calendar

まず、adventフォルダを開き、File>Save Workspace Asを開き、workspaceの名前をつけて保存します。(ここではworkspaceの名前はADVENT_CALENDARとしました。)

次に File>Add Folder to Workspaceを選択し、workspaceにcalendarフォルダを追加していきます。

するとVSCode左側のバーにCalendarフォルダが追加されたのが確認できます。 f:id:enigmo7:20201202101255p:plain

こんな感じでworkspaceを作成することができます!

おわりに

この記事では私が便利だと思ったツールやショートカットをピックアップして紹介してきましたが、もしもっといいツールがあれば是非紹介してください!!

明日の記事の担当はサーバーサイドエンジニアの岡本さんです。お楽しみに。


株式会社エニグモ 正社員の求人一覧

hrmos.co