Diary of a Perpetual Student

Perpetual Student: A person who remains at university far beyond the normal period

AWS AppConfig 機能フラグ向けのOpenFeature Go Providerを作りました

2024-09-01 v0.2.0で破壊的な変更をリリースしたので本エントリのコードも合わせて修正しました

OpenFeatureとは

OpenFeatureをご存知ですか?OpenFeatureは特定のベンダーに依存しないフィーチャーフラグの標準APIを定めるプロジェクトです。CNCF仲間のOpenTelemetryと同じような取り組みのフィーチャーフラグ版と捉えてもらうと良いのではないかと思います。フィーチャーフラグの標準APIがあるとどう嬉しいのかについては他のエントリでも紹介されていますのでぜひ探して読んでみてください。

AWSでフィーチャーフラグといえばAppConfig

さて、OpenFeatureが定めるのは単なるクライアントインタフェースなので、バックエンドが必要です。AWSを利用している身としてはフィーチャーフラグを自前で作ったり外部のSaaSに頼るよりまず、AWS AppConfigというサービスを選択肢として挙げたいです。

AppConfigを用いることで、デプロイと機能リリースのタイミングを分離することができます。なんらかの機能をリリースするために環境変数を変えてECSにコンテナをデプロイした経験はありませんか?これがECS側でノーデプロイで機能リリース・差し戻しができるようになるということです。

2024年7月にはマルチバリアントフラグといって、ユーザ名などのコンテクストを条件にフラグや属性の値を変える仕組みがリリースされたばかりです。

dev.classmethod.jp

そんな今ホットなサービスのAppConfigですが、探してもAWS AppConfig 機能フラグ向けのOpenFeature Go Providerが見つかりませんでした。このままではOpenFeatureのバックエンドとしてAppConfigを選択することができません。

なければ作ればいいじゃない、ということで作りました。

github.com

使い方

以下のようにして、AppConfig Providerを実体化しOpenFeature SDKのSetProvider関数に渡します。

import (
  "github.com/Arthur1/openfeature-provider-go-aws-appconfig/appconfig"
  "github.com/open-feature/go-sdk/openfeature"
)

openfeature.SetProvider(appconfigprovider.NewProvider("AppConfigのアプリケーション名", "同環境名", " 同設定プロファイル名"))

あとは、OpenFeatureのBoolean Evaluation関数を呼ぶと、AppConfigエージェント経由で指定した機能フラグを評価して結果を取得することができます。

client := openfeature.NewClient("app")
evalCtx := openfeature.NewTargetlessEvaluationContext(
    map[string]any{"userId": "userA"},
)
flagRes, err := client.BooleanValueDetails(ctx, "feature1", false, evalCtx)

現状AppConfig機能フラグではフラグの値にBooleanしか使えないため、OpenFeatureとしてBoolean以外に定義されているようなフラグ型の評価はできません。

詳しい使用方法や、AppConfigの概念とOpenFeatureの概念の対応づけについてはリポジトリのREADME.mdをご覧ください。

動作確認のため、リポジトリ内にデモを用意しています。

ここに含まれるterraformをapplyすることで、AppConfig機能フラグのリソース群や、AppConfigエージェントをレイヤーに持つLambda関数などが作られます。

Lambdaのコードはこのようになっており、今回私が開発したAppConfig機能フラグ向けのOpenFeature Providerを用いて、フィーチャーフラグの値を評価しログに書くという手続きが記述されています。

このLambda関数を実行すると、以下のようにログが出力され、このProviderがAppConfigエージェントを介して適切にフィーチャーフラグを取得できていることがわかります。

まとめ

プロダクト志向を掲げるエンジニアとして素早いデリバリーのためにフィーチャーフラグはなくてはならない仕掛けだと感じています。

OpenFeatureはまだまだ発展途中のプロジェクトですが、今回作ったフィーチャーフラグバックエンドにAWS AppConfigを使うProviderによって手軽に利用できるようになったと思うのでぜひ活用してみてください。盛り上がるときっとAWS公式でもライブラリを(Go言語に限らず)提供してくれることでしょう。

OpenFeature側でオーナー持ってもらっても良いかなと思い、openfeature/go-sdk-contribにissueは建ててみました。

github.com

Go製アプリケーション/ライブラリにおけるメンテナンス性を重視したGoのバージョン管理戦略

2024-08-28 GOTOOLCHAIN=auto時にはtoolchainディレクティブに指定したものより新しいGoがインストールされていても戻るわけではないという話を追記しました。

Go言語では半年に1回メジャーリリース(マイナーバージョンの更新)がやってきます。ちょうどこの8月にGo 1.23がリリースされたばかりです。Go言語のメジャーリリースは最新2つ分までサポートされるポリシーであることがhttps://go.dev/doc/devel/releaseに書かれています。現在であればGo 1.23やGo 1.22はサポートされており、Go 1.21はサポートが切れているということです。

また、サポートされているバージョンでは、不定期でマイナーリリース(パッチバージョンの更新)がやってきます。バグ修正や脆弱性対応がメインですね。

