バグがあっても接触確認アプリをインストールしてほしい理由

3行サマリー:

  • アプリではなくOSが接触履歴を取っている
  • 今のアプリはOSの接触履歴をONにするだけ。バグがあっても使わなければ問題ない
  • (特に東京では)今週の接触履歴が今後役に立つ可能性がある

とうとう接触確認アプリが公開されました。これで今までよりも圧倒的に効率的に、陽性者の接触者に検査を受けてもらうことができるようになるかもしれません。ワクチンが開発されるまでの間、コロナと戦うための最大の武器になるかもしれません。

www.mhlw.go.jp

しかし、Bluetooth が有効になってないと起動しない、利用規約に同意しないでアプリを終了しても同意したことになってる、などのリリース前の準備が明らかに不足してるであろう問題が報告され、炎上しています。

大前提として、これらのバグの責任はもちろんリリースした厚生労働省とその委託先の会社、そしてリリースを急がせた政府にあり、ベースとなったOSSの開発者には一切責任はありません。

ただ、普通ならリリースするべき状態ではないアプリだったとしても、特に外で人と接する機会のある人には早めにインストールして欲しいと思っています。

今回の接触確認アプリは、消費電力やプライバシーのことを考えつつ、GoogleAppleAndroidiOSに実装した接触履歴の機能を利用しています。*1 この機能を有効にするには、政府公認のアプリをインストールする必要があります。そのためにアプリをインストールしてほしいのです。

一度接触確認アプリを起動してOSの機能を有効にしたら、その後はもうアプリを起動する必要はありません。アプリではなくOSが接触履歴を取ってくれています。OSの機能さえ有効にすれば、今はアプリは使わなくても良いのです。 *2 私はリリース直後にインストールしましたが、このアプリはなんの権限も要求せず、バックグラウンドの動作時間が0で、通信量も電池消費も0です。全く動かさなくていいアプリのバグを気にする必要があるでしょうか?

将来アプリの新しいバージョンがリリースされて、バグが修正され、陽性者報告が動き始めたときに、接触履歴がOSにあれば陽性者との接触を教えてくれたり、自分が陽性になったときに接触した人にそれを(なるべくプライバシーに配慮した形で)伝えられるのです。今日接触履歴を取り始めないと、後でだれかの陽性がわかったときに今日の接触者を探すのが困難になるのです。

「アプリにバグがあったらインストールが敬遠されて逆効果じゃないか!」という意見は正論ではあるのですが、声高に問題を叫ぶことも同じくインストールを敬遠する人を増やします。 攻撃的な批判は、アプリのバグと同じくらい悪い効果があるのです。

攻撃的な批判ではなく、問題の回避方法とか、「起動できない人はアップデートを待ってね」と、インストールするモチベーションをなるべく下げない言い方で意見表明して欲しいと思います。

現在東京は一日に30人前後の新規陽性者が確認されている状況です。新型コロナは潜伏期間が長いので、今一日100人、200人が新規感染していて、それが2週間後に新規陽性者数として現れてくるかもしれないのです。そのときに陽性者報告が動き始めたとして、今週からの接触履歴があるのと無いのとでは大きな差になりえます。

このように、本来あと1週間か2週間かけて準備するべきリリースを前倒ししたことには十分な意義があったと思います。(それを説明する責任は政府にあると思いますが)

*1:すでに接触確認アプリを使ってる国で消費電力やプライバシーが問題になっているのは、GoogleAppleの実装を待たずにアプリで接触履歴を取っているからです

*2:アプリの役割である陽性者報告がまだ動いてないので、そもそもOSの機能を有効にする以外の使いみちはまだありません

ISUCON9決勝参加記 (チーム名: ようするにメガネが大好きです)

ISUCON9決勝に参加して fail しました。原因は自分の書いたバグで大分凹んでいます。ピークスコアは14991点でした。

やったこと

自分がやった分だけ。大したことはやれていません。

  • pprof, stackdriver trace の導入
  • Docker compose があったのでローカル開発できるか試してすぐやめた
  • reservation の N+1 解消 ref
  • distance_fare_master のオンメモリ化 ref
  • reservation テーブルの arrival, depature カラムに駅名ではなくIDを入れるようにした。これで getAvailableSeats で station との join が消せた。 ref

失敗したこと

fail の原因になったバグはこんなコードです。

   for {
        _, err := dbx.Exec("select 42")
        if err != nil {
            log.Println(err)
            time.Sleep(time.Second)
        }
        break
    }

