20210913のGoに関する記事は3件です。

DMMの API-Gatewayを構築した話 (第一話)

こんにちは、DMMのプラットフォーム事業本部の基盤開発グループの恩田です。プラットフォーム事業本部では、DMMの各事業を支える共通のAPIやGUI部分を提供しています。その基盤を開発しているのが我々基盤開発グループとなります。昨今では特に APIの品質強化や運用の自動化に取り組んでいます。 さて、我々が手掛けた DMMの第二世代 API-Gateway(Gen2-GW)が稼働して1年以上が経ちました。基盤選定から開発・テストに至る全行程で苦労させられたこのプロダクトですが、そろそろ実績も出来てきたので、制作秘話や簡単な内部構造を共有したいと思います。 新型 API-Gatewayは何を改善したのか 以降は、Gen2-GWの改善点と改善方法を述べるスタイルで進めたいと思いますが、忙しい人のためにざっとリストアップしてみました。今回はこの冒頭にあたる機能面を説明してゆきたいと思います。 APIの推奨仕様を独自のRPCスタイルから標準的なRESTスタイルに変えた APIの仕様は新旧同時にサポートした 旧来からの独自機能も暫定でサポートした レイテンシとスループットを底上げしてビジネスの拡大に備えた 可用性と回復性を上げメンテナンスフリーに近づけた 従来では厳しかった大量のログ分析を可能にした 可観測性を上げて問題の解消スピードを上げた デプロイサイクルを高速化し改善スピードを上げた 機能面での改善 1. 独自のAPI形式への互換性を残しつつ 標準的なRESTfulAPIに対応 詳細は後述しますが、社内では旧来の独自API形式が未だに多く利用されています。実は旧来のAPI-Gateway(Gen1-GWと呼びます)はこの形式に依存しており、(そこまでは良いのですが)API-Gatewayの仕様上独自形式から抜け出せないという状況でした。これは結構厳しいです。かくいう僕も入社して初めてAPIを作成した際、RESTfulに設計したAPIが適用できず、泣く泣く独自形式に合わせた悲しい過去があります。 種別 Method URL Expected GET https://xxxxxx/user-service/v1/users/001 Actual POST https://xxxxxx/user-service/v1/getUser 標準への舵取り 他の人には同じ思いをさせたくない…という思いがあったわけではありませんが、この独自性を解消したらDMMのAPIをRESTfulに誘導できます。現行仕様を踏襲すれば波風立たないのは百も承知ですが、この課題を見てみぬフリをすると組織の成長を阻害します。という訳でココは本格的に対応することにしました。今後のAPI戦略にも関わってきますし重要です。 また独自のAPI形式をRESTfulにすると良いことが沢山あります。例えばURL, HTTP-Method, StatusCodeがHTTPの仕様に基づいた利用法となるので、周辺システム(例えばProxyやログ分析基盤)が正しい状況判断をできるようになります。具体的には Methodの仕様に基づいて冪等性や安全性を判断できたり、StatusCodeに基づいてリクエスト単位の状況を判断できたりといった形です。当たり前の状態を作っているだけではありますが、こういう細かい改善は後々効果が出てきます。 新旧2形式を同時サポート 更には、新旧の並列稼働も実現しました。完全に新規の仕様となると移行が大事になるので、そこを考慮して独自形式とRESTfulAPI形式の双方を扱える仕様にします。こうすることで旧式にしか対応していないクライアントでも改修せずに利用することができます。この辺りは細く設定可能にし、API毎に既定の挙動を設定しつつも、リクエスト毎に挙動を指定できるようにしています。 (改善)新旧APIの互換性を持たせて並列稼働可能にした この分野における 新旧GWの比較も下の表に纏めておきます。 # 項目 Gen1 Gen2 1 URL 独自形式 自由形式 2 PathParam ✕ ○ 3 Method POST前提 全てサポート 4 ResponseBody 固定要素あり 自由 2. 従来からの独自機能は引き続きサポート 一般的に API-Gatewayはそれほど多くの機能をもたせるものではありませんが、やはりリクエストが集約される箇所には機能を追加したくなる様です(効率的に見えるんでしょうね)気づけば Gen1-GWには様々な独自機能が追加されていました。 責務的にココじゃない感が半端ないので、本音を言えば直ぐにでも削除したい訳ですが、それらをパワープレイで除外するとそれに依存していたAPIが次々に障害を起こして大変なことになります。こういう点が横断プロダクトの辛いところですが、当面は機能を搭載して互換性を維持しなければなりません。そういう訳で Gen2-GWにも独自機能は搭載されています。 一方で、これらは可能なら今すぐ削りたい機能です。そのため独自機能は疎に結合させ、処理レイヤーを一つ削るだけで、影響なく簡単に廃棄できる設計にしてあります。簡単に削れる・簡単に棄てられるという特性も長い目で見るととても大事です。 (改善)レガシー機能はサポートしつつ Disposableにして将来に備えた # 項目 Gen1 Gen2 1 独自機能サポート ○ ○ (廃棄可能) 3. API-Gatewayの一般的な機能を漏れなくサポート ここからは負債とは関係ない API-Gateway一般に期待される機能の話です。ゼロからAPI-Gatewayを設計される方はこのあたりを実現することになると思います。もちろんGen2-GWもこれらの機能を実現させており、Gen1の不足部分を補う形で強化させています。以下に具体的な強化ポイントを載せておきます。 # 項目 Gen1 Gen2 1 Routing 独自のURL設計のみサポート RESTfulなURLもサポート 2 Traffic Handling 数種類の機能をサポート 一般機能は全てサポート 3 Authorization OAuth2に対応 OAuth2に対応 (キャッシュ機能付き) 4 Logging サーバー内に1週分を記録 クラウド上に1月分を記録 (改善)基本機能も漏れなく強化した 続けて、各項目について掘り下げつつ、API-Gatewayが必要とされている一般機能に関して再確認してゆきつつ、改善点を確認してゆきたいと思います。 3.1. Routing API-Gatewayは Clientに単一の窓口を提供します。これはClientにとってサービスディスカバリーとして機能しますが、API-Gatewayにとっては Routingの責務を負うことも意味します。そうなると大量のリクエストを膨大な定義に基づいてRoutingする必要が出てきます。こう書くと大変そうに聞こえますが、実際のところ技術的な難しさはあまり無いのが救いです。 唯一気をつける点は、RESTfulなPath設計をサポート可能にすることで、よく問題になるのがPathパラメータまわりです。この辺りはURL設計のポリシーを満足させられるエンジンを選んでおかなければなりません。例えば /v1/resources/:idと /v1/resources/customMethodでは最後の要素が2系統の定義で衝突しますが(/v1/resources/helloのhelloがidかcustomMethodか判別しづらい)、エンジンが上手いことRoutingしてくれないと厳しいです。 3.2. Traffic Handling 次に API-Gatewayの責務として主要なものは Traffic制御です。いわゆるHTTPの Request / Responseにおける通信の制御となります。この制御が適当だと、APIのクライアントへの応答が過度に遅れてしまったり、不調なバックエンドAPIに対しリクエストを激しく送り続けてしまったり、それらのリクエストが滞留してAPI-Gateway自体に負荷がかかり API全体の不調を招いてしまったりします。さて、具体的なTraffic制御内容ですが、主に次の様なものです。 # 機能 Cli保護 Srv保護 処理内容 1 Timeout ○ △ 一定時間以上応答の無いリクエストをキャンセル 2 Rate Limiting - ○ Client単位 または全体の流量を API単位で制限 3 Circuit Breaking ○ ○ 不調なAPIが回復するまで GWがClientのリクエストを即座に失敗させる 4 Firewall - ○ 不要なアクセス元や攻撃の発生源を遮断 Traffic制御まで入るとだいぶAPI-Gatewayらしくなってきますが、この辺りまでなら Apache, Nginxの様なリバースプロキシサーバで代替が可能です。実際にOSSで有名な Kongは Nginxを拡張して構築されています。 3.3. Authorization 続いて 必要とされるのが認可処理です。API-Gatewayは主にAPIコールを対象とした Gatewayですので、直接的なユーザの認証は必要ありません。とはいえ全てのリクエストを通過させてしまうと不正なアクセスを遮断できません。 昨今は境界セキュリティに頼りきった時代では無くなって来ましたが、API-Gatewayといったリクエストの集約ポイントには、依然として不正アクセスを制御する役割が期待されます。例えば ZeroTrustセキュリティで有名な BeyondCorpの論文でも「Gatewayと呼ばれるProxy的な役割がAccessControlEngineの指示の下リクエストを通過させるか判断する」というデザインが採用されています。(参考: BeyondCorp: Design to Deployment at Google) 弊社でもAPIへのアクセスには OAuth2をベースとした認可の仕組みが導入されており、Gatewayはその指示のもとリクエストの通過・遮断を制御しています。その他の仕組みも併せて導入していますが、話が長くなりそうなので割愛します。 3.4. Logging 最後に Loggingも API-Gatewayにとっては重要な機能です。API-Gatewayという一点にリクエストが集約されるので、標準化された形式でログを記録する箇所として最適なのです。実際、BackendのAPIが少々不調でも、API-Gateway側にログが残っていればそれをもとにリクエストの痕跡を確認することができます。 実際に運用してみると分かりますが、API-Gateway上できちんとロギングされていれば諸々の部署から調査依頼が入ってきます。それ自体は良いことなのですが、頻繁に調査を代行するのも大変なのでこのあたりのセルフサービス化も進めてゆく予定です。 詳細は次回の記事に書く予定ですが、ログ情報はクラウド上のログ基盤を経て分析可能としています。クラウドの力で可能になったログ集約とビッグデータ分析が、今後のデータ活用・分析におけるブレークスルーとなると考えています。 (改善)Traffic制御機能の網羅率を従来より向上させ、大規模なログ情報も保存可能とした 機能実現の方法 上記の各機能を用意するにあたって、重視したのが「OSSの活用」と「拡張の自由度」です。つまり、API-Gatewayとしてのコア機能を充足させるためOSSを使って工数を削減しつつも、自由に拡張する余地を(充分に)残さなければなりません。特に後者は不十分だと対応できないことが増えてしまいます。ですので我々にとって重要なのがOSSにおける拡張の自由度なのです。 OSSの選びかた さて、一般的に API-Gatewayの様なProxy系のOSSは Plugin形式での機能追加をサポートしています。より具体的には「多くのProxy系OSSが タスクとフィルターのパターンを適用して構成されており、そのフィルター部分を Pluginとして追加可能にすることで機能拡張を実現する」といった具合です。 多くの場合はこれで問題ないのですが、注意が必要なのはこれらが「Request単位」に特化した制御で、それ以上を想定していない点です。例えば「Routingエンジンに手を入れなければならない時」や「一部のコア機能を無効化したい場合」はどうすべきでしょうか。マイナーケースではありますが可能性がゼロでない以上、アーキテクトとして考えておかなければなりません。 弊社内では「取り敢えずやってみよう」「細かい部分は後で考えよう」という進め方は割と許容されていますが、この様な横断的な機能はそのノリで進めると後々行き詰まる可能性が残ります。システムの柔軟性をどの辺りまで担保するか、逆にどの辺りはFIXさせてしまって問題ないかというのをロジカルに判断してゆくのも横断システムの設計を考える上ではとても重要です。 また、DMMでは現在50以上の事業が存在しており、プラットフォーム事業本部内でも様々な案件が稼働しています。どの事業やどの案件が、どんなAPI-Gateway向けの新機能を要求するかは全く予想がつきません。もちろん全ての要求を実現する気はありませんが「実現できるけどやらない」と「実現できないからやれない」には大きな差があります。 さて、実際に Gateway系のOSS・サービスを見てゆくと、以下の様に分類することができます。 # 種別 例 評価 1 Managed-Service Apigee ・コアな変更が厳しそう・流量によっては価格と性能のバランスがマッチしない場合も 2 Cloud-Based Amazon API-Gateway ・コアな変更が厳しそう・Cloud環境にロックイン 3 k8s-Based-OSS Gloo Edge Ambassador ・k8sに強く依存 4 OSS-Framework KongTYK ・Plugin の枠を越えた改修が厳しい・有償版の機能には Cloudにより実現可能な部分も 5 OSS-Library Lura (KrakenD) ・変更に制限が少なく機能追加が容易 ・Framework化された KrakenD-CE も存在 最終的な選定結果 この中で、我々が選択したのが KrakenD-CE となります。選択の理由は次のとおりです。 Golang製で 高速・並列処理に期待できる Luraのライブラリ利用すら可能で変更の自由度が極度に高い (See: Library-Usage) Krakend-CEでのエンジンにはGin Frameworkを採用(高い稼働実績と分析・機能追加の容易さ) Cloud基盤やコンテナ基盤への制約が皆無 Routing, Traffic制御といった基本機能が充足している Timeout Rate-Limit CircuitBreaker (Firewall / WAFは Cloud基盤の責務と設計) APIの各種設定をファイル管理する形式なので、設定のGit管理が容易 (別のデザインを採用した Kongは設定をマスターDB上で管理しAPIで更新・非同期で取得する方式 -> 設定の可視化より一元管理を優先) 結果的にこの決定には何度か救われた。具体的には次のような改修が必要となった - 不要な機能・Cloudで代替できる機能の無効化 - HTTP-Clientの設定変更による性能向上 - Backendに対するカナリヤリリースを実現するためのLB機能の拡張 [採用] [コア部分で依存] なお、krakenD-CE上での機能追加は基本的にフィルタ構造で行うことになります。フィルタの定義箇所は主に3種類です。基本的にはこれらのフィルタを追加・削除しながら、必要に応じて サーバー機能を提供するエンジン周りやバックエンドコールを行うクライアント周りに手を入れる形となります。 - Handler:エンジンのデフォルト実装となるgin-gonic上のフィルタ - Proxy:krakenD上で、入力Pathを司るEndpoint毎のフィルタ - Backend:krakenD上で、出力先サーバーを司るBackendごとのフィルタ 既存のフィルタ定義は krakend-ceプロジェクトのルートに存在する各ファクトリの実装で確認できます。ここを追うと基本的な機能スタックが理解できます。 - handler_factory.go - proxy_factory.go - backend_factory.go Gateway向けの言語選定 OSSの選定と深く絡むのですが、開発言語の選定もかなり重要なポイントです。極論を言ってしまえばどの言語を利用してもそれなりの API-Gatewayを組むことは可能ですが、言語によっては以下の問題が発生します。 型付けが動的で複雑なシステムの構築に向かない 速度や並列化に問題があり性能が伸びづらい ハイスペックな開発者の心に響かず人員調達に苦労する これらをクリアする候補としてはJVM系の Kotlin/Scalaや Go言語が挙がりました。それぞれが静的型付けを持ち、Thread, Coroutine, Actor/Akka, Goroutineといった機能で並列実行もサポートしています。大きな違いは表現力豊かなJVM系の高級言語と、表現はベタ臭いが起動が高速でCloud適性のある Go言語といった印象です。 今回は複雑なロジックを持たないGatewayを Cloud基盤を前提に構築するという想定だったため、短所が目立たず長所が活きる Golangを採用しました。 [採用] Cloudの選定 ここからが面白い部分なのですが、話が長くなりそうなので、次回に回したいと思います。。次回も頑張って書くのでさらっと目を通して頂けるとありがたいです。 まとめ 今回は API-Gatewayの構築に関する機能面での課題・解決に関してご紹介しました。機能面での課題解決を下の図に纏めてみました。ポイントを整理したいと思います。 独自機能をサポートしするため、OSSは改造しやすいものを選びました GWの一般機能は OSSの活用で上手いことやり過ごしました 利用言語はGoに適性がありそうと判断しました ログ集約は次回以降でご紹介する予定です 次回は非機能面での課題・解決に関してクラウドを活用してゆくお話となります。また見てくださいね! 宣伝 PF事業本部では 他にもいろいろな記事を発信しています。良かったら他の記事にも目を通して雰囲気を感じ取ってください。 https://qiita.com/organizations/dmmcompf またPFでは随時新しいメンバーを募集しています。私達とプラットフォームやAPIを改善していきませんか。(やりたいことが沢山あるんです)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