Goがリリースされると、Goでアプリケーションを作ったり、ライブラリを公開したりしているみなさんはなんらかの対応を取ることでしょう。一口にGoのバージョンといっても、

  • Go言語のバイナリのバージョン(=go versionで表示されるバージョン・Goのコンテナイメージのタグ)
  • ビルドする際のツールチェインのバージョン(=go.modのtoolchainディレクティブ)
  • ビルドができる最小バージョン(=go.modのgoディレクティブ)
  • 動作を保証する(サポートする)最小バージョン
  • テストを行うバージョン

といくつかあります。

このエントリでは、アプリケーションやライブラリといったリポジトリの性質ごと、前述の様々な「Goのバージョン」をどのように管理、アップデートすると良いのかを紹介します。私は個人でも仕事でもたくさんのGoのリポジトリをメンテナンスしているので、メンテナンスコストの小ささに重きをおいた選択をしています。Goのメジャーリリースのたびに様々なファイルを書き換えなくてはならず大変だと思っている方はぜひ読んでみてください。

一般論

先ほど紹介した「Goのバージョン」には以下のような大小関係が成り立ちます。

  • (ビルドができる最小バージョン)≦(ビルドする際のツールチェインのバージョン)
  • (ビルドができる最小バージョン)≦(動作を保証する最小バージョン)
  • (Go言語のバイナリのバージョン)≧ 1.21.0

ビルドができる最小バージョンというのはGo 1.21以降goディレクティブで指定されるものですが、これはめちゃくちゃ積極的に上げたいものではないと考えています。ビルドできる=サポートするとは一般に言えないでしょうし、なんらかの事情でGoのバージョンを上げられない人が自己責任で利用するのすら拒絶する強い理由もありません。もちろん、新しいセマンティクスを利用したい場合などでは、ライブラリとしてサポートするGoのバージョンポリシーの許す範囲でgoディレクティブを上げることになります。

goディレクティブを積極的に上げる場合でも、ライブラリや、アプリケーションでも公開packageがあるようなコードでは、最低限サポートされているGoのメジャーリリースの下限ではビルドできるようにしておくと良いでしょう。以下のissueは私がk1lowさんにgoディレクティブを上げすぎないでほしいですとお願いしたものです。

github.com

また、Go言語のバイナリのバージョンと、紹介した他の動的な「Goのバージョン」とには特に大小関係が成り立たせる必要がないことにも注意が必要です。Go 1.21から前方互換性を向上させるツールチェインの仕組みが導入され、go.modのtoolchainディレクティブやGOTOOLCHAIN環境変数を参照し、必要であれば新しいGoのツールチェインをダウンロードして使ってくれます。

go.dev

ツールチェインの仕組みがあるので、現代においてgoenvのような複数のGoバイナリを同居させるサードパーティのツールも基本的には不要でしょう。

以上の話を簡単にまとめると

  • Go言語のバイナリのバージョン
    • →現代では正直何でも良い
  • ビルドする際のツールチェインのバージョン(=go.modのtoolchainディレクティブ)
    • →やっぱり最新が最高
  • ビルドができる最小バージョン(=go.modのgoディレクティブ)
    • →利用したい言語機能が増えたときにはじめてアップデートすれば良い
    • →少なくともサポートされているメジャーリリースはカバーしたい

ということです。

アプリケーションでの設定例

ここではArthur1/otlcという拙作のCLIツールのリポジトリを例にして、アプリケーションリポジトリでの具体的な設定をご紹介します。

まず、go.modのtoolchainには、このエントリ執筆当時最新のGoバージョンであるgo 1.23.0を指定しています。toolchainディレクティブを利用するとGoのリリース後にRenovateがtoolchainディレクティブを更新するPull Requestを開いてくれるので、マージするだけでGoのバージョンアップデートが簡単にできます。Pull Request起因でCIを走らせることで新しいバージョンでのテストを行うこともできますね。

一方、go.modのgoディレクティブは更新せず1.22.0のままにしています。私がビルド成果物を提供していないようなCPUアーキテクチャ・OS向けのバイナリを利用者が各自でビルドすることも考えられます。その際に利用できるGoのバージョンを必要以上に狭めたくないからです。

テストやバイナリのビルドはGitHub Actionsで行っているのですが、setup-goではversionをstableに設定しています。setup-goにgo-version-file: go.modと記述するのをよく見かけますが、現在のsetup-goはビルド下限であるgoディレクティブしか見てくれません。例えばgoディレクティブに1.22.0と書かれているなら、まずgo 1.22.0が準備され、その後ツールチェインの仕組みでtoolchainディレクティブに指定したgo 1.23.0がダウンロードされるという挙動になってしまいます。stableを指定しておけば、大体の場合(最新に保った)toolchainディレクティブと一致する以上のバイナリを最初から用意してくれるでしょう。この問題はissueがすでにあるので将来に期待。

github.com

otlcはコンテナイメージも配布しており、公式のgolangイメージをビルドステージとして利用しています。Docker HubにあるgolangイメージではGOTOOLCHAIN環境変数がlocalとセットされており、デフォルトでは必要に応じてツールチェインをダウンロードすることができなくなってしまっています。これはパフォーマンスやDockerfileで宣言したバージョンとの一貫性を意識したもののようです。

github.com

