hnwの日記

PHPの連想配列は常にin_arrayより速いのか

プログラムを書いていると、入力値が辞書に含まれているかを調べたいようなことがあります。たとえば、ユーザーに都道府県名を入力させて、それが正しい都道府県名であるかどうかを調べたい、というようなことがあるかもしれません。

このような内容をPHPで書く際、キーに都道府県名を持つような連想配列を作る習慣がある人は多いはずです。これは典型的な連想配列の使い方といえるでしょう。

<?php
$prefs = array(
    "北海道" => true,
    "青森" => true,
    // ...
    "沖縄" => true,
);

if (isset($prefs[$input])) {
    // 都道府県名が正しい時の処理
}

一方で、in_array関数を使うやり方も考えられます。

<?php
$prefs = array(
    "北海道",
    "青森",
    // ...
    "沖縄",
);

if (in_array($input, $prefs)) {
    // 都道府県名が正しい時の処理
}


突然ですが、クイズです。この2つのうち、どちらの方が良いコードでしょうか?

連想配列から特定のキーを持つ要素にアクセスするコストはO(1)であるのに対し、配列から特定の要素を探すコストはO(N)ですから、一般論としては連想配列を使う方が好ましいと言えるでしょう*1。しかし、計算量の議論はNが大きい場合には正しいですが、Nが小さい範囲では係数や定数項など他の要素の影響も無視できないはずです。常に前者の方が良いコードであると言えるだけの材料を我々は持っているのでしょうか。

そこで、今回PHP 5.6.30およびPHP 7.1.5を用いて、配列サイズとアクセス回数を変えながら両者の速度を測定してみました。本稿ではその結果を紹介します。

PHP 5系では連想配列アクセスの方が速い


今回、配列と連想配列とで、辞書データの構築時間と検索時間の和を比較してみました。というのも、一定規模の配列になると構築にかかる時間も無視できないため、たとえ検索が遅くても構築が速い方がトータルでは有利になる可能性があるからです。

早速ですが、PHP 5.6.30での実験結果のグラフを示します。両対数グラフなので見方に注意してください。

配列サイズというのは辞書に含まれる単語数です。また、アクセス回数というのはその辞書に対する検索の回数です。上記グラフではアクセス回数を固定して配列サイズを倍々で増やしていく、ということをしました。

これを見ると、アクセス回数が8回ある状況であれば連想配列の方が1.2から1.5倍程度は速いと言えそうです。また、アクセス回数128回になると差が絶望的に開きます。検索回数が多い場合は(当然ですが)検索の計算量の差が支配的であることがわかります。

連想配列の方がデータの構築に時間がかかるためアクセス回数2回では互角程度になりますが、互角であれば連想配列を使った方が事故は少なそうです。

PHP 7系では配列アクセスに逆転されることがある

面白いことに、PHP 7系だと状況が変わってきます。PHP 7.1.5, アクセス回数8回のグラフを見てみましょう。

配列サイズが小さいうちは連想配列有利だったのが、配列サイズ2048あたりで配列が逆転し、サイズ65536あたりで連想配列が再逆転する、というような結果になっています。何が起こったのでしょうか。

配列サイズが小さいうちは配列と連想配列とで構築コストに大きな差はありません。この段階で差になってくるのは、in_array($key, $array)が関数呼び出しであるのに対し、isset($array[$key])はopcodeにコンパイルされて関数呼び出しにならないということです。PHPの関数呼び出しは他のopcode実行に比べるとコストが高いので、この差で連想配列の方が速くなっているのです。

配列サイズが中程度になってくると、配列と連想配列の構築コストの差が支配的になってきます。PHP 7からは真の配列と言うべきデータ構造が導入されており*2連想配列と比べて簡潔なデータ構造で済むようになりました。このため、同じサイズであれば連想配列より配列の方が構築コストが低く、PHP 5のときは見られなかった逆転現象が起こるというわけです。

さらに配列サイズが大きくなってくるとO(N)とO(1)というアクセス時の計算量の差が効いてくるので、ふたたび連想配列の方が有利になります。

このように、アクセス回数8付近では複雑な状況が見られるわけです。一方、アクセス回数32であれば基本的には連想配列の方が有利となります。

また、アクセス回数8192回では絶望的な差がついてきます。

このように、アクセス回数が大きくなってくると予想通り連想配列の方が有利だということがわかります。

一方、アクセス回数が少ない場合に限れば配列を採用する可能性もゼロではないと言えそうです。とはいえ、プログラムの改修などでアクセス回数が増える可能性を考えると基本的には連想配列を採用しておく方が無難でしょう。

