hnwの日記

セキュリティの話題に丸腰で踏み込んでくる人を見た

Qiita上で「ゲームでよくされるチート手法とその対策 〜アプリケーションハッキング編〜」という記事がいいね数を集めているようですが、全セクションにツッコミどころがあるような印象です。私はセキュリティ本職というわけではありませんが、素人の私から見てもひどいと思ったところだけ個別にツッコミを入れてみます。


念のため補足しておくと、誰であろうと情報発信すること自体は大変良いことです。ただ、誤りを含んだ文章がウッカリ注目されてしまうとそれを信じてしまう人も出てくるので、大人げないと思いつつツッコミを入れる次第です。

デコンパイル(逆コンパイル)

2.の詳しい解説として、C/C++で記述されたコードをコンパイルすると機械語に変換されます。これを逆コンパイルしても、逆アセンブラまでにしかなりません。そのため、この状態ではソースコードの中身を解析するのは(人間では)非常に困難なため、ネイティブコードで書いた処理というのはデコンパイルへの対策となります。


技術用語の使い方が正確でない点は見なかったフリをするとして、C/C++で記述すれば安全だというのは幻想です。攻撃者の多くは高機能な逆アセンブラ(おそらくIDA Pro)を所有しており、昔に比べると格段に処理を追いやすい環境が整っています。基本的にはネイティブバイナリからでもコードの内容は把握可能だと考えるべきでしょう。


そもそもこのセクションの前半で紹介されている「iOS Reverse Engineeringの操作手順」もARMのアセンブリをIDA Pro/Hopperで解析する話題になっており、ネイティブコードなら安全という主張とは反対の内容です。ご自身で紹介している記事なのに読んでいないのかしら?と思ってしまいますね。


話を戻すと、クライアント側バイナリの挙動は全て解析される前提でシステム全体を設計すべきです。つまり、解析されて困る内容はサーバ側で実装するのが原則論になるでしょう。


絶対に秘密にしたいロジックをユーザーに配布するバイナリに入れたい場合は、自前での対策はあきらめてアンチデバッギング機能・アンチ逆アセンブル機能を持ったセキュリティ製品を購入した方が良いでしょう。実際の現場ではバレてもクリティカルじゃないけど簡単にバレるのはイヤという程度の状況だったりするため、自前実装で頑張っているところもあれば外部ソリューションに頼っているところもあるといった印象です。

乱数調整

なお、乱数を生成する際、現在時刻を基にして、擬似乱数を生成することが多いので、特定のタイミング(時刻)を狙うとうまくいく、といった都市伝説みたいな論調はあながち間違っているわけではありません。

間違いでしょう。明示的ににひどい実装をしない限りそんなことにはなりません。


引用部からすると乱数シードとして現在時刻を利用している言語・環境が多い、という風に読み取れますが、モバイルアプリの文脈でそんな環境はないはずです。現在のOSは各種割り込みのタイミングから十分なエントロピー(乱雑さ)を蓄積し、それを利用して乱数を生成しているため、十分にセキュアだと言えます(マシンの外部から乱数列を推測することは原則不可能です)。また、各言語の乱数生成の実装はOSに依存しているはずで、これらもセキュアだと言えます。


ネット上には自前実装の乱数生成器が転がっていたりして、そうしたものは時刻を乱数シードに使っているかもしれませんが、それらはセキュリティ知識の無い人が作成した脆弱な実装です。言うまでもありませんがコピペして使うなどは言語道断です。


(上記段落は指摘があったので修正します)OSや言語によっては時刻を乱数seedに流用しており「暗号に使うには」セキュアとは呼べない実装もあるようですが、そうだとしてもマシン外部から乱数列を推測できるような実装がありふれているとは言えないように思います。コメントでも書きましたが、元記事の筆者が紹介している記事はゲームボーイでの実装で本体起動後の何秒後かを狙うことで抽選結果をコントロールできるような事例のようです。つまり、攻撃者がマシンの内部状態に介入できる状態であるわけです。モバイルゲームのサーバサイド環境とかけはなれた事例を元に、そんな攻撃がありうるという主張は非論理的であるように感じます。


もちろん、重要な抽選はサーバ側で行うべきという主張自体は正しいと思います。また、サーバ側で実装するにしても乱数シードのエントロピー確保について熟慮すること、および採用する疑似乱数の性質を把握することの2点は必須と言えるでしょう。


ちなみに、乱数シードのエントロピー不足でサーバ上の乱数生成器が攻撃対象になりうるという事例は私が以前会社の技術ブログ記事「PHPのセッションIDは暗号論的に弱い乱数生成器を使っており、セッションハイジャックの危険性がある」で紹介しました。読んで頂ければわかりますが、この手の攻撃は普通の条件ではまず成立しません。ご参考まで。

余談