このissueでrscさんがやや否定的なコメントしているのに自分も同感です。goのバージョン管理はgoのエコシステムだけに任せたい派なので、DockerfileでGOTOOLCHAIN環境変数をautoに上書きしています。そしてgolangイメージのタグ指定においてもバージョンを指定せず常に新しいものが利用されるようにしています(setup-goでgo-version: stableを選ぶのと同様の理由です)。

GOTOOLCHAIN=auto(デフォルト)時にはtoolchainディレクティブで指定したもの以上のGoのバージョンを許容します。常にGoを最新にしたいというわけではない場合には、あえてgolangのイメージを古いもの(1.21.0とか)に指定しておくと、toolchainディレクティブで指定したバージョンとピッタリ一致させることができます。

こうせずにDockerfileでの宣言を尊重したい方はビルド時のGoのバージョンを上げる際に同時にgolangイメージのタグも更新する仕組みを作ったり、こちらも合わせてDependabotやRenovateで更新したりようにすると良いでしょう。

長々と書いてしまったのでまとめます。

  • go.modにtoolchainディレクティブを用意し、Renovateで最新に保つ
  • setup-goで指定するgoのバージョンはstableで良い。必要であればツールチェインの仕組みで勝手にDLされる
  • DockerのgolangイメージではGOTOOLCHAIN=localになっているのでautoに上書きする

この通りにすることで、Goの新しいバージョンがリリースされたときにはRenovateが開くPull Requestをマージしてリリースワークフローを動かすだけで、ビルドやテストと様々な場所で使われるGoのバージョンが最新に保たれます。

ライブラリでの設定例

ライブラリの場合、Goのバージョンサポートポリシーはそれぞれのライブラリによって異なります。ここでは、Go言語として公式にサポートされている範囲(メジャーリリース2つ分)だけを同様にライブラリとしてもサポートするという前提で解説します。この前提は、メンテナンスに大量の労力を割けないような場合にも十分理にかなう設定かと思います。

ここではArthur1/http-client-cacheという拙作のライブラリのリポジトリを例にして、ライブラリリポジトリでの具体的な設定をご紹介します。

go.modはgo 1.21.0に固定しています。これはこのライブラリでは少なくともGo 1.22以降の新しい言語機能は現時点で利用していないからです。将来的に上がる可能性はありますが、CIなどで継続的に上げていくことはしていません。Go 1.21で利用することは許可しますが、バグが発生した場合などには大人しくGoのバージョンを上げてくださいと言います。

ライブラリですので、toolchainディレクティブは設定していません。

GitHub Actionsでのテスト時には、setup-goに指定するgo-versionにstableとoldstableの2種類を指定しています。stableエイリアスは最新のメジャーリリースの最新のパッチを、oldstableだと1つ前のメジャーリリースの最新のパッチを指します。エントリ執筆現在だとstableが1.23.0、oldstableが1.22.6ですね。サポート対象(CIでテストしておきたいGoのバージョン)をGo言語のサポートポリシーと一致させることで、setup-goのaliasを利用することができ、新しいメジャーリリースが出ても新しいものを追加して古いものを落とすという手間が必要なくなります。

つまり、Goの新しいリリースがあっても、新しい言語機能を使いたくなるまではリポジトリを何もアップデートする必要がないということです。

もちろん、新しいバージョンが出たときにすぐに動作確認しておきたいならpushベースのCIだけでは足りないので、テストを定期実行、あるいは手動で実行すると良いでしょう。

ライブラリかつアプリケーションでの設定

ライブラリかつアプリケーションというのは何かというと、アプリケーションの提供を主目的とするが公開packageを含むものです。

自分のリポジトリから例としてArthur1/mackerel-sesameを挙げます。これはリポジトリはツールを提供していると同時にクライアントライブラリも公開packageとして含んでいます。

この場合、「アプリケーションの設定例」でご紹介したtoolchainの設定と「ライブラリでの設定例」でご紹介したstable, oldstableのテスト戦略を組み合わせる必要があります。自分はアプリケーションバイナリを提供するだけのつもりでも、公開packageがあればそれに依存する人が出てくる可能性があります。もしこれを意図していない場合、アプリケーションを作るのに必要なpackageはすべてinternal/以下に押し込みましょう。

まとめ

Go自体ももちろん、日々利用する様々なツールでGoのバージョンを管理することができます。しかし、Goのバージョンの決定についてはGoのエコシステムにすべて寄せることで、テストやアップデート時の労力を減らすことができます。よろしければご紹介した設定を利用してみてください。ご意見ご感想もお待ちしています。

続き>

blog.arthur1.dev

SRE NEXT 2024で当日スタッフをしました&イベントの信頼性を支えるテクニック

去る2024年8月3日(土)・4日(日)に開催されたSRE NEXT 2024にて、当日スタッフをしておりました。SRE NEXTの当日スタッフとして関わるのは昨年に続き2度目となります。今回はTrack Aのお部屋で司会・タイムキーパー・誘導などしていましたので、ご来場いただいた皆さまとお会いする機会も多かったかと思います。

さて、簡単ではありますがスタッフ業の振り返りがてら、技術イベントの「信頼性」を支えるためのスタッフテクニックをご紹介します。