この文章をどう読むかについての補足(2017/5/25追記)

一般論として、普段のコーディングにおいてマイクロベンチの結果を参考にしすぎるのは良い習慣ではありません。たとえば、echoとprintfとでどちらが速い、みたいな内容に引っ張られて書くコードを変える必要はないでしょう。もしそれがボトルネックになっていることが判明したら、判明した後で変えれば良いはずです。

その一方で、マイクロベンチの速度差がなぜ生まれたのか、その原因を調べることで何らかの新しい知見を得ることもあるはずです。本稿で一番お伝えしたかったのは、その面白さについてでした。

加えて、今回は計算量の話題やオーダーの違いの怖さ、といった部分も盛り込んだわけですが、このあたりは人によって感覚が異なるのかもしれません。私個人の意見としては計算量の観点で不利な実装を採用する際は常に理由が必要だと思っており*3、本稿もそのような前提で書かれていますが、異なる意見があってもおかしくは無いと思います。

まとめ

PHP連想配列と配列のin_array()とで、どちらが有利かを検討してみました。

  • PHP 5.6では連想配列が有利
  • PHP 7では「真の配列」のおかげで配列が有利な状況もありうる
    • アクセス回数小、配列サイズ中程度の場合に限る
    • 配列の方が検索コストは高いが、構築コストが低いため
  • 現実には配列が正解という状況は少ないはず
    • アクセス回数と配列サイズの両方が極端に少ない場合は配列の方が読みやすいかも

連想配列の方が有利だというのは特に驚きのない結論ですが、PHP 7の「真の配列」による性能向上が垣間見えたのは面白いと感じました。

ネイティブコードにコンパイルするような言語であれば、辞書サイズが小さいときは連想配列よりも配列が正解という状況もありうると思うんですが、PHPの場合はin_array()が関数呼び出しであることのペナルティが大きく、そのような状況は確認できませんでした。

追試用資料

筆者の手元のマシンはCore M採用のノートマシンであり、Turbo Boostのせいで結果が安定しなかったりするので、結果の信用性は保証できません。追試したいと思われた方は下記URLをご確認ください。

*1:たとえば入力値が辞書に存在しないことを示す場合に、配列だと全要素とN回の比較が発生しますが、連想配列だとハッシュ値が一致する要素(平均では1要素)と比較すれば良いので、計算の効率が格段に違うことになります

*2:参考資料:「PHP7で変わること ——言語仕様とエンジンの改善ポイント

*3:逆にechoとprintfはオーダーが同じなのでどちらでも良いと思っています

PHPのシンボルテーブルを覗いてみる

PHPのシンボルテーブルというのはC実装のレベルの用語で、その時点で有効な変数テーブルのことを指します。つまり、グローバルスコープならグローバル変数を管理する変数テーブル、関数スコープならローカル変数を管理する変数テーブルの意味になります。


今回、自作エクステンションhnw/php-arraydumperでシンボルテーブルをvar_dump()するだけの関数symbol_table_dump()を実装してみました。下記のように使います。

<?php
function foo($a = 1) {
    $b = 2
    symbol_table_dump();
}
foo("bar");


出力は次のようになります。

array(2) {
 ["a"]=>
 string(3) "bar"
 ["b"]=>
 int(2)
}


このシンボルテーブルは$$varのようなイカれた変数アクセスの際に参照する必要がありますが、そうでも無い限り不要です。通常のローカル変数はコンパイル時に全部解決できてしまうので、通常の変数アクセスのみであれば実行時にシンボルテーブルへのアクセスは発生しないのです。


実際、PHP 5.3以降ではローカル変数に対応するシンボルテーブルは必要になるまで作られません。$$varのような変数アクセスがあったタイミングで内部的にzend_rebuild_symbol_table()が呼び出され、そのスコープに対応するシンボルテーブルが構築されます。


逆に言うと、$$varのような変数アクセスを多用するのは性能面でペナルティがあると言えるかもしれません。もちろん致命的な性能差が出るほどではないでしょうし、そもそもそんな変数アクセスをしたい状況が珍しいとは思いますが。

Windows Subsystem for Linux上でphp-fpmを動かしてみた

最近のWindows 10にはLinuxバイナリをそのまま動かすような仕組みが導入されています。これは Windows Subsystem for Linux (WSL) とか Bash on Ubuntu on Windows (BoW) などと呼ばれているもので、VM実行でなくWindowsネイティブでLinuxを動かすという意欲的な取り組みです。