HandleFuncとHandleでは何が起きてる?違いは?

HandleFuncとHandleの実行部分 注)実行したい関数、オブジェクトをhelloHandlerとする。 HandleFuncの実行部分 http.HandleFunc("/hello", helloHandler) Handleの実行部分 http.Handle("/hello", &helloHandler{}) とても似ている!!! なにが違うの?? HandleFuncとHandleの違い どちらも第1引数にはURLをとる。この二つには第2引数に違いがある。 HandleFunc http.HandleFuncは、第2引数にhttp.ResponseWriterと*http.Requestを取る関数をそのまま渡せる。 Handle HandleFunc関数とは違い、Handle関数の第2引数には関数を指定しない。HandleではhelloHandler型のServeHTTP関数を定義したHandlerのポインタを指定する。 つまり、http.Handleは、第2引数にhttp.Handlerインタフェースを実装したオブジェクトを渡す。 ん?http.Handlerインタフェースを実装したオブジェクトって? HandlerとはServeHTTPという関数を定義したインターフェイスのこと。参考:Handlerの公式ドキュメント つまり、第2引数はServeHTTP(http.ResponseWriter, *http.Request)という関数を実装したオブジェクトあれば良い。 func (t *helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } のようにhelloHandler用にServeHTTPを自分で実装する。 そうするとhelloHandlerはServeHTTPメソッドを実装しているためhttp.Handlerインタフェースを実装したオブジェクトとなる。 これまでのことも含め、何が起きているのかを見てみる HandleFunc func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) { DefaultServeMux.HandleFunc(pattern, handler) } -公式ドキュメントより参照 第1引数ではURLのパターンを受け取り、第2引数ではURLで実行したい関数を受け取る。 そしてDefaultServeMux.HandleFuncに登録される。これはURLとHandlerの関連付けをおこなう役割を持つ。 DefaultServeMuxはグローバルなServeMuxのインスタンス。net/httpパッケージにおいてあらかじめDefaultServeMuxが用意されている。 つまり、HandleFuncでは引数にURLと関数を渡すことで、それら二つを関連づけたものを登録してくれる。そして、URLにアクセスすると登録したところから探し出し、関数を実行してくれる。 Handle func Handle(pattern string, handler Handler){ DefaultServeMux.HandleFunc(pattern, handler) } -公式ドキュメントより参照 第1引数ではHandleFunc同様、URLのパターンを受け取る。第2引数ではURLで実行したい関数をServeHTTP関数に定義したHandlerのポインタを受け取る。 そしてDefaultServeMux.HandleFuncに登録される。 HandleFuncとHandleの実装例 最後にHandleFuncとHandleの実装例を見てみる。 注)実行したい関数、オブジェクトをhelloHandlerとする。 HandleFuncの実装例 package main import ( "fmt" "net/http" ) func helloHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "<h1>Hello, World</h1>") //「/hello」URLで実行したい処理 } func main() { http.HandleFunc("/hello", helloHandler) http.ListenAndServe(":8080", nil) } このようにURLで実行したい関数を作成し、実行ではHandFunc関数に引数としてURLと関数を渡す。 HandleFuncの実装例 package main import ( "fmt" "net/http" ) type helloHandler struct{} func (*helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "<h1>Hello, World</h1>") //「/hello」URLで実行したい処理 } func main() { http.Handle("/hello", &helloHandler{}) http.ListenAndServe(":8080", nil) } まずオブジェクトを作成し、URLで処理したい関数をServeHTTP内に記述する。 このように、ServeHTTP関数を定義したhelloHandler型のHandler(ServeHTTPという関数を定義したインターフェイス)を作る。 実行では、http.HandleにはURLとhttp.Handlerインタフェースを実装したオブジェクト(helloHandler)を引数として渡す。 参考文献 Go言語の公式ドキュメント 「http.Handler」 Go言語の公式ドキュメント 「http.HandleFunc」 Go言語の公式ドキュメント 「http.Handle」
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Gomega エラーメッセージが分かりやすいEqual Matcherを作った