見積もりと先読みによる安全管理

イベントを開催するからには達成したい主目的があります。SRE NEXTであれば、SREに関するプレゼンテーションやディスカッションを通して、参加者が新たな知見を得ることができ、日々の業務に活かすことができる、といったところでしょうか。これはSRE用語に例えるとクリティカルユーザージャーニーに相当すると考えます。コアスタッフの方々はこれを念頭に置いてさまざまな企画を考え、実現に向けて動いてくださったことでしょう。

しかし、制約条件もあります。極端な例ですが、大きな事故が起きて来場者が亡くなってしまうような事態が起きたらイベントを続行することはできないでしょう。イベントの信頼性を高めるには、基盤としての安全性の担保が大切な観点です。

大きなイベントでは人々の移動により事故が起こるケースが多いです。同じ空間にどれだけ人が集まるのかを見積もり、その空間で人々が動いた時に何が起こるのかを予測し、対応策を取ることが大事です。

今回私は2日目のランチ配布時の行列形成を発案し実践しました。ランチを配布する予定の場所が出入り口に近く、ここで混雑すると人々の移動に混乱が発生することが想定されたからです。あまり移動に使われていない一番奥の通路を利用し、部屋の出入り口のない方向から並んでいただくことで、衝突や混乱を防ぎました。今回は人数的に不要だったのですが、着席位置ごと分散して列に並んでいただくよう促すアナウンスも準備していました。これも、通路の長さを簡単に測り、その空間に最大で何人並べるのかを見積もって判断していました。

2日目の分散退場もそうですが、ご参加いただいた皆さまにはスタッフの指示に従って行動していただき大変感謝しております。また、私だけでなく、スタッフの方々はランチ配布時のアレルギー表示をはじめとしたさまざまな観点で安全に配慮して準備いただき、結果として大きな事故の起こらないイベントになったと思っています。

きっかけを意識してオペレーションミスを防ぐ

SRE NEXT 2024はハイブリッドイベントで、セッションの配信をしています。また、セッションの合間にはスポンサー企業様のCMが流れたり、セッションの初めにはタイトルムービーが流れたりします。司会・配信業者様・セッション登壇者様などが関わるイベントのワークフローはとても複雑です。

ここで意識したいのは、何かを開始する合図となる「きっかけ」です。例えば、「この人によるキューが出たきっかけでアナウンスを入れる」「このムービーの終了きっかけで登壇者様が話し始める」といった形です。

司会業では自分のアナウンスの終わりが他の何らかの仕事のきっかけになる機会も多いです。特にきっかけとなるようなセリフでは、関係者で共有している台本のテキストから一字一句変えずそのままに読むことを徹底していました。そうすることで、配信業者様がボタンを押して操作するタイミングを迷わずに済みます。

スタッフ同士のブリーフィングで流れを把握させる/する際にも、誰のどの行動がきっかけなのかを意識して説明をしたり話を聞くことで、オペレーションミスを防ぐことができます。


最後になりますが、SRE NEXT 2024に当日スタッフとして参加できて良かったと思いました。来年の開催も決まっているので、何らかの形でお手伝いできればと思っています。

また、普段Mackerel・はてなで技術イベントを行う際にも同様の意識でイベントを運営しております。今後も継続的に開催していくと思いますので、足をお運びいただけると嬉しいです。

Azure SDKのHTTP Pipeline Policyの活用事例:APIコール数手動計装とクライアントサイドキャッシュ

様々な言語で提供されているAzure SDKのコアモジュールにはHTTP Pipeline Policyという仕組みが実装されています。

Java版のドキュメントが一番わかりやすかったので、こちらに掲載されている画像を引用して、HTTP Pipeline Policyが何たるかを説明します。

learn.microsoft.com

https://learn.microsoft.com/ja-jp/azure/developer/java/sdk/media/http-pipeline.svg

簡単に言うとHTTPクライアントのミドルウェアのようなものです。実際にHTTPリクエストをAPIに投げるまでにパイプラインで様々な前処理を行います。Azure SDKにはログを出力する機能やリクエストに失敗しても一定回数リトライする機能がありますが、これらはPipeline Policyとして実装されています。さらに、ユーザーが定義した独自のPolicyをパイプラインに組み込むことも可能です。

ユーザーがPipelineに登録できるPolicyはPer-call PolicyとPer-retry Policyの2種類あります。Per-call Policyは1回のメソッドの呼び出しにつき1回だけ呼ばれて実行されます。対してPer-retry Policyは、1回のメソッドの呼び出しでもRetry Policyによって複数回APIコールすることがありますが、実際のAPIコール分だけ呼ばれて実行されるものです。

ここからは、Azure SDK for Goの場合のコードを示しながら解説します。

Policyの実装と利用

https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/azcore/policy#Policyの通り、Policyという型がinterfaceとして公開されています。これは

type Policy interface {
    Do(req *Request) (*http.Response, error)
}

という定義になっています。当ブログの読者はお気づきだと思うのですが、Go言語でHTTPレスポンスを透過的にキャッシュする - Diary of a Perpetual Studentで紹介したRoundTrip interfaceとほぼ同じですね。Requestの部分がGo標準のhttp packageのものではなくsdk/azcore/policy packageで独自に定義されたものになっていますが、これは単なるwrapperで、.Raw()を用いて簡単にhttp.Requestを取り出すことができます。