この環境で最新版のPHPソースコードからビルドし、アプリケーションサーバとして動作させてみました。


本稿執筆時点(2017年5月)ではWSL自体がまだ不安定な印象ですが、nginx+php-fpmを動作させることができました。以下はWindowslocalhost 80番ポートでPHPが動作している証拠画像です。「System」欄のunameの表示にMicrosoftという文字列が入っているのがオシャレですね。



以下、WSL上でnginx+php-fpmを動かすまでの手順を紹介します。

WSLのセットアップ

Bash on Ubuntu on Windows - Installation Guide」に従ってセットアップします。

PHPのビルド

まずはWSL上でPHPをビルドしてみましょう。


コンパイル済みバイナリをaptでインストールしてもいいのですが、普通にビルドできる程度にWSLが安定してるのかな?という興味から自前ビルドしてみました。


まずは必要パッケージをインストールします。

$ sudo apt update
$ sudo apt install build-essential libxml2-dev zlib1g-dev libcurl4-openssl-dev \
    libjpeg62-dev libpng12-dev libmcrypt-dev libreadline-dev libtidy-dev \
    libxslt1-dev libssl-dev libbz2-dev git autoconf


今回はphpenv+php-buildでPHPをビルドします。

$ curl -L https://raw.github.com/CHH/phpenv/master/bin/phpenv-install.sh | bash
$ mkdir $HOME/.phpenv/plugins
$ cd $HOME/.phpenv/plugins
$ git clone https://github.com/php-build/php-build.git


下記の内容を .bashrc追記してシェルを再起動します。

PATH=$HOME/.phpenv/bin:$PATH
eval "$(phpenv init -)"


あとはphpenv経由でPHPをビルドするだけです。

$ PHP_BUILD_EXTRA_MAKE_ARGUMENTS=-j4 phpenv install 7.1.4


ビルドにはかなり時間がかかるので注意してください。筆者の手元のマシン(Thinkpad X260、Intel Core i7 2.5GHz)で30分以上かかりました。同じビルドが12インチMacBook(Early 2016、Intel Core m5 1.2GHz)で10分を切ることを考えると、まだお仕事で使えるレベルではない印象です。今後のパフォーマンスチューニングに期待しましょう。


ちょっと遅いことを除けば、ビルド自体は正常に終了します。ここまでは普通のLinuxと大差ありません。

nginx+php-fpmの設定

つぎに、nginxをaptでインストールします。

$ sudo apt install nginx


設定ファイル /etc/nginx/sites-enabled/default の設定のうち、下記部分のコメントアウトを外して有効化します。

        # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
        #
        location ~ \.php$ {
                include snippets/fastcgi-php.conf;
                fastcgi_pass 127.0.0.1:9000;
        }


nginxを再起動します。

$ sudo service nginx restart


php-fpmの方はデフォルトの設定ファイルをコピーしてそのまま使います。

$ cd $HOME/.phpenv/versions/7.1.4/etc/
$ cp php-fpm.conf.default php-fpm.conf
$ cp php-fpm.d/www.conf.default php-fpm.d/www.conf


下記コマンドでphp-fpmを起動します。

$ $HOME/.phpenv/versions/7.1.4/sbin/php-fpm


ここで /var/www/phpinfo.php などを設置すれば、nginx経由でphp-fpmを利用することができます。


ただし、この設定だと1秒に1回下記のエラーが出続けます。getsockopt(2) の実装がまだ不完全のようですね。

[01-May-2017 20:06:06] ERROR: failed to retrieve TCP_INFO for socket: Protocol not available (92)
[01-May-2017 20:06:07] ERROR: failed to retrieve TCP_INFO for socket: Protocol not available (92)
[01-May-2017 20:06:08] ERROR: failed to retrieve TCP_INFO for socket: Protocol not available (92)


TCPでのプロセス間通信に問題があるならunix domain socketにすればいいじゃない、と考えてunix domain socketも試してみたのですが、phpinfo() の結果が途中で切れてしまうようです。こちらも実装が不完全なようで、16KB超のデータがうまく扱えないとか、そんな制限がありそうな挙動に見えました。TCPの方がまだ安定している状況だといえるでしょう。

まとめ

WSLはまだbeta版なので過度の期待をする方が悪いと言えばそうなのですが、まだ性能が出なかったり未実装のシステムコールもあったりで、お仕事で使えるようになる日は遠いかな、という印象を持ちました。


とはいえ、長期的に期待できる技術なのは間違いないところでしょう。さらなる安定と正式リリースが待ち遠しいですね。