作ったもの 動機 仕事でGoのテストライブラリとしてGinkgo/Gomegaを使っているのだが、Gomegaに組み込まれているEqual Matcherは、エラーメッセージが分かりづらい。 以下のようなテストコードがあるとする。gomegaのEqual Matcherにより2つの構造体を比較し、結果が同じになるか検証している。 var _ = Describe("Example", func() { It("Example", func() { actual := Object{ID: 1, Name: "actual", Tel: "Tel", Email: "Email"} expected := Object{ID: 1, Name: "expected", Tel: "Tel", Email: "Email"} Expect(actual).Should(Equal(expected)) }) }) 比較対象のNameの値に差異があるのでエラーになる。このときのエラーメッセージは以下の通り。 Expected <equalcmp_test.Object>: {ID: 1, Name: "actual", Tel: "Tel", Email: "Email"} to equal <equalcmp_test.Object>: {ID: 1, Name: "expected", Tel: "Tel", Email: "Email"} Nameが違うことが分かりづらい。 このモジュールが提供するEqual Matcher EqualCmpという名前でEqual Matcherを提供している。EqualCmpを使ったサンプルコードを示す。 gomegaのEqual()をEqualCmpに置き換えるだけ。 import ( "testing" . "github.com/kamikazezirou/equal-cmp" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) ... var _ = Describe("Example", func() { It("Example", func() { actual := Object{ID: 1, Name: "actual", Tel: "Tel", Email: "Email"} expected := Object{ID: 1, Name: "expected", Tel: "Tel", Email: "Email"} Expect(actual).Should(EqualCmp(expected)) }) }) EqualCmpのエラーメッセージは以下のとおり。 Mismatch(-actual +expected): equalcmp_test.Object{ ID: 1, - Name: "actual", + Name: "expected", Tel: "Tel", Email: "Email", } 差分箇所が分かりやすくなった。 備考:実現方法 実際値と期待値の比較にgo-cmpを使うことで、このモジュールは実現している。 元ネタはこちら。このコードのエラーメッセージを調整して、module化しただけ。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む