例えばリクエスト先のURLをログに書くPolicyは以下のようにして書けます。

type URLLogPolicy struct {}

func (p URLLogPolicy) Do(req *policy.Request) (*http.Response, error) {
      ctx := req.Raw().Context()
      slog.InfoContext(ctx, "request", slog.String("url", req.Raw().URL.String()))
    return req.Next()
}

細かいですが、PolicyのDoメソッドはgoroutine safeでないと実用上困るので、Policyの不変性を保つためにレシーバはポインタにしない方が望ましいと思います。

Azure SDK独自のRequest型に、パイプラインの次のPolicy(もしくはTransport)を呼ぶメソッドが生えています。Go標準のHTTP Transportを自作するときには親のTransportを構造体内部に持っておくことが多いと思うのですが、Policyではこれをやらなくて済むので手間が省けて便利ですね。

Policyの実装ができたら、Azure SDKのClientを作る際にClientOptionsのPerCallPoliciesもしくはPerRetryPoliciesに渡してあげましょう。

client := armappservice.NewWebAppsClient(
    subscriptionID,
    session,
    &arm.ClientOptions{
        ClientOptions: policy.ClientOptions{
            PerCallPolicies: []policy.Policy{URLLogPolicy{}},
        },
    },
)

上記のようにすると、このclientを用いてAPIコールを伴うメソッドを呼び出した時にURLがログに出力されるようになります。

Policyのテスト

さて、Policyができたら次はテストを書きたくなるでしょう。テスト時にはAzure SDKのClient内部で作られているPipelineを手で作るのが一番手っ取り早く確実だと思います。Doの引数に渡るRequestを直接作ってもいいのですが、素朴に作ってしまうとPipelineの次のステージ(テスト時には専らTransportでしょう)が未定義となりエラーになってしまいます。

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "OK")
}))
defer ts.Close()
// 第1, 2引数はテストではそこまで関係ないはずなので適当に指定
pipeline := runtime.NewPipeline("", "", runtime.PipelineOptions{}, &policy.ClientOptions{
    PerRetryPolicies: []policy.Policy{URLLogPolicy{}},
})
req1, _ := runtime.NewRequest(context.Background(), http.MethodGet, ts.URL)
res, err := pipeline.Do(req1)

カスタムPipelineの活用事例

APIコール数をOpenTelemetry Metrics手動計装

APIコールは費用がかかるものですしレートリミットも設定されていることから、SDKが行ったAPIコールの数を可視化したい需要があるでしょう。

以下のようなPolicyをPerRetryPolicyとして設定することで、成功したAPIコール数をOpenTelemetry Metricsとして計装することができます。

type MonitorPolicy struct {
    Counter metric.Int64Counter
}

func (p MonitorPolicy) Do(req *policy.Request) (*http.Response, error) {
    ctx := req.Raw().Context()
    res, err := req.Next()
    if err != nil {
        return res, err
    }

    // 最新のAzure SDK for Goでは、contextにAPI名を入れてくれているのでここから取り出します
    apiName, ok := ctx.Value(runtime.CtxAPINameKey{}).(string)
    if !ok {
        apiName = "unknown"
    }
    p.Counter.Add(ctx, 1, metric.WithAttributes(attribute.String("azure.api_name", apiName)))
    return res, nil
}

MackerelのOpenTelemetry Metrics対応機能でグラフにした様子

また、同様にして、レスポンスヘッダに書かれているAPIコールレートリミットのバケット残数をヒストグラムとして計装することもできます。これは読者への課題としましょう。

クライアントサイドでキャッシュする

Go言語でHTTPレスポンスを透過的にキャッシュする - Diary of a Perpetual Studentで紹介したTransportと同じようなものをPipeline Policyとして実装してあげることで、Azure APIの呼び出しを一定期間キャッシュすることができます。実装についてはGitHub - Arthur1/http-client-cache: a Go library for transparent HTTP client-side caching using Transportとほぼ同じなため本エントリでは割愛させていただきます。

さて、以下のドキュメントに記載されている通り、Azure Resource Manager APIのレートリミットポリシーは2024年3月ごろから順次新しいものに変わりました。

learn.microsoft.com

この改定によって一般的にはより多くのリクエストを送れるようになったのですが、制限が1時間毎のリクエスト数だったものから1分毎一定量バケットに補充される方法に変わったため、短い時間で大量のリクエストを送るようなケースでは制限が厳しくなってしまいました。そういったアプリケーションではPolicy 1つ導入してクライアントサイドでキャッシュすることによって、APIコール数超過に対して小さな改修で対応することができます。

感想

現代のAzure SDKは開発体験が良い洗練されたライブラリだと感じています。自分が何らかのAPI Clientライブラリを提供するときにはHTTP Pipeline Policyの仕組みをぜひ取り入れたいです。

銅鑼パーソン的YAPC::Hiroshima 2024日記

ブログを書くまでがYAPC!ということで、今更すぎますがYAPC::Hiroshima 2024の振り返り記事を書いていきます。ログなのでメッセージ性はそんなにありません。