アメリカで何年の4月1日がサマータイムだったか調べてみた

日付関連のテストケースを書いていたら、ロサンゼルスの1970年4月1日0時はサマータイムではないけれど、2012年4月1日0時はサマータイムであることに気づきました。何かの間違いじゃないかと思って改めて調べてみたところ、ロサンゼルスで4月1日がサマータイムだった年は以下の5つの時期に含まれることがわかりました。

  • 1918-1919年
  • 1942-1945年(War Time)
  • 1948年
  • 1974-1975年
  • 2007年以降、現在まで


このうち、1番目は第一次世界大戦中に制定された「標準時間法」 (Standard Time Act)によるものです。アメリカでは初のサマータイム導入でしたが、不評のため2年で廃止されたとか(おそらく終戦の影響もあったでしょう)。


2番目は第二次世界大戦時の省エネ対策として通年の「War Time」が実施されたもので、1942年2月9日から1945年9月30日まで継続的に運用されました。


3番目は1948年にカリフォルニアで電力危機があり、その対策でカリフォルニア州のみ3月14日から通年のサマータイムが実施されたようです。このころ各州バラバラでサマータイムを実施していたようですが、開始時期は大半が4月でした。


4番目は第一次オイルショック(1973年)によるもの。1967年から全米でサマータイムが実施されていますが、この開始時期は4月第1週からでした。しかし、オイルショックの影響により1974年は1月6日から、1975年は2月23日からサマータイム開始だったとのこと。


5番目は「包括エネルギー政策法」(Energy Policy Act of 2005)が2007年から施行され、サマータイム開始が4月第1週から3月第2週にズレたことによるもの。


サマータイムについて調べていたはずが、歴史の教科書に載っているような出来事が関係してくるのは面白いですね。

上のリストを作る方法

上記リストは以下のPHPスクリプトを使って求めました。

<?php
for ($i = 1900; $i <= 2017; $i++) {
    $dt = new DateTime("$i-04-01 00:00:00",
                       new DateTimezone("America/Los_Angeles"));
    var_dump($dt->format("c T"));
}


このスクリプトの出力を下記のようにgrepすれば標準時でないもの(≒夏時間)が抜き出せます。

$ php dst-investigate.php | grep -v ST
string(29) "1918-04-01T00:00:00-07:00 PDT"
string(29) "1919-04-01T00:00:00-07:00 PDT"
string(29) "1942-04-01T00:00:00-07:00 PWT"
string(29) "1943-04-01T00:00:00-07:00 PWT"
string(29) "1944-04-01T00:00:00-07:00 PWT"
string(29) "1945-04-01T00:00:00-07:00 PWT"
string(29) "1948-04-01T00:00:00-07:00 PDT"
string(29) "1974-04-01T00:00:00-07:00 PDT"
string(29) "1975-04-01T00:00:00-07:00 PDT"
string(29) "2007-04-01T00:00:00-07:00 PDT"
string(29) "2008-04-01T00:00:00-07:00 PDT"
string(29) "2009-04-01T00:00:00-07:00 PDT"
string(29) "2010-04-01T00:00:00-07:00 PDT"
string(29) "2011-04-01T00:00:00-07:00 PDT"
string(29) "2012-04-01T00:00:00-07:00 PDT"
string(29) "2013-04-01T00:00:00-07:00 PDT"
string(29) "2014-04-01T00:00:00-07:00 PDT"
string(29) "2015-04-01T00:00:00-07:00 PDT"
string(29) "2016-04-01T00:00:00-07:00 PDT"
string(29) "2017-04-01T00:00:00-07:00 PDT"


上のように、タイムゾーン名がPDT(Pacific Daylight-saving Time、太平洋夏時間)やPWT(Pacific War Time、太平洋戦争時間)などと表示されているのがわかります。


PHPの日付関数はTime Zone Databaseを元にしているので、他の言語でも同じ結果を得る方法があると思います。

感想など

調べてみると、省エネ対策としてカジュアルにサマータイムを実施している歴史がわかって面白いですね。サマータイムが身近でない日本人の感覚だと「本当に省エネ効果あるの?」という気もしちゃいますけど、きっと効果があるからやってるんでしょう。


また、州ごとに夏時間の適用状況が違うのもアメリカならではです。上のスクリプトを America/Detroit とか America/Phoenix などで試すと全然違う結果になって面白いです。

PHP 7.1.3で時刻の差を取ると時々1マイクロ秒ズレる

