地獄! YYYYMMDDだと思っていたらYYYYMDとして扱われていた情景
たとえば 2023111
という日付が登場した時、これは 20230111
とも 20231101
とも解釈がされうるということです。
にわかには信じがたい出来事ですが太古のコードを眺めているとそういうことがあります。大変ですね。これが生きるということと言うこともできるはずです。まあ俺が書いたコードじゃないし......
というわけでひとまず被害状況としてどういう日付が影響を受けるかサクっと確認してみましょう。ふつうに手で検証してもわかる話ではありますが、今日は街に雪が降ったのと元のコードがRubyだったのでRubyで書いて確認しました。
#!/usr/bin/env ruby # coding: utf-8 require 'date' dict = {} d = Date.new(2023, 1, 1) for day in 0..364 yyyymd = (d + day).strftime('%Y%-m%-d') unless dict[yyyymd] dict[yyyymd] = [] end yyyymmdd = (d + day).strftime('%Y%m%d') dict[yyyymd].append(yyyymmdd) end for yyyymd, yyyymmdds in dict if yyyymmdds.length() >= 2 puts yyyymd => yyyymmdds end end
{"2023111"=>["20230111", "20231101"]} {"2023112"=>["20230112", "20231102"]} {"2023113"=>["20230113", "20231103"]} {"2023114"=>["20230114", "20231104"]} {"2023115"=>["20230115", "20231105"]} {"2023116"=>["20230116", "20231106"]} {"2023117"=>["20230117", "20231107"]} {"2023118"=>["20230118", "20231108"]} {"2023119"=>["20230119", "20231109"]} {"2023121"=>["20230121", "20231201"]} {"2023122"=>["20230122", "20231202"]} {"2023123"=>["20230123", "20231203"]} {"2023124"=>["20230124", "20231204"]} {"2023125"=>["20230125", "20231205"]} {"2023126"=>["20230126", "20231206"]} {"2023127"=>["20230127", "20231207"]} {"2023128"=>["20230128", "20231208"]} {"2023129"=>["20230129", "20231209"]}
1年のうち36日、つまりおよそ10%が影響を受けることがわかります。大変ですね。頑張りましょう。
[追記]
ところでこれは推察なんですけど、元コードとしては `%Y-%m-%d` と書くことで `YYYY-MM-DD` としたかったんだけど、その時に気流か気圧か何かが変化したせいで `%Y%-m%-d` になるというという不幸が発生して `YYYYMD` になったのではないか?????
— moznion (@moznion) February 27, 2023
YAPC::Kyoto 2023で話します! そしてチケットを今すぐに購入しましょう!!
YAPC::Kyoto 2023の採択トークが決まったようですね。面白そうなトークが沢山あってすごいですね。
僕のトークが採択されていない......というのはトリックで、今回は畏れ多くもゲストスピーカーとして招待されましたので、そちらの枠でお話しします。
何を話すかは実際まだ100%は固まってはいないのですが、仮決めとしては「ソフトウェアエンジニアリングサバイバルガイド: 廃墟を直す、廃墟を出る、廃墟を壊す、あるいは廃墟に暮らす、廃墟に死す」というタイトルでお話できれば良いかなと思案しているところです。
技術的負債なのではなく、貧乏だから廃墟に住み続けているのだという正しい認識が必要
— moznion (@moznion) November 29, 2022
つまりはこういうことです。どういうことだ?
それはそうとして、そんなYAPC::Kyoto 2023ですがチケット販売が今月1月の31日までとなっています。
今月中にチケットを買わないと参加ができないのです! 今、まさにこの瞬間、すぐに買いましょう!!!!!
買いましたか? 買いましたね。それでは会場でお会いいたしましょう!
京都は僕が今住んでいるシアトルから片道10時間ってところなのでギリ日帰り圏内なのが助かりますね。
GoのHTTPクライアントがAWS NLB配下にあるコンポーネントと通信するときに5-tupleが分散しないので特定のインスタンスにしか負荷分散されないという話題
Microservicesのようなものを考えた際、Goで書かれたコンポーネントがHTTP(S)を使って他のコンポーネントと通信するという場合があると思います。
その「他のコンポーネント」がAWS NLBの配下にある時、GoのHTTPクライアントがTCPコネクションを使い回す場合があり、その状況においては特定のNLB配下のインスタンスにしかリクエストを割り振らない挙動をするという話題です。
NLB
プロトコル、ソースIP、ソースポート、宛先IP、宛先ポート、そしてTCPシーケンス番号に基いてフローハッシュアルゴリズムを用いて割り振り先のインスタンスを選択するようになっています。
ref:
For TCP traffic, the load balancer selects a target using a flow hash algorithm based on the protocol, source IP address, source port, destination IP address, destination port, and TCP sequence number. The TCP connections from a client have different source ports and sequence numbers, and can be routed to different targets. Each individual TCP connection is routed to a single target for the life of the connection.
https://docs.aws.amazon.com/elasticloadbalancing/latest/network/introduction.html
つまり、同一のTCPコネクションが継続的に張られている状態 (すなわち5-tupleが同じ状況) では、その上を通るHTTPリクエストは常に同じインスタンス (ターゲット) に割り振られることになります。
GoのHTTPクライアント
GoのHTTPクライアントは基本的にHTTP Keep-Aliveするようになっています。デフォルトのHTTPクライアントのTransport
はhttp.Transport
が用いられており、MaxIdleConns
あるいはMaxIdleConnsPerHost
によってKeep-Aliveして使い回すコネクションの数をコントロールしています。
ref: https://engineers.fenrir-inc.com/entry/2018/11/12/153859
デフォルトではMaxIdleConns
は無制限 *1、かつMaxIdleConnsPerHost
は2
*2 *3 となっています。
つまりデフォルトの状況では同一ホストに対して2並列コネクションまではKeep-Aliveが有効となり、それ以上の並列リクエストについては都度コネクションを張り切りするという挙動となります。
GoのHTTPクライアントからNLB配下のコンポーネントへリクエストを送る時
以上から、並列リクエスト数が少ない時 (すなわち MaxIdleConns
あるいは MaxIdleConnsPerHost
以下の時) にTCPのコネクションがHTTP Keep-Aliveによりpersistentに保たれるため、固定のインスタンスにしかリクエストが割り振られないこととなります。たとえば高々1リクエストしか同時に捌かないような時 (つまり総リクエスト数が少ない時)、そのリクエストは常に一意なNLB配下のインスタンスに振り分けられることとなります。
なので並列リクエスト数が少ない時にNLBの配下にたくさんターゲットをぶら下げていても、大半のターゲットにはリクエストが振られず遊んでいる状況となるためまったくの無駄となります。なのでうまいこと並列リクエスト数に応じてスケールアウト・スケールインできるようになっていると良さそうですね。
(ただこれはリクエスト元のGoで書かれたコンポーネントのプロセス数にも依存するとは思っており、もし大量のプロセスがいる場合はプロセスあたりの並列リクエスト数が少なくてもうまいこと5-tupleが分散する (あるいはプロセス内のコネクションが暇すぎてKeep-Aliveが切れて都度ハンドシェイクする) のでNLB配下のターゲットについては分散するとは思いますが、そもそもリクエスト数が少ない時の大量のプロセスを上げているのはリソース過剰であるのでそこが無駄では、という見方もできる気がします。)
一方で突然リクエスト数が増えた時にスケールアウトをトリガーしたとして、果たして間に合うかどうかという話題はありますが......そうなってくると遊ぶのを織り込んで余剰なターゲットをあらかじめ上げておくしかないような気もしているところです。
それはそうとして、低並列の場合でもTCPコネクションを良い感じでラウンドロビンして5-Tupleを散らす (ついでに良い感じでTCPコネクションをプールしておく)、みたいな方法は無いものですかね。自分でTransportレイヤーを書くしか無いのでしょうか? とはいえ並列リクエスト数が少ないということは総リクエスト数も少ないというわけで、その少ないリクエストをNLB配下のターゲットにまんべんなく割り振っても特に意味はないような気はしますね......ウーン。
[追記]
ありがたい助言:
TCPコネクションをいい感じに散らすにはDefaultClientを使わないで自分でClientを作ってpoolして使う + 適当なタイミングでhttp.Request.Close=trueを設定して持続接続を切る、とかしてました
— fujiwara (@fujiwara) January 17, 2023
なるほど、TCPレベルでのpoolをTransportレイヤで持つことを当初考えていましたが、HTTP Clientレベルでpoolすれば良いという気付きをいただきました。
[追記ここまで]
*1:MaxIdleConns controls the maximum number of idle (keep-alive) connections across all hosts. Zero means no limit. https://cs.opensource.google/go/go/+/refs/tags/go1.19.5:src/net/http/transport.go;l=192
*2:MaxIdleConnsPerHost, if non-zero, controls the maximum idle (keep-alive) connections to keep per-host. If zero, DefaultMaxIdleConnsPerHost is used.
*3:https://cs.opensource.google/go/go/+/refs/tags/go1.19.5:src/net/http/transport.go;l=58;bpv=1;bpt=1
IP Routing TableのGo実装 "go-iprtb" 書いた
GoのIP Routing Tableの実装について調べてみたところ、だいたいOSのRouting Tableを操作する系のライブラリがヒットし *1、そうではなくユーザー側コードでRouting Table相当の実装・処理をしたい時に使えそうなライブラリがパッと見当たらなかったのでそれを書いたという次第です。
さて、GoでシンプルなRouting Table実装を書くのは実に簡単で、以下のように書くだけでほぼ期待通りに動くと思います。
type RouteEntry struct { Destination net.IPNet Gateway net.IP NwInterface string Metric int } routes := map[string]RouteEntry{} // ここでroutesにrouteを登録する // `target net.IP` がrouting tableに含まれているかどうかをチェックする var matched RouteEntry var everMatched bool for _, r := range routes { if r.Destination.Contains(target) { if !everMatched { matched = r everMatched = true continue } matchedMaskLen, _ := matched.Destination.Mask.Size() newRouteMaskLen, _ := r.Destination.Mask.Size() if newRouteMaskLen > matchedMaskLen { // for longest match matched = r } } } fmt.Println(matched)
この実装の問題としてはlongest matchを考慮するためにはRouting Tableのエントリをすべて線形に走査する必要があるため、Routing Table中のエントリ数が増えれば増えるほど計算量が増える、ということが挙げられます。
したがってTable中のroutes全てを1つのPrefix Treeに落としこんでやることで、対象のアドレスについてその木のパスを1回走査するだけで対象アドレスがRouting Tableにマッチしているかどうかを判断することができ、これにより計算量を減らすことができるという狙いがあります。
例えば 10.0.0.0/8
, 192.0.0.0/8
, そして 192.128.0.0/9
という3つのサブネットを考えたとき、それぞれの2進数表記は以下のようになります。表にはサブネットに属するゲートウェイについても記載しています。
Subnet | Subnet Binary | Gateway |
---|---|---|
10.0.0.0/8 | 00001010 00000000 00000000 00000000 | GW1 |
192.0.0.0/8 | 11000000 00000000 00000000 00000000 | GW2 |
192.128.0.0/9 | 11000000 10000000 00000000 00000000 | GW3 |
太字はサブネットマスクを適用した後のアドレスを表わしています。このアドレスビット列についてTrie木を構築していくと以下のようになります。
R / \ / \ 0 1 / \ 0 1 / / 0 0 / / 0 0 \ / 1 0 / / 0 0 \ / 1 0 / / GW1 [0] [0] GW2 \ [1] GW3 † R: Root Node †† [n]: Terminal Node
そして対象アドレスを2進数にしてこの木に対して最長マッチをさせていくと以下のような結果が得られます ([ ]
で囲われた箇所は終端ノードを表わしています):
Target IP | Target IP Binary | Found Gateway |
---|---|---|
10.10.10.10 | 0000101[0] 00001010 00001010 00001010 | GW1 |
192.10.10.10 | 1100000[0] 00001010 00001010 00001010 | GW2 |
192.192.10.10 | 11000000 [1]1000000 00001010 00001010 | GW3 |
127.0.0.1 | 01111111 00000000 00000000 00000001 | N/A |
今回書いたライブラリでは上記で挙げたような木構造による実装をしたところ、以下のような性能改善を実現できました。
$ go test -bench=. -benchmem goos: linux goarch: amd64 pkg: github.com/moznion/go-iprtb cpu: 12th Gen Intel(R) Core(TM) i7-1280P Benchmark_PrefixTreeAlgorithm-20 18535410 81.68 ns/op 64 B/op 1 allocs/op Benchmark_LinearSearch-20 6148155 192.1 ns/op 96 B/op 1 allocs/op PASS ok github.com/moznion/go-iprtb 2.968s
速い! 良かったですね。
なお他にもroute消去時の枝刈りなど細々としたパフォーマンスのチューニングが入っていたりもします。
ぜひご利用ください。
CのShared LibraryにしたGoのコードで簡単にダングリングポインタを発生させる
例えば以下のようなGoのコードを書き、
package main import "C" import ( "fmt" ) //export DoSomething func DoSomething(cstr *C.char) { fmt.Printf("Go: %s\n", C.GoString(cstr)) } func main() { }
go build -buildmode c-shared -o libdosomething.so ./
としてCのShared Libraryを生成して、以下のようにCのプログラムに組み込みます。
#include <stdio.h> #include <stdlib.h> #include "libdosomething.h" int main() { char *str = (char *)malloc(sizeof(char) * 4); str[0]='f'; str[1]='o'; str[2]='o'; str[3]='\0'; printf("C: %s\n", str); DoSomething(str); free(str); return 0; }
そして gcc -L . main.c -ldosomething
とこのCのコードをコンパイルし、LD_LIBRARY_PATH=. ./a.out
のように実行すると
C: foo Go: foo
という風に、もちろんこのコードは期待通りに動作します。
さて、Go側の DoSomething()
の処理をgoroutineを使うように変更してみましょう。
package main import "C" import ( "fmt" "time" ) //export DoSomething func DoSomething(cstr *C.char) { fmt.Println("Go: DoSomething is called") go func() { time.Sleep(3 * time.Second) fmt.Printf("Go: %s\n", C.GoString(cstr)) }() } func main() { }
そして説明のためにCのコードも以下のようにします。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include "libdosomething.h" int main() { char *str = (char *)malloc(sizeof(char) * 4); str[0]='f'; str[1]='o'; str[2]='o'; str[3]='\0'; printf("C: %s\n", str); DoSomething(str); free(str); printf("C: C string has been freed and it's sleeping...\n"); sleep(5); return 0; }
そして先程と同じようにCのプログラムをコンパイルして実行すると、
C: foo Go: DoSomething is called C: C string has been freed and it's sleeping... Go: 3/_
という風に、めでたくGoの C.GoString(cstr)
がダングリングポインタを参照したおかげでおかしな文字列が出力されました。
理由としてはおわかりのように単純で、goroutineの中でsleep (これは何らかの処理を模しています) している最中にGoの関数の引数として渡されたCの char *str
がCの側で解放されてしまったため、いざsleepから目覚めてgoroutineの中で C.GoString(cstr)
を実行するとダングリングポインタを参照してしまう、ということになります。なまじ変数をGoの世界に引き込んだので油断してしまい、Goの関数に渡された時点でGo世界にallocateされてcopyされているだろうとタカをくくっていたところそんなはずは無く、そりゃ *C.char
なんだから明らかにそうなんですが、まあ普通にpointerでしたね......という感じです。
これを避けるためには以下のように同期的に (すなわちgoroutineの外で) Goの文字列にしてしまえば良いでしょう。goroutineの引数としてキャプチャしてしまっても良いと思います。
func DoSomething(cstr *C.char) { gostr := C.GoString(cstr) go func() { time.Sleep(3 * time.Second) fmt.Printf("Go %s\n", gostr) }() }
こうして見ると原因は単純ですし、この例のようにsleepを使った露骨なコードだとわかりやすいのですが、実際のコードでこういう事が起こるとなかなかわかりにくいですね。ハマりました。疲れますね。
ts-dynamodb-attributes-transformer - TypeScriptオブジェクト向けDynamoDB Attributes変換器
TypeScriptのオブジェクトをAmazon DynamoDB Attributes (正確には aws-sdk-js-v3 がサポートする Record<string, AttributeValue>
) に自動変換するTypeScript Transformer Pluginであるts-dynamodb-attributes-transformerをリリースしました。
npmからもご利用いただけます。
これはTypeScript Compiler APIを使ったCode Transformer (コード変換器) です。ざっくり言うと、コンパイル時にそのASTを見つつそこでコード変換を挿し込めるという面白いやつです。特定の言語におけるマクロに似ている感じがしますね。
ts-dynamodb-attributes-transformerはどういうTransformerなのかというと、例えば以下のようなTypeScriptコードがあった時、
import { AttributeValue } from '@aws-sdk/client-dynamodb'; import { dynamodbRecord } from '@moznion/ts-dynamodb-attributes-transformer'; interface User { readonly id: number; readonly name: string; readonly tags: Map<string, string>; } const record: Record<keyof User, AttributeValue> = dynamodbRecord<User>({ id: 12345, name: 'John Doe', tags: new Map<string, string>([ ['foo', 'bar'], ['buz', 'qux'], ]), });
このTransformerは dynamodbRecord<T>(obj: T)
(つまりこの場合は dynamodbRecord<User>(...)
) を検出して、型パラメーター T
(今回は User
) の定義を読み取り、そして引数に与えられたオブジェクトの内容に従って以下のようなDynamoDB Attributesのコードに変換します。
const record = function (arg) { return { id: { N: arg.id.toString() }, name: { S: arg.name }, tags: { M: function () { var m; m = {} for (const kv of arg.tags) { m[kv[0]] = { S: kv[1] } } return m; }() } }; }({ id: 12345, name: 'John Doe', tags: new Map([ ['foo', 'bar'], ['buz', 'qux'], ]), });
実挙動としては dynamodbRecord<T>(obj: T)
の関数呼び出しをごっそりDynamoDB Attributes (の生成関数) に置き換えるという動きをします。
これはTypeScriptのTransformer Pluginのため、実際の環境で利用するためにはそのTransformerをTypeScriptコンパイル時にうまくこのTransformerをフックしてあげる必要があります。現時点では ttypescript *1なんかを利用するのが手っ取り早いと思います。詳しくは使い方をご参照ください。
TypeScript Compiler APIを使ったTransformerにした利点としては、対象オブジェクトをその定義に従って自動的にDynamoDB Attributesにできるというのはもちろんのこと、コンパイル時に静的にコード変換されて解決されるため「ランタイムで動的にリフレクションなりなんなりをして頑張ってDynamoDB Attributes Objectを組み立てる」ということをしなくても良くなりパフォーマンスの向上が狙えるというのがあります。この高パフォーマンスが狙いでした。良かったですね。*2
[追記]
ベンチマークを類似のライブラリであるkayomarz/dynamodb-data-typesに対して取ってみたところ以下のような結果となりました。
node version: v16.17.0 dynamodb-data-types marshalling x 3,475,450 ops/sec ±0.45% (96 runs sampled) ts-dynamodb-attributes-transformer marshalling x 13,405,409 ops/sec ±0.43% (91 runs sampled) Fastest is ts-dynamodb-attributes-transformer marshalling
おおよそ3.85倍の性能向上が認められます。
[追記ここまで]
TypeScript Compiler APIを使ってTransformerを書いたのははじめてだったので、何かおかしな点などありましたらご指摘いただけるとうれしいです。
TypeScript Compiler API Transformerについては、index.d.ts
を手で書いてユーザーに提供し、ユーザーはそのd.tsの中で定義されている関数を使ってコードを書き、Transformerはその関数呼び出しに対してコード変換を実行することでユーザー目線では単なる関数呼び出しのように使える一方で裏側では関数のSignatureに則ったコードに変換されている (つまり、無論実際には違うのですがイメージとしては「関数呼び出しが予めコンパイル時に行なわれている」感じ)、というのは個人的に面白いなと思いました。
実際に現場で使いはじめておりますが便利な感じがしています。ぜひご利用ください。
docker composeでワンショットタスクを実行する
TL;DR
docker compose up --abort-on-container-exit
[追記]
docker compose run だとredis serviceがstopしないからダメ的な話?
— r7kamura (@r7kamura) 2022年9月19日
docker compose run --rm ${service_name}
で良かった......
[追記ここまで]
例えばなんらかのテストを実行する時、テスト用にDB等のストレージコンポーネントを用意してそいつに対し読み書きすることでend to endのテストを模擬しているというようなことがあると思います。
かつてはテストケースごとにデータベースプロセスを上げそれに対してread/writeを実行、ということもよくしていましたが *1、最近ではテスト起動時にストレージコンテナを立ち上げてそれを読み書きしている事も多くなってきました。
さて、そのような時に「テスト起動するにあたってはコンポーネントAとコンポーネントBを事前にマシン上で立ち上げておいてください」みたいなのはいかにも面倒くさいので、こういう時に便利なツールとしてパッと思い浮ぶのはdocker composeでしょう。
docker compose自体については本記事では説明しませんが、本記事ではそういった「ストレージのような永続的なライフサイクルを持つコンテナ」と「テストランナーのような一時的なライフサイクルのコンテナ」を同時に起動させ、テストランナーの実行が終了したらすべて店仕舞いさせるというワンショットタスクをdocker composeで実行する方法について記します。
version: '3.9' services: redis: image: redis:7.0.4 container_name: redis ports: - "6379:6379" network_mode: host app-test: image: app-test-env:latest container_name: app-test command: npm test volumes: - ./:/app working_dir: /app network_mode: host depends_on: - redis
上記の docker-compose.yml
では redis
というRedisコンテナと、app-test
というテストランナーコンテナを同時に実行するという定義をしています。
まずapp-test
はテストの実行にあたってRedisを利用するので depends_on
で redis
を指定することでredisコンテナの起動を先に行うようにします。
なお、これはあくまでredisコンテナの起動を先に行うというだけで「Redisが6379番ポートで実際にlistenしてくれるまで待つ」ということをしてくれるわけではありません。なのでドキュメントにも「実際に使えるようになるまで待つ必要がある場合は適切な方法でやるように」と書かれていますので注意しましょう *2: https://docs.docker.com/compose/startup-order/
ネットワークの設定は適宜書き換えてください。この例では簡単のためにredisコンテナの6379ポートをホストの6379ポートに公開し、redisとapp-testの両方のnetwork_mode
をhostモードにすることでホストネットワークを介して6379ポートを通じて通信できるようにしてあります。
と、ひとまずこのようにしておくと、docker compose up
を実行することでテストの実行は可能となります。
しかし、テストランナーの実行が終了してもredisコンテナの実行は継続するため明示的にSIGINT等を送ってあげないとこのdocker compose upは終了しなくなります。healthcheckなどを適切に設定するとこのへん上手くやれるはずですが、ワンショットのタスクにそこまでやるのも凝りすぎでは?
ということで、docker compose up --abort-on-container-exit
というふうに --abort-on-container-exit
を付与して実行すると、いずれかのコンテナがexitした時にそのexit codeを引き継いでdocker composeを終了してくれるようになります。
--abort-on-container-exit Stops all containers if any container was stopped. Incompatible with -d
[追記]
冒頭にも書いたように、こうしておいて docker compose run --rm app-test
と実行すると良いです。
[追記ここまで]
良かった良かった、これで簡単にワンショットのタスクをdocker composeで実行できましたね。
ちなみに、exitしたら全体をabortさせたいコンテナを PID=1
にすればexitした際にすべてを終わらせてくれるのではないか、と思って app-test
に対し init: true
を指定してみましたがこれは効きませんでした。
小ネタ
docker compose runの時はそのserviceのログしか出てこないので問題無いので大丈夫なんですが (つまり何も問題が無い)、docker compose upの場合はすべてのサービスのログが流れてきます。その際、今回のようなケースだとRedisのログが見れてもあまりうれしいことは無いので抑制したい。というわけで
... redis: image: redis:7.0.4 logging: driver: none ...
このようにlogging driverを none
に設定するのですがこれは期待通りに動きません。
詳しくはこのissueに書かれているのですが、docker compose upのログは実行中のコンテナに実際にattachしたものが表示されるのであって、logging driverの設定とは別とのことです。
つまり、ここでredisコンテナに対してnoneを設定すると、docker compose upのログには表示される一方、logging driverを使っているロガー、例えば docker logs ${container_id}
等には表示されなくなるという挙動をするようです。
なので、起動オプション --attach
でログを見たいコンテナを指定すると、docker compose upのログにはそれだけが表示されるようになります。今回の例だと --attach app-test
などとすると良いでしょう。
*1:e.g. Test::Mysqld
*2:かつて似たようなことをブログに書いていたことを思い出しました: 相手のサーバにHTTP(S)で接続できるかどうかを確認するときにリトライしながらやりたいんですけどって時 - その手の平は尻もつかめるさ