本当はDBのトランザクションとロックに関してツッコミを入れようと思ったんですが、長編になりそうだったのでその他のところだけ記事にしました。もちろん私より適任の方が記事を書いてくださってもいいんですよ?(チラッ

PHPメソッドのprototypeとは何か

なんとなくPHPマニュアルを眺めていたところ、リフレクション機能に下記のようなメソッドを見つけました。

ReflectionMethod::getPrototype — メソッドのプロトタイプを (存在すれば) 取得する


http://php.net/manual/ja/reflectionmethod.getprototype.php

特定のメソッドについて、「プロトタイプ」の情報を返してくれるもののようです。しかし、この説明だけでは何の値が返ってくるのか想像がつきませんよね。本稿ではこのメソッドについて調べてみます。

「プロトタイプ」の意味

そもそもPHPでプロトタイプとは何を意味するのでしょう?PHPの文脈では耳慣れない単語のような気がします。

私も全くわからなかったのでPHPのCソースコードを眺めてみたところ、プロトタイプとは関数の型宣言の意味だとわかりました。Cの「関数プロトタイプ」と同じ使い方です。

この型宣言はインターフェースと継承の実現で利用されています*1。インターフェースを実装した場合、実装したメソッドはインターフェースと同じ個数の引数が必要で、全て同じ型でないといけません。これはまさに関数の型チェックそのものです。継承でメソッドをオーバーライドした場合も同様で、親メソッドの型と矛盾しないかどうかのチェックが走ります。

プロトタイプを確認する

では、実際にReflectionMethod::getPrototype()の動作を確認していきましょう。次のようなコードを動かしてみます。

<?php

interface Foo
{
    public function func1(int $x);
}

abstract class Bar implements Foo {
    public function func1(int $x) {
    }
    abstract public function func2(int $x, double $y);
    private function func3() {
    }
}

class Baz extends Bar {
    public function func1(int $x) {
    }
    public function func2(int $x, double $y) {
    }
    protected function func3() {
    }
}

class Baaz extends Baz {
    public function func2(int $x, double $y) {
    }
    public function func3() {
    }
}

$cl = new ReflectionClass(new Baaz());
$methods = $cl->getMethods();
foreach ($methods as $mt) {
    $proto = $mt->getPrototype();
    printf("method=%s::%s(), prototype=%s::%s() \n",
           $mt->getDeclaringClass()->getName(), $mt->getName(),
           $proto->getDeclaringClass()->getName(), $proto->getName());
}

これを実行すると次のような結果になります。

method=Baaz::func2(), prototype=Bar::func2()
method=Baaz::func3(), prototype=Baz::func3()
method=Baz::func1(), prototype=Foo::func1()

これはBaazクラスの全メソッドについて、それぞれのプロトタイプを表示したものです。

Baaz::func2のプロトタイプは抽象メソッドのBar::func2です。型チェックをするだけなら親のメソッドであるBaz::func2がプロトタイプになっていても良い気がしますが、どうやら親子関係として一番上位で定義されたものがプロトタイプになるようです。

Baaz::func3のプロトタイプは親のprotectedメソッドであるBar::func3です。親の親には同名のprivateメソッドが定義されていますが、これは子にもエクスポートされないので単に無視されています。

func1のプロトタイプはインターフェースであるFoo::func1となります。これもやはり最上位で定義されたものがプロトタイプになっています。

まとめ

PHPメソッドのプロトタイプとは型宣言のことであり、クラスやインターフェースの親子関係において最上位で定義されたメソッドが実体となります。これは主に子メソッドの型チェックに利用されます。

普段のPHPプログラミングでは全く役に立たない知識だと思いますが、PHPのCソースコードを読むときに少しだけ役立つかも知れません。

*1:他にはClosure::fromCallable()でも使われています

2017年をふりかえる

年末恒例の振り返り記事です。過去記事はこちら。

書いた

2017年ははてなダイアリーに本記事を含め18本の記事を書きました。人気があったのは下記の記事です。


Qiitaでは65本の記事を書きました。人気があったのは次の記事です。


例年と比べるとPHP以外の話題が多めで、どちらかと言えばインフラの人っぽいラインナップですね…。PHPの記事は去年よりむしろ多いくらいだったはずなんですが、イマイチ人気が出ませんでした。

参加した

勉強会発表・カンファレンス発表を計4件行ったのを含め、計11回技術系イベントに参加しました。


こうしてみると件数だけは多いのですが、会社の採用目的のイベントが多かったりして思ったより活動できていないなーという印象です。


そもそも今年は闇PHP勉強会を開催できていません。今年の9月頃からずっと開催したいと考えていたのですが、バタバタしていて身動きが取れない状態が続いてしまいました。気を取り直して来年前半のどこかで開催したいと思っています。

10年ぶりにWindows環境を手に入れた

今年の2月にThinkpad X260を入手して普段使いの環境をWindowsにしようと思ったんですが、馴染めずに12インチMacBookに戻りました。理由は複数あるんですが、一番我慢できなかったのはフォントのカッコ悪さでした。


結果として、Thinkpadリモートデスクトップ機として運用しています。家の外からでもSSH越しにアクセスできるようにしたので、どのマシンからでもWindowsに触れるようになりました。個人的には仮想環境でWindowsを持ち歩くより便利だと感じます。


また、Bash on Windows・MSYS2・Chocolateyなどを試して、最近のWindows環境の常識に触れられたのも良かったと思っています。

Google Cloud Platform (GCP) に触ってみた


今年の3月の無料枠拡大のタイミングから各GCPサービスを試しています。人気が出た記事も何本かはGCP関連のものでした。記事にしていない内容もありますが、下記のようなサービスに触れてみて一定の肌感覚が掴めたように思います。


それにしてもGoogleさん無料で遊べる範囲広すぎでは?と心配になるくらいですね。ありがたいことです。

PHP拡張php-timecopをPECLに登録した

PHPカンファレンス2017のLT「PHP拡張をPECLに登録してわかったこと」でも紹介した通り、今年の7月にphp-timecopをPECLに登録できました(PECL :: Package :: timecop)。結果としてRedHatディストリビューションの標準リポジトリに登録されるなど、利用されている方の利便性向上にも繋がっており、当初想像していたよりも大事件だと感じています。


Windows版のDLLが配布できるようになった点、またWindows上でのCIができるようになった点も個人的に長年の課題だったので嬉しい変化だと言えます。

電子工作の取り組みを継続

今年も電子工作・IoTまわりのキャッチアップは続けています。LoRaWANやWi-SUNのデバイスを触ってみたり、Amazon Dashボタンで遊んだり、シリアル通信インターフェース経由で温度センサーを操作するハードウェアを作ったりして個人的には引き出しが広がったように思います。

OSSへのcontribution

今年はMIPS32環境でGoプログラムを動かすのが自分の中のブームで、MIPS32バイナリをビルドするのに必要だったPull Requestを3本投げました。


また、php-buildには17件のPull Requestを出して16本mergeされました。自分でも頑張りすぎな気がします。


また、PHP本体へのバグレポを2本投げました。


他にもPHP-doc ML(PHP日本語マニュアルについて議論するML)に修正提案1件を含め3回投稿しました。

まとめ

今年はイマイチ何もできていないような気がしていましたが、まとめてみるとそれなりに活動できていることがわかりました。とはいえ自分の中での消化不良感は嘘ではないので、来年のモチベーションに変えていきたいと思います。


というわけで、来年もがんばるぞー

PHP 7のforeachを&つきで回すと配列の消費メモリが倍増する話

2015年12月にPHP 7.0.0がリリースされてから2年と少し経ってしまいました。今月頭には無事PHP 7.2.0がリリースされたわけですが、皆様の参加プロジェクトのPHP 7導入は進んでいるでしょうか?まだPHP 5系なんだよね、という方もPHP 7が高速だという噂くらいは聞いているかと思います。


さて、そのPHP 7.0では内部のデータ構造を大幅に変更し、PHP 5系に対する後方互換性を確保しつつ大きな性能改善を果たしたわけですが、PHP 7で相対的に不利になった機能があるのをご存じでしょうか?答えは参照です。参照を使うと通常の変数よりメモリを消費しますし、読み書きも若干遅くなります。特にforeachを&つきで回すような場合にその影響は顕著になります。


この話題は一部の人には当然の内容かと思いますが、あまり知られていないように感じたので、本稿で詳細を紹介します。

foreachの文法おさらい

まずはPHPの文法をおさらいしておきましょう。PHPのforeach文は配列をループさせる記法で、for文よりシンプルかつ高速なのでよく使われる構文です。

<?php
$sum = 0;
$arr = range(1,100);
foreach ($arr as $v) {
    $sum += $v;
}
echo $sum; // 5050


このように、foreachでは配列中の要素をループ中で取り出して利用することができます。それだけでなく、&つきで参照として取り出すことで、ループ変数を使って配列値を書き換えることもできます。

<?php
$sum = 0;
$arr = range(1,100);
foreach ($arr as &$v) {
    $v *= 2;
}
echo array_reduce($arr, function ($sum, $v) { return $sum+$v; }); // 10100


foreachを&つきで回すのは人によって好き嫌いがあるとは思いますが、なにげなく使っている人も多い機能ではないでしょうか。

foreachループ前後のメモリ使用量を観察してみる

さて、foreachループを回しただけでメモリ消費量が増えるなんてことが起こるのかどうか確認してみましょう。まずは&なしのforeachループで確認します。利用したPHPのバージョンは7.2.0です。

<?php
$array_size = 1024*1024;
$array = range(1, $array_size);
$sum = 0;
$start_memory = memory_get_usage(true);
$start_time = microtime(true);
foreach ($array as $v) {
    $sum += $v;
}
$end_time = microtime(true);
$end_memory = memory_get_usage(true);
printf("array_size = %d, %f sec, %+d bytes\n",
       $array_size, $end_time-$start_time, $end_memory-$start_memory);


上記コードでは要素数1Mの配列を生成し、foreachでループさせたときの実行時間を測定し、さらにループ開始前と終了後とのメモリ消費量の比較を行っています。これを実行すると次のような結果を得ます。

array_size = 1048576, 0.022955 sec, +0 bytes


foreach前後でメモリ消費量は変わっていません。まあ当然ですよね。では、上のプログラムのforeach文の$v&$vと書き換えて実行してみましょう。すると次のような結果になります。

array_size = 1048576, 0.052640 sec, +25165824 bytes


なんということでしょう!foreachを&つきでループするとメモリ消費量が25MBほど増えてしまいました。配列1要素あたりピッタリ24バイトの増加ということになります。PHP 7の配列は1要素あたり約32バイトを消費しますので、+75%程度のメモリ量の増加になっています*1。また、実行時間も倍以上になっています。


念のため補足しておくと、このループでは配列値を読み出しているだけで、特に配列操作をしているわけではありません。配列値を参照で受けとっただけでメモリ消費量が変わるのです。

zvalと参照の関係

上で見た不思議な現象を説明するにはPHPの内部構造を知る必要があります。PHP 7の変数の実体はCソースコードレベルではzvalと呼ばれる16bytesの構造体になります。このzvalのうち先頭8バイトが変数の値に対応します。また、残りの8バイトに型の情報やその他の情報が格納されます。たとえば$a = 123;とした場合はメモリ上で次のように格納されます。



この変数の値を別の変数に代入する場合は単にzvalの16バイトをコピーします。$b = $a;であれば次のようになります。



一方、$b = &$a;のように参照として代入する場合はzend_referenceという24バイトのデータ構造が作られ、参照を共有する変数はzend_referenceへのポインタを持つようになります。



つまり、参照として代入すると元の$aの構造ごと変わってしまうのです。foreachを&つきでループしたときに1要素あたり24バイト増加した理由も同じで、配列の全要素を$vに参照で渡すときにzend_referenceが作られたためです。読み出しているだけなのにメモリ使用量が増えるのは不思議に思えるかもしれませんが、参照の性質上仕方のないことです*2

補足:参照は常に悪いのか

上記の内容だけを見て既存ソースコードの参照を全部取り除こう、と考えた人がいるかもしれませんが、それは早計かもしれません。


本稿で議論しているのは1変数あたり24バイトの増加です。そんな程度の差を気にするくらいなら、コードの可読性を上げたり、テストカバレッジを上げたり、新機能を実装したりする方が有意義かもしれません。巨大な配列の場合には一定の影響を及ぼす可能性はありますが、それでも処理全体のパフォーマンスに与える影響はさほど大きくないように思います。


そもそもPHP 7の参照は「相対的に」不利になっただけであって、PHP 5と比べればなお互角または若干高速なくらいです。メモリ消費量の観点でも、全要素が参照になっているPHP 7の配列の方がPHP 5の通常の配列より断然コンパクトだったりします。性能の観点で見直すというのであれば、実コードでベンチマークテストを行ってからにすべきでしょう。


また、性能以外の理由で参照の善し悪しを整理する観点もあるかもしれません。これは本稿の範疇外ですが、下記ページなどはうまく論点がまとめられているように思います。

まとめ

PHP 7から参照で変数代入するとメモリ消費量が増えること、foreach文で&を使うとその影響が全要素に及ぶことを紹介しました。この結果だけをもって参照の善し悪しを議論するのは早計だと思いますが、こうした部分を入り口にPHPの内部構造に興味を持つのは良いことだと思います。


また、PHP 5の頃は配列の全要素の値を書き換えるような場合に&つきのforeachを使うのが最速でしたが、PHP 7では新たな配列を作るのとほとんど差がなくなりました。速度が理由で&をつけている人がもしいれば、PHP 7でのベンチマークテストをオススメします。


本稿を書き終わるあたりで似た内容を「PHP7はなぜ速いのか(zval編)」で書いていることに気づきましたが、少し切り口が違うので良いことにします。

*1:本稿タイトルで倍増と言っているのは25%ほど誇張です

*2:例示したコードを救うためだけの最適化は不可能ではありませんが、実装するメリットは薄そうです

Mackerelで家庭内ネットストーカーシステムを作ってみた

本エントリはMackerel Advent Calendar 2017の23日目の記事です。


自宅の無線LANの利用状況をMackerelで監視するようにしたところ、予想以上にキモい仕組みができました。たとえば、家族の誰か(正確には誰かのスマートフォン)が外出するとSlackに通知を飛ばすことができます。



同じことをしている人は多くないと思うので、その知見を紹介します。

システム概要

まずは我が家のネットワーク構成を紹介します。



インターネットに接続しているブロードバンドルータがあり、無線経由でスマートフォンやPCがぶら下がっているような、ごく普通のネットワーク構成です。唯一変わっている点は、ブロードバンドルータ上でLinuxおよびMackerelエージェントが動いていることでしょう。


このルータの詳細は本稿では省きますが*1、ザックリ言うとRaspberry Pi 3を無線LANアクセスポイント兼ブロードバンドルータとして運用している環境が一番イメージしやすいと思います。

iwコマンドの出力をMackerelで監視する

Linux無線LANアクセスポイントとして動かす場合、無線LANの利用状況がiwコマンドで確認できます。下記は私のブロードバンドルータ上での出力です*2

# iw dev wlan1 station dump
Station 00:00:5e:f0:07:87 (on wlan1)
        inactive time:  1600 ms
        rx bytes:       6216368
        rx packets:     50296
        tx bytes:       4006367
        tx packets:     30159
        tx retries:     1159
        tx failed:      2
        rx drop misc:   17
        signal:         -50 [-49, -57] dBm
        signal avg:     -50 [-49, -56] dBm
        tx bitrate:     243.0 MBit/s MCS 14 40MHz
        rx bitrate:     216.0 MBit/s MCS 13 40MHz
        expected throughput:    53.557Mbps
        authorized:     yes
        authenticated:  yes
        associated:     yes
        preamble:       long
        WMM/WME:        yes
        MFP:            no
        TDLS peer:      no
        DTIM period:    2
        beacon interval:100
        short slot time:yes
        connected time: 195122 seconds


このように、iw dev station dumpで現時点でアクティブなWi-Fiクライアントのネットワーク利用状況が取得できます。同じネットワークに5台のクライアントがぶら下がっている状態であれば同様の内容が5台分それぞれ表示されます。


今回、上記の情報を記録するMackerelプラグイン hnw/mackerel-plugin-iw を作成してみました。


以下、mackerel-plugin-iwによって得られる情報を紹介します。

Wi-Fiセッションの生死がわかる

iwコマンドでは接続中の端末のMACアドレスごとの通信状況が表示されます。つまり、MACアドレスごとのエントリの有無を1分に1回記録することで、どの端末が現在ネットワークに参加しているかがわかります。


これをグラフ化することで、自宅でいつPCを開いていたかがわかるようになりました。PCを開いている時間は常にWi-Fiセッションが維持されるので、セッションが切断されたということはPCをスリープさせたということになります。


また、特定のスマートフォンを持った人が外出したかどうかの判断にも利用できます。というのも、大半のスマートフォン端末は画面オフ時もWi-Fiセッションが切れません*3。つまり、スマートフォンWi-Fiセッションが切れたときは外出したと判断できるのです。


次のグラフは筆者がMacBookを閉じ、iPhoneを持って外出したときのものです。両者のWi-Fiセッションが同時間帯に切れていることから、9時頃に家を出たことがわかります。



ちなみに、mackerel-plugin-iwでは利便性向上のためにMACアドレスの前3桁からわかるベンダー名をラベルの先頭に追加しています*4。こうしておけばMACアドレスだけ表示するより端末の判別が容易ですし、万一見慣れない端末がネットワークに参加していればすぐに気づくことができます。

端末ごとのネットワーク転送量がわかる

iwコマンドでは送受信した累積バイト数がわかるので、これを1分ごとに記録することでネットワーク転送量をグラフ化することができます。これにより、各端末でどんな操作をしているか想像することができます。


たとえばYouTubeで動画を見ている場合には転送量のグラフは横ばいになりますが、Webページを閲覧している場合には凸凹が多いグラフになります。


他にもアプリケーションによっては特徴的なグラフになることがあります。下記のグラフは私がNintendo Switchスプラトゥーン2ナワバリバトルを6回遊んだときのものです。ナワバリバトルは同じ部屋にプレイヤーが集まるのを待ち、8人揃ったら3分間の通信対戦を行うまでが1セットになっています。実際、グラフを見ると約3分間一定量の通信を行って1分ほど通信が落ち着くという繰り返しになっているのがわかります。


電波強度から端末と親機の距離が推定できる

無線LANの電波強度は-30dBから-100dBといった値で表されます。この数字は小さくなればなるほど電波強度が弱く、親機と子機の距離が離れている可能性が高いという意味になります。


電波は人体やその他でも遮蔽されるのでそこまで正確な位置検出はできませんが、Wi-Fiルータと同じ部屋にあるか別の部屋なのか、くらいは判断ができそうです。


下記のグラフは私がWi-Fi親機のある部屋でスマートフォンを操作していて、電池残量が減ってきたので隣室でUSB充電をはじめたときのものです。両者は直線距離ではそれほど変わらないのですが、隣室まで壁を挟んでいるせいか約-40dB*5から約-60dBと電波強度が大きく減衰していることがわかります。


仕組みのキモさと対策

これまで紹介してきた情報は、同じ無線LANにぶら下がっている全端末についてグラフ化されています。自分の端末の情報を自分が把握できるだけであれば何の問題もないのですが、同じ無線LANネットワークを利用している家族の情報も取れてしまうわけですから、非常にキモい状態だといえるでしょう。本稿の最初で例示したように、家族の誰かが外出するたびに管理者に通知が飛ぶような設定もできるわけで、家族間であってもプライバシー侵害のような問題になりかねないような気もします。


一方で、このような仕組みを作れるのは我々自身が情報をまき散らしているからこそです。これらの情報が家族内でさえ知られて困るのであれば、ネットワークを分離したり常時4G回線を使ったりといった対策が必要になるでしょう。

まとめ

iwコマンドの出力をMackerelで監視することで、端末ごとの詳細なネットワーク利用状況の可視化が実現できました。また、状態変化に対してアラートを流せるようになりました。


この仕組みは自宅で利用していると非常にキモいのですが、たとえば会社所有スマートフォン端末の管理・棚卸しに使うなど、実用的な利用方法もありそうだと考えていますので、もう少しブラッシュアップしていきたいと考えています。

*1:詳細に興味がある方は「BHR-4GRVにLEDEをインストールしたメモ」をご覧下さい。

*2:MACアドレスは加工してあります

*3:一部のAndroid端末にはスリープ中にWi-Fiセッションを維持させない設定があるようですが、全iOS端末および大半のAndroid端末ではスリープ中もWi-Fiセッションが維持されるようです。また、両OSとも省電力モードのときはWi-Fiセッションの接続・切断を繰り返すようです

*4:hnw/wsoui というライブラリを自作しました

*5:現在のMackerelの仕様ではマイナスの値をグラフ化できないため正負反転して記録しています

MySQLのFLOAT型を使う理由が見つからない件

MySQLのデータ型としてFLOAT型という型があるのですが、これを採用するのは混乱の元ではないか?と感じたので、その詳細を紹介します。


そもそもこの話のきっかけは「MySQLで6桁までの小数点を丸めずに扱うならFLOAT型を使うべき理由」という記事が目に止まったことです。それなりに人気を集めている記事のようですが、私の読んだ限りではFLOAT型を使うだけの根拠が文中から読み取れず、さらに類似する一次情報や英語記事が全く見つからなかったので、真偽が怪しい情報だと感じました。


その後、MySQL上で実験したりCソースコードを読んでみたりした結果、私の得た結論は真逆のものになりました。MySQL警察の方や浮動小数点数警察の方、追試や反論など頂けると助かります。

MySQLのFLOAT型とは

MySQLのFLOAT型は原則としてIEEE754浮動小数点数単精度型(32bit)で実現されます*1。これ自体はMySQLマニュアルからも読み取れる内容です。

FLOAT および DOUBLE 型は概数値データ値を表します。MySQL は、単精度値には 4 バイトを、倍精度値には 8 バイトを使用します。


https://dev.mysql.com/doc/refman/5.6/ja/floating-point-types.html


名前としてもC言語のfloat型に対応していそうな名前ですから、これ自体に特に違和感は無いと言えるでしょう。

不思議な挙動(1):+0すると見た目の値が変わる

ところで、MySQLでFLOAT型を使うと説明しづらい結果になることがあります。実際に試してみましょう。下記実験に利用したMySQLのバージョンは5.7.20です。

mysql> CREATE TABLE a (f FLOAT, d DOUBLE);
Query OK, 0 rows affected (0.03 sec)

mysql> INSERT INTO a VALUES(0.9,0.9);
Query OK, 1 row affected (0.00 sec)

mysql> SELECT * FROM a;
+------+------+
| f    | d    |
+------+------+
|  0.9 |  0.9 |
+------+------+
1 row in set (0.00 sec)


FLOAT型とDOUBLE型のカラムを作り、両者に0.9を代入してみました。SELECTしてみると、それぞれのカラムの値は0.9と表示されます。


ところが、それぞれに0を足すと不思議な結果が返ってきます。

mysql> SELECT f+0,d+0 FROM a;
+--------------------+------+
| f+0                | d+0  |
+--------------------+------+
| 0.8999999761581421 |  0.9 |
+--------------------+------+
1 row in set (0.00 sec)


なんとFLOAT型の値の方が0.9では無くなってしまいました。これは+0したせいで値が変わったわけではなく、元々格納されていた値が表示されているだけです。「0.8999999761581421…」はIEEE754単精度浮動小数点数で0.9に一番近い数ですので、FLOAT型では単精度で値が保存されている証拠とも言えるでしょう。


一方、d+0が0.9と表示されているのはdの値が倍精度浮動小数点数で0.9に一番近い数だからです。MySQLはDOUBLE型の値を表示する際、取り得る表現の中で最短となる10進表現で表示します。このことはMySQLマニュアルにも記述があります。

dtoa ライブラリでは、次のプロパティーを使用した変換が提供されます。D は DECIMAL または文字列表現を含む値を表し、F はネイティブバイナリ (IEEE) 書式の浮動小数点数を表します。


F -> D の変換は、最大限の精度で実行され、読み取り時に F が生成されるもっとも短い文字列として D が返され、IEEE で指定されているネイティブバイナリ形式でもっとも近い値に丸められます。


https://dev.mysql.com/doc/refman/5.6/ja/type-conversion.html


そんなわけで、10進小数を格納する場合にはFLOAT型もDOUBLE型も丸め誤差を含む値になるのですが、最初に紹介したQiita記事の読者がそれを読み取れるかは疑問です。もしかするとFLOAT型のことを10進6桁までをピッタリ扱える便利な型だと勘違いしてしまう人さえいるかも知れませんが、そんなわけはありません。

不思議な挙動(2):INSERTした値がWHERE句の検索条件に使えない

先ほどのテーブルを使ってもう少し実験してみます。今度はWHERE句で浮動小数点型を持つレコードを検索してみましょう。

mysql> SELECT * FROM a WHERE f=0.9;
Empty set (0.00 sec)

mysql> SELECT * FROM a WHERE d=0.9;
+------+------+
| f    | d    |
+------+------+
|  0.9 |  0.9 |
+------+------+
1 row in set (0.00 sec)


fもdもINSERT文で0.9を入れたので、当然WHERE句の検索でも0.9が使えるだろうと考える人がいるかもしれません。ところが、実際に0.9を検索してみるとFLOAT型の方では1件もマッチしません。


この原因は、浮動小数点数絡みの演算は全てDOUBLE型で計算されるため*2です。fの値は「0.8999999761581421…」となりますが、DOUBLE型としての0.9は「0.900000000000000022…」となるため、異なる値だと判断されてしまうのです。一方で、DOUBLE型のカラムはINSERTもSELECTも全てDOUBLE型で行われるので、期待通りにレコードを取り出すことができます。


どうしてもFLOAT型の値を検索で引っかけたい場合は、欲しいFLOAT型の値をDOUBLE型にキャストしたときの浮動小数点数リテラルを渡す必要があります。実用的とは言いがたいですね。

mysql> SELECT * FROM a WHERE f=0.8999999761581421;
+------+------+
| f    | d    |
+------+------+
|  0.9 |  0.9 |
+------+------+
1 row in set (0.00 sec)


まとめると、MySQLでは多くの浮動小数点演算がDOUBLE型で行われるので、FLOAT型の利用には注意が必要です。FLOAT型からDOUBLE型へのキャストの性質にピンとこない人が利用すると混乱してしまうかもしれません。

不思議な挙動(3):同じ値に見えても別の値のことがある

さらに別の例を紹介します。テーブルbを作って2レコードをINSERTしてみました。

mysql> CREATE TABLE b (f1 FLOAT, f2 FLOAT);
Query OK, 0 rows affected (0.04 sec)

mysql> INSERT INTO b VALUES(0.9,0.8999996);
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO b VALUES(0.9,0.89999996);
Query OK, 1 row affected (0.01 sec)

mysql> SELECT * FROM b;
+------+------+
| f1   | f2   |
+------+------+
|  0.9 |  0.9 |
|  0.9 |  0.9 |
+------+------+
2 rows in set (0.00 sec)


0.9および0.9に近そうな数2種類をINSERTしてみたところ、全て0.9として表示されました。

mysql> SELECT * FROM b WHERE f1=f2;
+------+------+
| f1   | f2   |
+------+------+
|  0.9 |  0.9 |
+------+------+
1 row in set (0.00 sec)

mysql> SELECT f1+0,f2+0 FROM b;
+--------------------+--------------------+
| f1+0               | f2+0               |
+--------------------+--------------------+
| 0.8999999761581421 | 0.8999996185302734 |
| 0.8999999761581421 | 0.8999999761581421 |
+--------------------+--------------------+
2 rows in set (0.00 sec)


ところが、f1とf2が等しいレコードをSELECTしてみると1レコードしかヒットしません。「0.89999996」と「0.9」とはFLOAT型として同じ値として格納されるのですが、「0.8999996」は異なる値として解釈されることが原因です。


しかし、比較したときは異なる値であるにも関わらずSELECT * FROM bしたときの表示が同じなのは問題です。FLOAT型の値をSELECTで取り出し、取り出した値をUPDATE文で再代入した場合に元の値と変わってしまうことになりますから、混乱の原因になりかねません。


これはMySQLソースコードstrings/dtoa.c中のmy_gcvt関数のバグだと思われます。FLOAT型の値を表示する際、my_gcvt関数の内部で表示精度を決定する処理があるのですが、FLOAT型の場合はFLT_DIG桁となります。

  res= dtoa(x, 4, type == MY_GCVT_ARG_DOUBLE ? width : MY_MIN(width, FLT_DIG),
            &decpt, &sign, &end, buf, sizeof(buf));


このFLT_DIGは「C言語のfloat型で精度を失わずに表現できる10進桁数」を意味する定数で、通常6となります。しかし、この桁数ではFLOAT型として異なる数を同じ表記にしてしまい、適切とは思えません。本来的にはFLT_DIG+2とすべきで、こうすればFLOATとして異なる値を10進でも異なる表記で表示できるはずです。


これが本当にバグなのか仕様なのかはわかりませんが、混乱の元であるのは間違いないところでしょう。


また、この問題が今まで修正されていないこと自体がFLOAT型の利用リスクだと言えるかもしれません。利用者の多さで品質を担保できることが有名OSSの強みであるはずですが、逆に言うとあまり使われていない機能ほどバグは残ってしまうわけです。FLOAT型の採用を検討する場合、他の人がどれほどFLOAT型を採用しているのか?という観点も重要かと思います。


ちなみに、このような問題はDOUBLE型では発生しません。DOUBLE型の場合は、SELECTで取り出した10進表現と内部的な表現とが1:1対応しており、内部的に異なる値であれば必ず10進表現も異なっています。

まとめ

MySQLのFLOAT型の性質および問題点を指摘しました。

  • FLOAT型で小数を扱うと丸め誤差が発生するが、そのことを知らずに採用するのは危険
    • f+0などとするとDOUBLE型として評価され、誤差を含んだ値が観測できる
  • FLOAT型の値は暗黙的にDOUBLE型にキャストされることがあり、利用者がキャストの挙動を知らないと期待通りの処理を行えないことがある
  • FLOAT型をSELECTした結果と内部表現とは1:1対応していないが、これはバグだと考えられる
    • そもそもFLOAT型の利用実績が少ない疑いがあり、他の型ほどバグが出きっていない可能性もあるのではないか


唯一FLOAT型を採用するメリットとしてサイズが小さいことが挙げられますが、大抵の状況では上記の問題によるデメリットの方が大きいのではないでしょうか。


今回指摘した問題を回避するために下記のような案が考えられます。

  • DECIMAL型を使う
  • 与えられた小数を内部的には1000分率で扱うなどして、MySQL上では整数として格納する
  • 与えられた10進表現の小数を文字列として格納する


どれが最適かは状況次第だと思いますが、私個人は2番目の案が好みです。また、浮動小数点数に十分詳しい場合はDOUBLE型を採用してもよいでしょう。

*1:FLOAT(25)のように精度指定した場合は倍精度型になることもあります

*2:MySQLマニュアル上でこれに関する記述は見当たりませんが、実質的には仕様に近いように感じています

とあるPHP拡張のCI事情

PHP Advent Calendar 2017の3日目です。公開が遅くなってしまいました、ごめんなさい。


筆者はphp-timecopというPHP拡張を5年間ほどメンテナンスしています。このPHP拡張はCで書かれているのですが、Travis CIやAppVeyorなど複数のクラウドCIサービスを組み合わせてテストを回しています。本稿では各サービスをどのように利用しているか、それぞれの使い分けや特徴などを紹介していきたいと思います。

背景

本題に入るまえに、なぜPHP拡張でCIが必要か、という話を紹介します。


C言語で書かれたプログラムはポータビリティが高いような印象を持つ人が多いかもしれませんが、実際はむしろ逆で、ポータブルに書くのが難しい言語の1つです。Cではシステムコールやライブラリ関数を直接呼び出すのが普通ですが、これらは環境によって存在しないこともありますし、細かい挙動が異なることも珍しくありません。また、コンパイラごとの方言も存在します。


こうした事情から、C言語で書かれているOSSでは環境の差を埋めるためにAutoconf/Automake/Libtoolを利用するのが定番ですが、それでも未知の環境でリンクに失敗することも珍しくありませんし、環境の差を検出するためのconfigureスクリプトがバグで停止するようなことも起こりうるので、各環境でテストしておくに越したことはありません。


さらに面倒なことに、PHP拡張ではPHP本体のC APIを呼び出したり本体で定義されている構造体を操作したりすることがあるので、PHPの特定バージョンでだけ動かない、というようなバグがありえます。


要するに、PHP拡張をメンテナンスするためには、様々なOS、コンパイラPHPバージョンの組み合わせでテストをし続ける必要があるのです。ひとことで言えば地獄のような状況 ポジティブな言いかたをすると、とてもCIしがいのある題材だと言えるでしょう。

各CIサービスの特徴と実際の使い方

php-timecopで現在利用しているCIサービスは下記の4つです。


いずれのサービスも無料プランで利用しています。筆者自身がこのPHP拡張でお金を得ているわけではないのでお金を払ってまでCIする気にはならないですし、各社さんOSSへの応援の意味も込めて公開リポジトリのCIを無料で提供していると思うので、ありがたく使わせてもらっています。


以下、個別のサービスについて紹介します。

Travis CI

言わずと知れた最大手クラウドCIサービスです。

  • LinuxUbuntu)とmacOSの2つのOS上でCIできる
  • PHP 5.4から7.2までの6バージョンがビルド済み、設定で簡単に切り替えられる(記事執筆時)
  • 無料プランでは最大5並列でジョブが走る