本日はエイプリルフールなので、ウソでも本当でも誰も困らないPHPのバグの話をします。


PHP 7.1.0からPHPのDateTimeクラスでマイクロ秒の扱いを強化しているようで、挙動やコードの変更がチラホラ見受けられます。(参考:「PHP 7.1からDateTimeが現在時刻のマイクロ秒まで見るようになった - Qiita」)


時刻と時刻の差分を扱うDateIntervalクラスでもPHP 7.1.0以降マイクロ秒に対応したようで、DateInterval::formatメソッドもマイクロ秒の表示に対応しているようです。さっそく実験してみましょう。

<?php
$dt1=new DateTime("2000-01-01 00:00:00");
$dt2=new DateTime("2006-01-02 03:04:05.6");
$interval = $dt1->diff($dt2);
var_dump($interval->format("%R%Y-%M-%D %H:%I:%S.%F"));
/* Output: string(25) "+06-00-01 03:04:05.600000" */


パラメータ%Fで差分のマイクロ秒が表示できました。ところで、次のような例を試すと奇妙なことに気づきます。

<?php
$dt1=new DateTime("2000-01-01 00:00:00");
$dt2=new DateTime("2006-01-02 03:04:05.000251");
$interval = $dt1->diff($dt2);
var_dump($interval->format("%R%Y-%M-%D %H:%I:%S.%F"));
/* Output: string(25) "+06-00-01 03:04:05.000250" */


0と251の差なので「000251」と表示されるはずが「000250」となっており、1マイクロ秒ズレた結果になっています。


ドキュメント化もされていない機能なので誰も困らないと思いますが、一応バグ報告しておきました。

ポートノッキングで10秒間だけsshdを公開する設定

先日Twitterに次のような書き込みをしたところ思ったより反応が良かったので、詳細の設定を紹介します。

といっても特殊なことをしたわけではなく、knockdでポートノッキングの設定を行い、iptablesと組み合わせて実現しました。

ポートノッキングとは

ポートノッキングというのは、決められたポートを決められた順番で叩くことでファイアーウォールに穴を空けられるような仕組みのことです。ポートノッキングを使えば、TCPの7000番、8000番、9000番の3ポートにパケットを送りつけると22番ポート (SSH) へのアクセスが許可される、といった設定ができます。

ポートノッキングの実現にはknockdというデーモンを使うことが多いようです。knockdはlibpcapと組み合わせて使う前提のツールで、どのポートもlistenせずにポートノックを監視できるのが特徴です。

iptablesの設定

早速ですが、筆者の行った設定を紹介しましょう。今回の対象サーバはDebian 8ベースでした。まずはiptables関連のパッケージをインストールします。

$ sudo apt-get install iptables iptables-persistent

次にiptablesの設定ですが、TCP22番ポート (SSH) へのアクセスを全てふさぎます(6行目)。ただし、トラブル時の非常用経路(下記の例では 192.0.2.0/24)からのアクセスだけは許可しておき、以降の設定はこちらから行います(5行目)。

$ sudo iptables -t mangle -A PREROUTING -p tcp --dport 443 -j MARK --set-mark 443
$ sudo iptables -A INPUT -i lo -j ACCEPT
$ sudo iptables -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
$ sudo iptables -A INPUT -p tcp -m tcp --dport 22 -m mark --mark 443 -j ACCEPT
$ sudo iptables -A INPUT -s 192.0.2.0/24 -p tcp -m tcp --dport 22 -j ACCEPT
$ sudo iptables -A INPUT -j DROP

TCP443番からのパケットにはPREROUTINGチェインでマークを付けておくように設定し、マークの付いたパケットのTCP22番へのアクセスは許可する、という設定にしてあります(1行目、4行目)。これはknockdの方の設定と組み合わせて利用します。

最後に、次のように設定を保存します。

$ sudo netfilter-persistent save

ポートノッキングの設定

今度はポートノッキングの設定です。まずはknockdをインストールしましょう。

$ sudo apt-get install knockd

次に、/etc/knockd.confを次のように書き換えます。

[options]
	UseSyslog

[SSH]
	sequence    = 53:udp,443:tcp,123:udp
	seq_timeout = 5
	command     = /sbin/iptables -t nat -A PREROUTING -s %IP% -p tcp --dport 443 -j REDIRECT --to-port 22
	tcpflags    = syn
	cmd_timeout = 10
	stop_command = /sbin/iptables -t nat -D PREROUTING -s %IP% -p tcp --dport 443 -j REDIRECT --to-port 22