接続ができなくても Sleep したあと continue してないですね。。。。これでDBより先にアプリが起動したら、 master データをDBから持ってくるコードが動かずに fail してしまいました。

cancel を bulk 化する前にいったん遅延するようにしてみたところエラーが起こり revert しました。しかしこれはどうやら別の原因だったようです。

/initialize で負荷を指定できることに気づいておらず(大ポカ)、17:20頃に教えてもらって試したら cancel がたまりすぎてエラーに。慌てて bulk を実装始めるもヌルポエラーを出してしまい、修正できないまま再起動試験に。

あと、 makki_d が train_master, seat_master をオンメモリ化してくれている間に座席のロックの粒度を下げたり、席が空いているかどうかを高速にチェックできるようにする方法を考えようとしていたのですが、全然頭がクリアに働かず頭の中をぐるぐるするだけで何もできませんでした。この時間にキャンセルの bulk 化とか落ち着いてレギュレーション読むとかできればもっと色々できたはず。

感想と反省

意味不明の print だらけでログがぐちゃぐちゃになるクソコード、プライマリキーが存在せずに駅名等が全て文字列で入っているテーブル、とにかく問題外な初期実装でした。(褒め言葉)

家庭の問題があってメンタルも睡眠時間も不足気味で実力を発揮できないばかりか致命的なミスをしてしまい本当に悔しいやら情けないやら。来年もできれば同じチームで参加して挽回したいです。

ISUCON9予選参加記 (5位通過)

mapk0y (インフラ), makki_d (アプリ) とともに「ようするにメガネが大好きです」というチーム名で参加し、2日間を通して5位のスコアで通過しました。

選択言語は Go です。ソースコードはこちらで公開しています。 GitHub - methane/isu9q

POST /buy 初期対策

CPUプロファイルを見ると login の bcrypt が重いのはすぐに目に付きます。過去にもあったのでsha1など軽いハッシュへの置き換えも考えましたが、面白くないのでこれはしなくても攻略できるようになっているはずだと出題者を信じて無視します。 最終的に login は残りの2台に分散するだけで一切手を加えずに乗り切りました。

アクセスログを見ると明らかに POST /buy が重く、 campaign を増やしたときのマッコネ (MySQL の Too many connections) やベンチマークタイムアウトエラーの主因にもなっていました。とりあえず最初はシンプルにGoの段階で排他してやります。