無料プランでもかなりの大盤振る舞いなので、OSSでCIしたい場合は最初の選択肢になるでしょう。


また、PHP拡張のCIに関していうとPHPの全マイナーバージョンの最新版が提供されているのが素晴らしい点です。PHPではマイナーバージョンアップのタイミングで内部構造の大変更が入ることが珍しくない(5.3→5.4など)ので、全マイナーバージョンでテストするのは非常に重要です。それが設定を少し書くだけで簡単に対応できるのは本当に助かります。


実際、筆者はLinux環境上で7つのPHPバージョン*1のテストを走らせています。


Travis CIではビルド済みのPHPバイナリがZTS(Zend Thread Safe)有効になっているのが特徴的です。大抵の人はPHPNTS(Non Thread Safe)で利用しているはずなので、ある意味テスト環境として不適切と言えなくもありません。一方で、ZTSでだけビルドが通らないようなバグもありうるので、個人的にはテスト環境としてありがたいと感じます。

AppVeyor

Windows環境のCIが必要ならほぼ唯一の選択肢*2と言えるでしょう。

  • Windows環境でのビルドに対応
    • Visual C++も各バージョンがインストール済み
    • chocolateyやMSYS2-pacmanがインストール済みなので追加パッケージのインストールも簡単


PHPWindows版はバージョンごとに要求するVisual C++のバージョンが異なるため、自分でCI環境を準備するのは比較的面倒です。その意味で、クラウドCIサービスが利用できるのは良いですね。