sequenceはポートノッキングのポートと順序を指定するものです。今回はUDP53番 (DNS)、TCP443番 (HTTPS)、UDP123番 (NTP) という3つのポートを叩くとポートノッキングが成立するよう設定しました。もちろん、これらのポートにはデーモンは立てていません。

commandはポートノッキング成立時に実行するコマンドで、TCP443番へのパケットを22番ポートに転送するようにしました。上で紹介したiptablesの設定と組み合わさることで、443番へアクセスするとsshdが返事を返す状態になるわけです。

この設定はポートノッキング元のIPアドレスに対してのみ有効になっているということにも注目してください( command の指定にある -s %IP% で実現しています)。ポートノッキング成立と同じタイミングで別の誰かが443番ポートに接続しようとしても単にファイアウォールがパケットを落としてしまうだけで、sshdにアクセスされることはありません。

cmd_timeoutstop_commandはポートを閉じるための設定です。この例では、ポートノッキング成立から10秒後に先ほどの443番から22番への転送設定を削除しています。

最後にknockdを再起動します。

$ sudo service knockd restart

これでポートノッキングを行うことで10秒だけSSHアクセスが可能になる、という設定が完成したわけです。

ポートノッキングに利用するポートの選択

ポートノッキングに利用するポートは本来何でもいいのですが、たまに外向きのアクセスが一部制限されている環境があります。社内ネットワークなどで必要なポートのみ開放されている場合もあるでしょうし、最近は多くのインターネットプロバイダで外向きのTCP25番ポートがブロックされているはずです。

そうした観点から、多くの環境で開いているのではないか?と考えて筆者はDNS、NTP、HTTPSという3つを選びました。身近な環境で外向きアクセスが制限されていないのであれば、7000番以降など後ろの方のポートを使った方がいいかもしれません。

.ssh/configの設定

このポートノッキングは手動で行うこともできますが、毎回手動で叩くのは面倒すぎますよね。それに、モタモタしていると10秒経ってSSHのポートが閉じられてしまうかもしれません。

そこで、ポートノッキングするコマンドを.ssh/configに組み込んでポートノッキングを自動で行うようにしてみましょう。

まずはknockコマンドをインストールします。私は手元のマシンはMacなので、次のようにHomebrewでインストールします。

$ brew install knock

下記のように.ssh/configProxyCommandでknockコマンドを指定することで、SSHログインの直前にポートノッキングを行います。

Host host1.example.com
  Port 443
  ProxyCommand bash -c '/usr/local/bin/knock -d 100 %h 53:udp 443:tcp 123:udp; sleep 1; exec /usr/bin/nc %h %p'

knockコマンドの-dはポートを叩く間隔(ミリ秒)です。テザリング回線を使う場合など待ち時間が短すぎるとうまく動作しないことがありますので、サーバ側のログを見ながら調整してください。

こうしておけば、普段はポートノッキングの存在すら忘れて他のマシンと同様に利用できるわけです。

まとめ

以上で、ポートノッキングの順序を知らない人はSSHログインを試みることすらできない、それでいて設定を知っていれば全自動でポートノッキングを行ってSSHログインできるような環境が構築できました。

念のため補足しておくと、ポートノッキングはセキュリティの観点では気休め程度のテクニックです。ポートノッキングに利用するパケットはそのままネットワーク上を流れますから、途中経路でのネットワーク盗聴などがあれば簡単に再現できてしまいます。間違ってもtelnetdなどセキュアでないサービスの公開にポートノッキングを使ってはいけません。

逆に言うと、ポートノッキングは元々セキュアなサービスを隠すような用途でしか使えません。基本的には「カッコいい」以外のメリットがない仕掛けだと言えるでしょう(今回は実益があったので設定したのですが、それは別の機会に紹介します)。

また、個人的にはknockdを業務で利用することはあまりオススメしません。引き継ぎが面倒になるだけでセキュリティ面の効果は気休め程度、と考えればデメリットの方が大きいくらいだと思います。

一方で、個人用サーバであれば無駄な苦労をしても困るのは自分だけですから、面白いと感じたことはドンドンやればいいと思います。こうした遊びを通じてネットワークまわりの知識が増えていけば、いずれ業務にも生きてくるはずです。

PHPの中身をgdbで観察できるようなDockerイメージを作りました

CLI版のPHPgdb上で動かしつつ、内部的なデータ構造を覗き見ることができるようなDockerイメージを作ってDocker Hubにアップロードしました。Docker環境さえあればすぐに動かすことができます。