yapcjapan.org

Stats

2/9(金)

準備

YAPC::Hiroshimaに行くことはproposalを出したときから決めていましたが、キャパオーバーであらゆる事務作業を後回しにしていました。2月になってからホテルを取ろうとしたところ、なんと全然ホテルが空いてない!!

直前キャンセルする人もいるだろうということで、広島に出発する当日の朝に一所懸命ホテルを探して、なんとか奇跡的に予算内のものを見つけました。去年前日祭のLT資料を直前に作りはじめたところから進歩していないですね。

blog.arthur1.dev

一安心して今度は新幹線のチケットを予約して荷造り。前回の経験から各社様の素敵なノベルティがたくさんもらえるだろうしお土産も買いそうということで、ほぼ空のスーツケースを用意しました。こっちはちゃんと学べてるじゃん。

広島到着&チェックイン

17:00ごろに広島駅着。JRの改札に「ようこそ広島へ!」と大きく書かれていたのとmazda車が展示されていたのが印象的で、早速ワクワクしてきました。

路面電車に乗ってホテルにチェックイン。あれだけホテルの残数がなかった状態で空いていたホテルがどんなものかとビクビクしていましたが、普通に快適なビジネスホテルでした。

大きな荷物を置いてヘイタクシーして移動。YAPC::Kyoto 2023では同僚と乗ったタクシーの運転手さんにカップルと間違われ謎のディスカウントを受けるイベントがありましたが、今回は芸能人と間違われて今日は何のロケですかとか謎に話の方向が進んでしまいなんだか申し訳なかったです。さらにこのあと運賃のレシートを受付に置いてきてしまったのが忘れ物第一号となったのでした。

前夜祭

少し遅れて到着。司会のPasta-Kさんに言われるがまま最前列に着席しました。onkさんtakesakoさんに挟まれて畏れ多い感じ。

前夜祭の自分の全ツイートはこの辺り

Introduce Hono v4!!!! / Yusuke Wadaさん

成果物は軽量で高速に動作、さらに強力な型の恩恵を受けられるHonoというフレームワークの話でした。Reactと同様のインタフェースを用いてClient Componentを書ける、それでいてコアが軽いなどの思想はそのままというのがすごい。これはゆっくり時間をとってコードの中身を覗きたくなるやつですね。

個人的にはやはりSSGをサポートしたというのが刺さりポイントでした。SSG(Static Exports)ができるフレームワークはNuxt.js・Gatsby.js・Next.js・Astroと色々触ったもののまだ自分の手にしっくり馴染むものが見つかっていないので、次の個人開発でHonoを利用してみようと考えています。

Cache-Control: max-age=86400 / キャッシュバスターズ(そーだいさん・onkさん)

キャッシュを適切に活用するためにはobservabilityめっちゃ大事じゃんと思いました。どの指標をどのように見れば良いかも紹介されていたので、システム開発・運用者として、さらにサーバー監視SaaSの提供者として非常に参考になりました。

伝えたいメッセージ的に「キャッシュバスターズ」という名前が「キャッシュは麻薬」と同じように字面だけ一人歩きしてキャッシュは良くないぞという印象を与えてしまわないかというのは若干の気になりでした。Vim開脚バスターを知っている層はバスターという言葉に感じる印象がポップよりなのかもしれない。(これは空説です。)

2/10(土)

本編

ちゃんと朝起きて間に合いました。えらい。

コーヒーブース

はてなはGold Sponsor&コーヒーブーススポンサーということで、ワキヤコーヒー様の「はてなブレンド」を提供させていただきました!

セッション

セッションを聞いた感想はXに投稿しているのでこちらをあたってください。

個人的に印象的だったのはid:SougmuさんのBlogを作り、育み、慈しむ ~ Blog Hacks 2024でした。自分はこのブログをはじめとしてWebによる自己表現を続けていて、それがあんまり反応されないなあとかメンタルを病むことも多々あったのですが、このトークに救われました。

ライトニングトーク

なんと、銅鑼パーソンという大役を務めさせていただきました!今回はサブイベント含め全く登壇できなかったな〜出番ないな〜と思ってたのでめちゃくちゃ嬉しいです。

LTはどれも面白かったのですが、ドラと時間計測用スマホを持つ左腕が終始悲鳴をあげていてそれだけでかなり精一杯でした。Perl or Rakuクイズは5分の時間制約を良い感じに拡張していてXで盛り上がっていたので面白い発想だなと思いました。

懇親会

このビールが最高でした!

2/11(日)

YAYAPC::Hiroshima ~オフラインだからできる話〜

イベントの性質上インターネット上で多くは語れないのですが少しだけ。

これまで自分が参加した技術イベントの中で一番面白かったです。イベントの趣旨ぴったりの良い話がこんなにも集まるのか〜と感動していました。

ネタがネタだけに清々しい気持ちで聞けない雰囲気もちょくちょくありましたが、id:fujiwaraさんによるキーノートの締め「すべてのWebサービスに感謝を」はこれ以上ない最高の終わり方だったと思います。

その後……

その日のうちに帰るために新幹線を取るかもう一泊分ホテルを取るか悩んでいたのですが、#yapcramenしそびれていたのがどうしても心残りでもう一泊することにしました。