php-timecopでは現在PHP 5.4、PHP5.6、PHP 7.1の3バージョンでテストを行っています。

Wercker

最近Oracleに買収されたクラウドCIサービスです。「わーかー」と読むらしいですね。CI環境を整えた当時、Dockerコンテナの扱いが一番楽そうだったので利用することにしました。


php-timecopではCentOSと32bit Ubuntuの2環境でのテストを走らせるのに利用しています。


CentOSでのテストは、specファイルによるバイナリパッケージ作成の動作確認のために行っています。通常Linuxディストリビューションごとのテストを行う必要性はあまり無いと思いますが、パッケージングのテストや、バイナリパッケージのデプロイなどの必要性があればディストリビューションの数を増やす必要があるでしょう。


一方、32bit Ubuntuでのテストは拡張自体のテスト目的です。PHPは32bit環境と64bit環境とで整数のサイズが変わるなど言語レベルで影響があるので、32bit環境でのテストには十分意味があります。

CircleCI

クラウドCIサービスの中でも有名どころの1つです。最近大幅バージョンアップしてコンテナベースになったと聞きましたが、筆者はまだ古いバージョンを利用しています。


現状ではNTS環境でのテストの意味で走らせているだけで、あまり活用できていません。

実際にCIは有用か?

PHP拡張でのCIを回してみて個人的には非常に有用だと感じています。客観的証拠のようなものは挙げにくいのですが、メリットだと思われる内容を何点か紹介します。