このイメージを動かせばCのコードを書かなくてもPHP内部のデータ構造を確認することができます。PHPの内部構造を詳しく知りたい、というような人は参考にしてみてください。

準備

Macの人はDocker for Macを用意しましょう。他のOS上でも同様にDockerをインストールしてください。また、イメージの圧縮時サイズが200MB程度ありますので、それなりのネットワーク環境で遊ぶことをオススメします。

起動

Docker 1.10以降ではseccompにより一部システムコールが制限されているため、コンテナ内でgdbによるデバッグができません。期待通りにgdbを動かすにはコンテナ起動時にコマンドラインオプション「--cap-add=SYS_PTRACE --security-opt seccomp=unconfined」を付ける必要があります。

$ docker run -v $(pwd):/work -w /work --rm -it --cap-add=SYS_PTRACE --security-opt seccomp=unconfined yhnw/php-debug:7.1 /bin/bash

遊び方

とりあえずzvalの中身を覗いてみましょう。


まず作業ディレクトリに移動して、適当なPHPプログラムを作ります。今回は中身を見てみたい変数を列挙してvar_dump()してみましょう。

$ mkdir -p work/php-debug
$ cd work/php-debug
$ vim var_dump.php


今回作成したvar_dump.phpは下記のようなファイルです。

<?php
$a = strtoupper("foo");
$b = [ $a, &$a, "FOO"];
$c = $a;
var_dump($b);
$a = 123;
$b[1000] = 1234.5;
var_dump($b);


このディレクトリでDockerイメージ「yhnw/php-debug:7.1」を実行してgdbを起動します。

$ docker run -v $(pwd):/work -w /work --rm -it --cap-add=SYS_PTRACE --security-opt seccomp=unconfined yhnw/php-debug:7.1 /bin/bash
root@536f406bded6:/work# gdb php
GNU gdb (Debian 7.7.1+dfsg-5) 7.7.1
Copyright (C) 2014 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from php...done.


まずPHP関数var_dump()にブレークポイントを設置します。PHPの内部関数はCの世界では「zif_」というプレフィックスがつきますので、zif_var_dumpという関数名になります。

(gdb) b zif_var_dump
Breakpoint 1 at 0x8429a4: file /usr/src/php/ext/standard/var.c, line 205.


では先ほどのPHPプログラムを実行してみましょう。