さらにその後

blog.arthur1.dev

最後に

来てよかったです!!!また次回もお会いしましょう!と言いたいところなのですが次回のYAPC::Hakodate 2024は予定が合わず行けそうにありません。またいつか。

Go言語でHTTPレスポンスを透過的にキャッシュする

Webアプリケーションの裏側にさらにHTTPサーバが立っていて、レスポンスを返すために裏側のサーバにリクエストを送ってその結果を必要とするような構成があります。裏側のサーバに設定さえたAPIレートリミットへの対応やサーバへの過負荷を避けるため、キャッシュを利用してリクエストが飛びすぎないようにしたいケースがあるでしょう。

Go言語のHTTP ClientにはTransportというパラメータがあり、これを差し替えることで透過的なクライアントサイドのキャッシュ層を導入することができます。

実際にライブラリとして作ってみたのでご紹介します。

github.com

使い方

利用例を用意しています。

https://github.com/Arthur1/http-client-cache/blob/82d0e8e327b9fd37554a135b0915891621689a6b/_example/main.go

まずは以下のように、ファクトリ関数でtransportを生成し、http.ClientのTransportに設定します。

redisCli := redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 0})
transport := httpclientcache.NewTransport(
    rediscache.New(redisCli), httpclientcache.WithExpiration(5*time.Minute),
)
client := &http.Client{Transport: transport}

このclientのDoメソッドに*http.Requestを渡してあげることで、リクエストが5分間Redisにキャッシュされます。期限内にこのclientを利用して同じHTTPメソッド・本文のリクエストを送るとレスポンスが得られますが、HTTPリクエストを送っているわけではなくキャッシュから取り出しています。

req1, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
res1, _ := client.Do(req1) // origin response

req2, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
res2, _ := client.Do(req2) // cached response

実装の詳細

先ほど紹介したライブラリの実装のポイントをかいつまんで説明します。

キャッシュキーを生成する

キャッシュストレージとのやり取りに必要なので、*http.Request(のURL・メソッド・本文)から一意に定まるハッシュキーを生成します。

このとき、リクエストボディが必要になるのですが、io.ReadCloserという型になっており二度Bodyを読むことができません。すなわち、キャッシュキーを作るためにBodyを読んでしまうと、実際にclientがリクエストを送る時にBodyを読めなくなってしまいリクエストに失敗してしまいます。

この問題を防ぐため、以下のようにReadCloserを作り直して代入する必要があります。

body, err = io.ReadAll(req.Body)
if err != nil {
    return "", err
}
req.Body = io.NopCloser(bytes.NewReader(body))

Bodyが取得できたら、あとは他のパラメータと合わせてハッシュを生成しましょう。キーの暗号化要件は求められないため、軽量なハッシュアルゴリズムであるhash/fnvパッケージを利用しています。

http-client-cache/cache/key/key.go at 82d0e8e327b9fd37554a135b0915891621689a6b · Arthur1/http-client-cache · GitHub

キャッシュと読み書きして必要に応じてHTTPリクエストを送るTransportを作る

Transportに求められるinterfaceはRoundTripperで、Requestを引数にとってResponseとerrorを返すメソッドを実装すれば良いです。

type RoundTripper interface {
    RoundTrip(*Request) (*Response, error)
}

フォールバック先のTransportをフィールドに持った構造体を作って、RoundTripperインタフェースを実装しましょう。

  • キャッシュヒット時
    • キャッシュから得たResponseを返す
  • キャッシュミス時
    • フォールバック先のTransportを利用してOriginにリクエストを送る
    • TTLを決めてレスポンスをキャッシュに格納する
    • Originから得たResponseを返す

上記のような手続きを行うRoundTrip()を作ってあげれば良いです。以下のコードでは、細かいところは簡略化しています。

type TransportWithCache struct {
    Base http.RoundTripper
}

func NewTransportWithCache() *TransportWithCache {
    return &TransportWithCache{Base: http.DefaultTransport}
}

func (t *TransportWithCache) RoundTrip(req *http.Request) (*http.Response, error) {
    ctx := req.Context()
    key, err := t.cacheKey(req)
    if err != nil {
        // キャッシュキーの生成に失敗したらOriginにアクセスする
        return t.Base.RoundTrip(req)
    }

    cachedRes, ok, err := cache.Get(ctx, key)
    if err != nil {
        // キャッシュからの取得に失敗したらOriginにアクセスする
        return t.Base.RoundTrip(req)
    }
    if ok {
        // キャッシュヒット
        return cachedRes, nil
    }

    // Origin にアクセス
    res, err := t.Base.RoundTrip(req)
    if err != nil {
        return nil, err
    }
    // 200ならキャッシュにセットする
    if res.StatusCode == http.StatusOK {
        cache.Set(ctx, key, res, time.Minute)
    }
    return res, nil
}

http-client-cache/transport.go at 82d0e8e327b9fd37554a135b0915891621689a6b · Arthur1/http-client-cache · GitHub

http.Responseを保存可能な型に変換する

http.Responseはio.ReadCloserなどを含んだ構造体なので、そのままではRedisなどのデータストアに保存することができません。httputil.DumpResponseを利用することで、[]byteに変換することができます。