たとえば、新機能を実装するときなど、大変更の際はCIのおかげで特定バージョンでのバグを未然に防げることは珍しくありません。これまでの経験としてはOSやコンパイラの差で怒られることはそれほど多くなく、PHPの特定バージョンで動かないバグが多いので、まずはTravis CIで複数PHPバージョンでのテストを走らせるのがオススメです。


また、しっかりテストできているとPull Requestを受け取ったときの安心感も大きいので、その意味でもオススメです。


PHP 5時代はZTS環境で必要な「おまじない」の付け忘れにCIで気づく、ということが頻繁にありましたが、PHP 7になっておまじないが減ったので、最近はあまりそんなことはありません。


変わったところでは、Windows上でテストが失敗すると思ったらWindowsPHPのバグ(仕様?)だった、ということがありました。(参照:「PHPのsleep関数とusleep関数の挙動を調べてみた」)

まとめ

php-timecopのCIでは複数サービスを活用しており、有用だと感じているという話を紹介しました。また、テストの軸が複数あることも紹介しました。


何個もCIサービスを使うのは若干趣味的に思われるかもしれませんが、LinuxWindowsの両OSに対応するためにTravis CIとAppVeyorの2サービス併用、くらいまでは十分実用的だと思います。


PHP拡張を公開しているけどテストしてないという方はphp-timecopのリポジトリから設定ファイルをコピーすれば簡単に導入できますので、ぜひお試しください。

*1:PHP 5.4から7.2まで6バージョン、およびmasterブランチ

*2:本来ならVSTSが本命だと思うんですが、イマイチ知名度が低い気がします…