(gdb) run var_dump.php
Starting program: /usr/local/bin/php var_dump.php
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, zif_var_dump (execute_data=0x7ffff2214140, return_value=0x7fffffffb0b0) at /usr/src/php/ext/standard/var.c:205
205		if (zend_parse_parameters(ZEND_NUM_ARGS(), "+", &args, &argc) == FAILURE) {
(gdb) n
209		for (i = 0; i < argc; i++) {


実行するとブレークポイントの設定によりPHPプログラム5行目のvar_dump()で停止します。次に「n」をタイプしてzend_parse_parameters()を実行しています。これによりvar_dump()のパラメータがargsに代入されます。


さて、ではvar_dump()の第一引数$bの中身を見てみましょう。

(gdb) printzv &args[0]
[0x7ffff2214190] (refcount=2) array:     Packed(3)[0x7ffff2258300]: {
      [0] 0 => [0x7ffff225fa08] (refcount=3) string: FOO
      [1] 1 => [0x7ffff225fa28] (refcount=2) reference: [0x7ffff2202120] (refcount=3) string: FOO

      [2] 2 => [0x7ffff225fa48] (refcount=0) string: FOO
}


情報量が多いので全部は説明しませんが、$bは3要素の配列であり、ハッシュテーブルを持たない真の配列(Packed)であることがわかります。また、$b[0]の文字列"FOO"の参照カウントは3であることがわかります。これは$b[0]と、$cと、$aの実体($aおよび$b[1]が共有している)の3つから参照されているためです。$b[1]の参照カウントが2なのは$aと$b[1]が同じ実体を参照しているという意味です。


$b[2]の参照カウントは0ですが、これは文字列リテラルを代入していることが原因です。文字列リテラルインターン化文字列になるため、参照カウントおよびGCの対象外なのです(参考:「PHPのインターン化文字列とは何か - hnwの日記」)。


では、次のブレークポイントまでプログラムを再開してみましょう。

(gdb) c
Continuing.
array(3) {
  [0]=>
  string(3) "FOO"
  [1]=>
  &string(3) "FOO"
  [2]=>
  string(3) "FOO"
}

Breakpoint 1, zif_var_dump (execute_data=0x7ffff2214140, return_value=0x7fffffffb0b0) at /usr/src/php/ext/standard/var.c:205
205		if (zend_parse_parameters(ZEND_NUM_ARGS(), "+", &args, &argc) == FAILURE) {


プログラム5行目のvar_dump()の結果が表示され、8行目のvar_dump()で再び停止しました。改めて$bの中身を見てみましょう。

(gdb) n
209		for (i = 0; i < argc; i++) {
(gdb) printzv &args[0]
[0x7ffff2214190] (refcount=2) array:     Hash(4)[0x7ffff2258300]: {
      [0] 0 => [0x7ffff225fb60] (refcount=2) string: FOO
      [1] 1 => [0x7ffff225fb80] (refcount=2) reference: [0x7ffff2202120] long: 123

      [2] 2 => [0x7ffff225fba0] (refcount=0) string: FOO
      [3] 1000 => [0x7ffff225fbc0] double: 1234.500000
}


$b[1000]に値を代入したことで$bが4要素の連想配列(Hash)に変わったことがわかります。PHPは添字が全部数字であっても中身がスカスカになってしまうような場合は内部構造として連想配列を採用します。各要素のzvalのアドレスも書き換わっていることから、データ構造の変更に伴い要素のメモリコピーが発生していることもわかります。仕組み上当然のことですが、PHP 7において当初配列として利用していたものを途中から連想配列として利用するのは非効率なのです。


また、$a[0]の参照カウントが1減って2になっていることもわかります。これは$aの実体が123に書き換わり、参照先が変わったためです。


ちなみにprintzvというのはPHPに標準添付の.gdbinitで定義されている関数で、zvalの中身を見るのに非常に便利です。もしgdbの標準コマンドで$b[0]の中身を見るとすれば、次のような手順が必要になります。

(gdb) p args[0].u1.v.type
$1 = 7 '\a'
(gdb) p args[0].value.arr.arData[0]->val.u1.v.type
$2 = 6 '\006'
(gdb) p (char *)args[0].value.arr.arData[0]->val->value.str.val
$3 = 0x7ffff22649d8 "FOO"
(gdb) p args[0].value.arr.arData[0]->val->value.str->gc->refcount
$4 = 2


まず$bが配列であること(type=7)を確認し、次に$b[0]が文字列であること(type=6)を確認してその値にアクセスしています。zvalでは共用体を多用しているので仕方ないのですが、ちょっと面倒すぎますよね。

シンボルテーブルを確認する

シンボルテーブルを確認する方法も紹介しておきます。グローバルなシンボルテーブル(つまりグローバル変数)は次のように確認できます。

(gdb) print_ht &executor_globals.symbol_table
Hash(10)[0x1368530]: {
  [0] _GET => [0x7ffff2259100] (refcount=2) array:
  [1] _POST => [0x7ffff2259120] (refcount=2) array:
  [2] _COOKIE => [0x7ffff2259140] (refcount=2) array:
  [3] _FILES => [0x7ffff2259160] (refcount=2) array:
  [4] argv => [0x7ffff2259180] (refcount=2) array:
  [5] argc => [0x7ffff22591a0] long: 1
  [6] _SERVER => [0x7ffff22591c0] (refcount=2) array:
  [7] a => [0x7ffff22591e0] indirect: [0x7ffff2214080] (refcount=2) reference: [0x7ffff2202120] long: 123


  [8] b => [0x7ffff2259200] indirect: [0x7ffff2214090] (refcount=2) array:

  [9] c => [0x7ffff2259220] indirect: [0x7ffff22140a0] (refcount=2) string: FOO

}


そのスコープでのシンボルテーブル(=ローカル変数)は次のようにすれば見えることもあります(PHP 5.2までは常にローカル変数に対応するシンボルテーブルが作られていたのですが、PHP 5.3以降は必要なときだけ作られるようです)。

(gdb) print_ht execute_data.symbol_table

Dockerイメージについて

ちなみに今回作ったDockerイメージはオフィシャルのphpイメージのリポジトリをforkして作りました。Travis CIを利用しており、masterにコミットするとCIでDocker Hubへのデプロイまで行うようにしてありますので、参考にしてみてください。


ちなみに5.6と7.0のイメージもデプロイ済みです。

まとめ

Cもgdbも良くわからなくても、PHPプログラム中のvar_dump()のタイミングで止める方法と「printzv」だけで結構遊べるのではないでしょうか。もう少し深く知りたい場合はgdbのマニュアルおよびPHPソースコードのZend/以下を調べてみてください。