逆にhttp.Responseを復元する際には、http.ReadResponse関数が有効です。

req, _ := http.NewRequest(http.MethodGet, "https://example.com", nil)
res, _ := &http.Client{}.Do(req)
dumpedRes, _ := httputil.DumpResponse(res, true)
restoredRes, _ := http.ReadResponse(bufio.NewReader(bytes.NewReader(dumpedRes)), req)

http-client-cache/cache/engine/rediscache/redis.go at 82d0e8e327b9fd37554a135b0915891621689a6b · Arthur1/http-client-cache · GitHub

Cache Stampedeを防ぐ

キャッシュが破棄された時に、同時に並行してOriginにアクセスをしてキャッシュに格納しようとしてしまいバックエンドの負荷が高まってしまうCache Stampedeという問題があります。

これを防ぐため、singleflight packageを利用して、同じキャッシュキーのOriginへのリクエストは同時に1つしか飛ばないようにしています。

group.Do()ではhttp.Responseをそのまま返したいところなのですが、複数のgoroutineで同じ結果が共有されてしまうので、どれか1つのgoroutineしかBodyを読めなくなってしまいます。これを回避するため、先ほど紹介したhttputil.DumpResponseでbyte列にしてから返し、それぞれの呼び出し元で復元するようにしています。

+var group singleflight.Group

 func (t *TransportWithCache) RoundTrip(req *http.Request) (*http.Response, error) {
     ctx := req.Context()
     key, err := t.cacheKey(req)
     if err != nil {
         // キャッシュキーの生成に失敗したらOriginにアクセスする
         return t.Base.RoundTrip(req)
     }
 
     cachedRes, ok, err := cache.Get(ctx, key)
     if err != nil {
         // キャッシュからの取得に失敗したらOriginにアクセスする
         return t.Base.RoundTrip(req)
     }
     if ok {
         // キャッシュヒット
         return cachedRes, nil
     }
 
-    // Origin にアクセス
-    res, err := t.Base.RoundTrip(req)
-    if err != nil {
-        return nil, err
-    }
-    // 200ならキャッシュにセットする
-    if res.StatusCode == http.StatusOK {
-         cache.Set(ctx, key, res, time.Minute)
-     }
-    return res, nil
+    maybeDumpedRes, err, _ := group.Do(key, func() (any, error) {
+        // Origin にアクセス
+        res, err := t.Base.RoundTrip(req)
+        if err != nil {
+            return nil, err
+        }
+        // 200ならキャッシュにセットする
+        if res.StatusCode == http.StatusOK {
+            cache.Set(ctx, key, res, time.Minute)
+        }
+        dumpedRes, err := httputil.DumpResponse(res, true)
+        return dumpedRes, err
+    }
+    dumpedRes := maybeDumpedRes.([]byte)
+    return http.ReadResponse(bufio.NewReader(bytes.NewReader(dumpedRes)), req)
 }

待ち受けポートをカスタムしたsshdをアプデしたらsshで繋げなくなってしまった問題のポストモーテム

事象

piyolog.hatenadiary.jp

CVE-2024-6387の対応のため、個人開発サービスを運用していてインターネットに露出しているサーバのopenssh-serverをアップデートしていた。

Google CloudのCompute Engineで立てているあるサーバのopenssh-serverをapt upgradeでアップデートしたところ、以後sshで接続することができなくなってしまった。

原因

当該サーバではSSHのポート番号を22とは異なるものに設定していた。また、Cloud Firewallではtcp 22のインバウンド通信を不許可としており、SSHの代替ポートについて許可するルールとしていた。

apt upgrade時にsshd_configを更新しますがよろしいですかという確認が表示されていて、何も考えずにYを押してしまったところ、sshd_configが書き換えられた。

Configuration file '/etc/ssh/sshd_config'
 ==> Modified (by you or by a script) since installation.
 ==> Package distributor has shipped an updated version.
   What would you like to do about it ?  Your options are:
    Y or I  : install the package maintainer's version
    N or O  : keep your currently-installed version
      D     : show the differences between the versions
      Z     : start a shell to examine the situation
 The default action is to keep your current version.
*** sshd_config (Y/I/N/O/D/Z) [default=N] ? 

その結果、待ち受けポートを標準から変えていた設定がロールバックしてしまい、22番ポートでlistenするようになった。22番はFirewallでブロックしていたため、再接続できなくなってしまった。

影響

サーバの管理者である私がsshを経由して当該サーバにアクセスすることが不可能になった。個人開発サービスの可用性に影響はない。

対応

Firewallの設定を変更し、一時的に22番ポートのインバウンド通信を許可した。その結果22番ポート経由でsshで当該サーバに接続することができた。

sshd_configファイルを元の状態に書き換え、代替ポートで接続できることを確認した。

再発防止策

  • sshdをアップデートしたあと、そのセッションを閉じないまま別に接続してみて、これまでと変わらずに接続できることを確認する
    • sshdをアップデートしても、既存の接続は古いプロセスを使用し続ける
  • 設定ファイルを書き換えるようなメッセージが表示された場合にはノールックでYを押さずに差分を確認する