+var itemMutex sync.Mutex
+
+func itemLock() func() {
+   itemMutex.Lock()
+   return itemMutex.Unlock
+}
+
 func postBuy(w http.ResponseWriter, r *http.Request) {

...


+   defer itemLock()()
+
    tx := dbx.MustBegin()
 
    targetItem := Item{}
    err = tx.Get(&targetItem, "SELECT * FROM `items` WHERE `id` = ? FOR UPDATE", rb.ItemID)

外部APIエラーとの戦い

しかし外部APIサーバーが 502 Bad Gateway を大量に返すようになってしまって fail が続きます。

f:id:methane:20190910094451p:plain
502-bad-gateway

答えられないということは、障害ではなくて仕様なのでしょう。(追記: 障害だったようです。なので私達のチームはこのエラーのために多くの時間を浪費することに。) ここからAPIサーバーとの戦いが始まります。

マニュアルを読んでもレートリミットやバッチ化呼び出しなどの仕様はなく、 tcpdump を使って確認してもヘッダは削られまくっててやはりレートリミットに関するヘッダなどのヒントはありません。

この API は購入と配送に関するもので、1取引あたりの最低呼び出し回数は決まってます。とりあえず GET /transaction のなかからの呼び出しを削除してその時点の暫定1位はゲットしたものの、その後はずっとAPIの呼び出し方を工夫していました。

mapk0y が chocon という API 呼び出し用の proxy サーバーを入れてくれ、 makki_d がその対応や分散などをしてくれました。

呼び出しの並列度を下げても、すこし間隔を入れてもエラーが出ます。 Bad Gateway 以外に bad IP もあるみたいなので、API呼び出しを3台から分散して行うようにしてみたのですが効果は微妙です。

マニュアルにベンチマーク中以外に使える開発用APIサーバーが載っていたのでそこに curl でアクセスしてみて、使い方がわかったところでそれを雑に while : で回したらエラーが出ないことに気づきました。それで chocon の keep alive を切ってもらったのですが、残念ながらエラーはまだ出たので最終的に makki_d にリトライを入れてもらってゴリ押ししました。

結局502エラーの数が安定しないまま、どの対策がどれくらい効果があるのかを確認できないままでした。ベンチを回すばかりでなく、もうちょっと直接APIサーバーを色んな方法でアクセスして bad gateway や bad ip エラーの発生条件を調べられなかったのが反省点です。(ブラックボックスリバースエンジニアリング、好きな人は大好きだけど、そうじゃない人は逃げがち)

その他のチューニング

上で紹介した雑なロックは次のように書き換えて別々の商品は並行して買えるようにしました。

var itemMutex []sync.Mutex = make([]sync.Mutex, 100)

func itemLock(i int) func() {
    n := i % 100
    itemMutex[n].Lock()
    return itemMutex[n].Unlock
}
...
    defer itemLock(int(rb.ItemID))()

これでも /buy のタイムアウトが発生します。商品が売れたあとに、API呼び出しを含む長いロックを待ってた人が1人ずつ売れてしまったことを確認するようになっていたので、売れた商品をキャッシュするようにするとタイムアウトが解決しました。

そもそも売上がスコアに直結するのに同じ商品に客が集まり過ぎなので、これをバラせないかなと思って新着商品の順番をページ内でシャッフルしてみたところベンチマーカーに怒られたので諦めてしまいました。あとから考えるとここがスコアに直結するので諦めたのは早計で、 /buy でロック中の商品(APIエラーで売れない可能性がある) を sold 扱いで表示するとか、隠すとか、もうちょっと粘るべきだったと思います。

この辺は実際のアプリ開発なら自分から提案できるんですが、あくまでもブラックボックスのベンチマーカーがルールのISUCONでは提案ができないので、ベンチマーカーが何を許すかの試行錯誤を面倒がってしまいます。。。チューニングがしたいんであってブラックボックスリバースエンジニアリングがしたいんじゃないんですよね。。。

他には、 items テーブルの seller_id, buyer_id, created_at カラムにインデックスを張ったり、設定をいじったりしていました。アプリ側で一番大きい変更 (user の N+1 解決) は makki_d がやってくれました。

感想

去年の予選は上位スコアを出しつつも重いAPIの対策をおろそかにしていたために終盤スコアが乱高下し結果敗退しました。

その反省から、今年は12時台に 502 エラーが大量発生したあとは、頑張ってチューニングしてエラーで fail するという結末を避けるために穏当なチューニングだけで手堅く勧め、エラー率の低い(スコアが安定する)状態で勝ち進むことができました。

一方でAPIエラーの発生条件の解析や、商品リストをどう改造するとベンチマーカーがエラーを出すかの解析など、役割分担的に僕がするべきだったところの解析がちゃんとできていなかったのが悔やまれます。これを突破しないと実力での優勝はできないので、決勝では面倒臭がらずもっと覚悟と気合を入れていきます。

設問とインフラは良かったと思います。特に Go の初期実装が (Sinatra風の無名関数を直接ハンドラに登録するやりかたでなく) ちゃんとAPIごとに名前のある関数を用意しているのでスタックトレースやプロファイルも読みやすかったですし、ベンチマーク待ちも(17:20以降は完全に手を触れなかったのもあり) ほぼ感じませんでした。

一日目の Twitter を見てトラブルを覚悟していたのですが、初回起動もすぐ終わり、インスタンス作り直しが必要なミスもしなかったのでとても快適に競技に集中することができました。ありがとうございます。

(PEP 584) dict + dict 演算子追加について

注意:この記事は議論中の機能について紹介し自分の考えを述べるものです。 Python 3.8 で追加されるらしいよ!みたいな拡散はしないでください。

Python-ideas で dict + dict が提案され、PEPになった。

www.python.org

d3 = d1 + d2 の動作は、 d3 = {**d1, **d2}d3 = d1.copy(); d3.update(d2) と同じになる。

もともとの提案では + と | の両方が考慮されていたのだけれども、 sequence + sequence は交換法則を満たさず、 set | set は交換法則を満たすので、交換法則を満たさない dict + dict は | より + のほうが良いだろうという Guido の一言が PEP 化の直前にありこの方向になった。他には | が + よりも初心者に優しくない記号だからなんて理由もあった。ただし、Guidoはその後やっぱり | も良いかもしれないと発言しているので今後は分からない。

追加のモチベーションについて

  • seq + seq や set | set があるのに dict を結合する演算子がないのは不公平。
  • d3 = {**d1, **d2} はわかりにくい。
  • d3 = d1.copy(); d3.update(d2) は複数の statement に分かれるのが嫌。 expression で書きたい。

僕の考え

演算子を abuse するにしても、 seq + seq や set | set はそれぞれ abuse するのに妥当な演算子を選んできた。 seq + seq は、数学未満の算数で考えたとき、 concatenation は「足し算」が現れる場面の1つであろう。また seq + seq + seq と seq * 3 が同じになる。 set | set はもちろん∪の代わりに、ビットごとのOR演算子 | を採用したものだ。集合の和とビットごとの論理和の振る舞いには強い関連がある。

一方で、 dict.update() は seq + seq や set | set に比べて + や | 演算子との関連度が低い。(この関連度の低さをML上でうまく英語で説明できなくてつらい。) dict.update() は dict の key に注目したときに set | set と同じふるまいをする分、 seq + seq よりも set | set に近いふるまいだ。

言語設計は判断の積み重ねで、その中で生まれてきた一貫性は、たとえ意図せず生まれた偶然の産物であったとしても、その言語のユーザーに指針を与え、学習速度を助ける。 |が交換律を満たしていたというのも、振る舞いから適当な演算子を選んでいたというのも、最初から絶対的な指針があった上でこうなっているわけでは無いものの重要な一貫性だ。さてどっちの一貫性を壊す?

もしここで大きな決断をするのであれば、 + を「コンテナ型の連結演算子(連結時の振る舞いはコンテナ依存)」として一般化してしまうのが良いと思う。その場合は set + set も set | set のエイリアスとして追加する。

いくつかの演算子オーバーロードがある最近人気の高い言語を調査してみた結果、「コンテナ型の連結」に共通の演算子を割り当てている言語があった。 Kotlin は (| に対する演算子オーバーロードがないという事情もあるが) + を利用していて、 Scala は ++ を利用している。

破壊的変更メソッドと演算子について

この提案に賛同する人のモチベーションの一つが、 Python の update() メソッドが self を返さず、 func(d1.update(d2))(d1.copy().update(d2))のようなことができないという物がある。

この背景には、実行効率や安全さを1つの複雑な書ける事よりも優先するという設計上の決定がある。そのために Python は、半ば意図的に、いくつかの場面で簡単な処理でも1つずつ文にしないといけないという面倒さを押し付けている。

僕は、言語を使うときの何かの面倒さは、なにかのメリットを得るための引き換えだと、わりとすんなり許容するほうだ。(Goのループとか) だから Python の dict.update() も、この利用頻度なら別に文を書かないといけなくても大した問題じゃないと思っている。

一方でそういうのがとても嫌な人がいることも理解している。しかし、演算子というのはメソッドよりも言語設計上重要な要素のハズで、メソッドの安全性のための設計による面倒さを回避するために演算子を abuse しようというのは、どうも気持ち悪く感じる。d3 = d1.updated(d2) とかのメソッドを足すだけで我慢してほしい。

MySQL Connector/C の代わりに MariaDB Connector/C を使う

mysqlclientWindows 版バイナリ wheel を作るために、以前は MySQL Connector/C を使っていたのですが、しばらく問題があって利用できませんでした。

  • static link library が提供されない
  • ビルドの仕方がよくわからん。ドキュメントもない。
  • TLS と sha256_password / caching_sha2_password のために OpenSSL がいる

ちなみに、 MySQL Connector/PythonWindows 版は site-packages 直下 (つまりグローバル) に OpenSSL の dll を含めて必要なものを全部ぶちまけるようになっています。ロックだ。それと同じロックな手段をとると当然衝突します。

そこで MariaDB Connector/C の動向をウォッチしていたのですが、最近色々な条件が揃ったので、 mysqlclient の Windows 版は MariaDB Connector/C を使うようにしました。

  • static link をサポートしている
  • ビルドの仕方が(一応)ドキュメントされてる
  • TLS も sha256_password や caching_sha2_password も OpenSSL ではなく Windows API を利用しているので OpenSSL の同梱が不要。

MariaDB Connector/C は Windows 版バイナリを配布しているのですが、 caching_sha2_password などのプラグインが DLL の状態で同梱されているので、完全 static link したい場合にはちょっとあいません。

wiki に完全 static なライブラリのビルド方法を残しておいたので、必要な方は参考にしてください。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。