hnwの日記

Raspberry Pi の Wi-Fi パワーマネジメントモードについて調べた

さいきんRaspberry Pi 4を買ったんですが、Wi-Fiだけで運用したときにRaspberry Piへのアクセスがイマイチ不安定、ということがありました。ネットの情報を調べるとLinuxの無線ネットワークの「パワーマネジメントモード」をオフにすれば平和になるような話が見つかるんですが、その挙動を解説した記事が見つからなかったので自分なりに調べてみました。

パワーマネジメントモードの確認

パワーマネジメントモードの有効無効はiwコマンドで調べられます。たしかにwlan0で有効になっていますね。

$ iw dev wlan0 get power_save
Power save: on

パワーマネジメントモードの無効化

下記のようにすればパワーマネジメントモードを無効にできます。

$ sudo iw dev wlan0 set power_save off

この設定はOSを再起動すると元に戻ってしまうので、私は以下の/etc/dhcpcd.exit-hookWi-Fi設定時にパワーマネジメントモードを無効化しています。これはdhcpcdのフック機能を利用しています(参考:dhcpcd-run-hooks(8))。

if [ "$reason" = "PREINIT" -a "$interface" = "wlan0" ]]; then
    iw dev wlan0 set power_save off
fi

パワーマネジメントモード有効のときの挙動を確認する (1)ping

パワーマネジメントモードを有効にしていると、無線インターフェースが無通信時にスリープに入るような挙動になります。

$ while true ; do ping -c4 192.168.1.168 ; sleep 15; done
PING 192.168.1.168 (192.168.1.168): 56 data bytes
Request timeout for icmp_seq 0
64 bytes from 192.168.1.168: icmp_seq=0 ttl=64 time=1793.757 ms
64 bytes from 192.168.1.168: icmp_seq=1 ttl=64 time=790.571 ms
64 bytes from 192.168.1.168: icmp_seq=2 ttl=64 time=2.760 ms
64 bytes from 192.168.1.168: icmp_seq=3 ttl=64 time=6.573 ms

--- 192.168.1.168 ping statistics ---
4 packets transmitted, 4 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.760/648.415/1793.757/734.991 ms
PING 192.168.1.168 (192.168.1.168): 56 data bytes
64 bytes from 192.168.1.168: icmp_seq=0 ttl=64 time=703.300 ms
64 bytes from 192.168.1.168: icmp_seq=1 ttl=64 time=3.618 ms
64 bytes from 192.168.1.168: icmp_seq=2 ttl=64 time=9.246 ms
64 bytes from 192.168.1.168: icmp_seq=3 ttl=64 time=9.187 ms

--- 192.168.1.168 ping statistics ---
4 packets transmitted, 4 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 3.618/181.338/703.300/301.364 ms
PING 192.168.1.168 (192.168.1.168): 56 data bytes
Request timeout for icmp_seq 0
64 bytes from 192.168.1.168: icmp_seq=0 ttl=64 time=1549.241 ms
64 bytes from 192.168.1.168: icmp_seq=1 ttl=64 time=545.393 ms
64 bytes from 192.168.1.168: icmp_seq=2 ttl=64 time=2.565 ms
64 bytes from 192.168.1.168: icmp_seq=3 ttl=64 time=9.234 ms

--- 192.168.1.168 ping statistics ---
4 packets transmitted, 4 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.565/526.608/1549.241/630.164 ms
PING 192.168.1.168 (192.168.1.168): 56 data bytes
64 bytes from 192.168.1.168: icmp_seq=0 ttl=64 time=455.575 ms
64 bytes from 192.168.1.168: icmp_seq=1 ttl=64 time=3.466 ms
64 bytes from 192.168.1.168: icmp_seq=2 ttl=64 time=9.315 ms
64 bytes from 192.168.1.168: icmp_seq=3 ttl=64 time=3.040 ms

他マシンから15秒おきにpingを打ってみると、1回目のレスポンスだけ極端に遅く、2回目または3回目からレスポンスが安定します。最初の2秒くらいスリープしているかのような挙動です。おそらくスリープ時にNICの一部回路だけでパケットを監視し、自分宛のパケットが届いたら無線インターフェース全体をwake upするような仕組みになっているのでしょう。

この状態でSSH越しに作業しているとたまに秒単位のレイテンシが発生するものの、wake up後は爆速になるので、これで省電力になるならアリかな?と思ってしまうかもしれません。

パワーマネジメントモード有効のときの挙動を確認する (2)ARP

パワーマネジメントモードを有効にしたときの問題点は、スリープ時にブロードキャストパケットが来てもスリープしたまま無視されてしまう点です。具体的にはARPやmDNSに対して返事を返しません。これは次のようにARPテーブルをクリアしてからRaspberry PiIPアドレスに対してpingを打つことで確認できます。

$ sudo arp -d -a
192.168.1.1 (192.168.1.1) deleted
192.168.1.168 (192.168.1.168) deleted
224.0.0.251 (224.0.0.251) deleted
$ ping -c1 192.168.1.168
PING 192.168.1.168 (192.168.1.168): 56 data bytes

--- 192.168.1.168 ping statistics ---
1 packets transmitted, 0 packets received, 100.0% packet loss

tcpdumpで確認してみましたが、無線インターフェースがスリープしているタイミングではARPのリプライは返ってきません。偶然Raspberry Pi内部から通信が発生したタイミング(wake upしたタイミング)でARP問い合わせを投げると返答があり、そこでようやくIPアドレスベースで通信ができるようになります。

率直に言って、この設定は多くの利用者にとって混乱の元だと思います。別マシンからリモートログインするような状況を考えると、久々にログインする際にARP問い合わせをして運悪く返事がなかったらIPでの通信すら成立しないわけですから、さすがに不便すぎると思います。

まとめ

  • Raspberry Pi では無線のパワーマネジメントモードがデフォルトでオンになっている
  • パワーマネジメントモードがオンだと無通信時に無線インターフェースがスリープする、省電力の観点で有利
  • 自分がパケットを出すとき、または自分宛のパケットが届いたときに無線インターフェースが自動的にwake upするので、条件が整っていればスリープ時も通信できる
  • ブロードキャストパケットに対しては自動wake upの仕組みが働かない模様、特にARPに反応できないのは致命的で実用性は疑問

このパワーマネジメントモード、たとえばIoTの文脈で間欠的にセンサー値を外部サーバに送信するだけの状況であれば有用だと思いますが、大抵の場合は無条件にオフにした方が良さそう、というのが個人的な結論です。

自宅のネットワーク図をメンテし続ける工夫

みなさん、自宅のネットワーク図って何のツールで書いてますか?私は過去に次のようなツイートをしたところ案外バズったことがありました。

これがバズるのは自宅にヤバいネットワークを組んでいる人が一定数いる証拠と言えるかもしれません。リプライ欄を見ても、みなさんそれぞれ工夫されていることがわかりました。

私個人はネットワーク図を書くのに色々なツールを試してきたのですが、イマイチ定着しないのが悩みでした。最初は頑張って書くのですが、いつの間にかメンテをサボってしまい、いざネットワーク構成をいじる段になると情報が古くて役に立たないことが複数回ありました。悲しいですね。

プロなら仕事で使う定番ツールがあるのかもしれませんが、私のようなアマチュアの立場だと機能云々よりも「いかにメンテナンスコストを下げるか」が一番大事なのかもしれません。そんな私が最近使っているのがdiagrams.net(旧draw.io)です。

diagrams.net自体は有名ツールなので今さらと言われそうですが、GitHubと連携することで閲覧・編集がきわめて簡単にできることは案外知られていないように思います。本稿では私が使っているネットワーク図のテンプレートプロジェクトを紹介します。

テンプレートのネットワーク図

diagrams.netとは

diagrams.netJavaScript製のドローツールで、JGraph LtdによりOSS開発されています。オンライン版は各種ブラウザからすぐ使うことができますし、Electronベースのデスクトップ版もあります。

以前のdraw.ioという名前の方が有名だと思いますが、セキュリティ上の理由でツール名とドメイン名を変更中のようです。

diagrams.netのセーブ先はGitHubDropboxなど各種オンラインストレージを使うのが普通です。逆に言うとdiagrams.net自体はデータを人質にとっているわけではないので、一般ユーザーからお金を稼ぐチャンスはありません。じゃあ何で稼いでいるのか?というとConfuluenceとJiraのインテグレーションで稼いでいるようです。ConfuluenceとJiraを採用している会社さんは採用を検討してみてください(勝手に宣伝)。

diagrams.netをブラウザから利用しているところ

GitHubとの連携

さて、このdiagrams.netはGitHubとの連携が大変優れています。論より証拠、まずは私のテンプレートリポジトリを見てください。

見ての通り、ブラウザでリポジトリにアクセスすれば特別なツールがなくてもネットワーク図を確認できます。GitHubなら普段使うはずのサイトなので、心理的な障壁も随分下がります。

diagrams.netのストレージとしてGitHubを使う場合、ファイルフォーマットはSVGにするのがよいでしょう。GitHubMarkdownSVGを画像として埋め込めるので特に不便はありません。

また、クリックするとdiagrams.netが起動して編集できるようなリンクもMarkdown中に簡単に作れます(上のリポジトリの「Edit」リンクを試してみて下さい)。編集してセーブするとGitリポジトリにcommitされます。これくらいハードルが下がればズボラな私でもメンテできるというわけです。

このやり方は自宅のネットワーク図以外でも活用できるはずです。業務であればER図やクラス図を同じように管理するとメンテナンスが継続されやすくなるのではないでしょうか。

ネットワーク図に書くべきこと

ネットワーク図に何を書くべきかについても紹介します。この手のものは記述者が「何が必要か、何を書きたいか」を表現すべきものであって正解はないのですが、参考までに私の基準を紹介します。

  • 書くべきもの
    • ネットワーク機器の物理的な場所
    • 機器の識別名(機種名など)
    • 物理配線とポート番号
  • 必要に応じて書くもの
    • 機器の固定IP
    • 無線ネットワークが複数ある場合、それぞれ利用している端末
    • VLANの情報(簡単な場合のみ、複雑なら論理配線のネットワーク図を起こす)

私個人の落としどころとしては、物理的な作業の際に有用な情報を書き、論理的な情報はできるだけ書かない工夫をする、という感じになりました。逆に言えば、メッシュネットワークなどで有線接続がほとんどないネットワーク構成にした場合はネットワーク図が極端にシンプルになる(場合によっては不要になる)と思います。

まとめ

diagrams.netの図をGitHubで管理すれば閲覧・編集の心理的ハードルが下がってメンテナンスされやすくなる、という体験談を紹介しました。

この手のものはカッコいいテンプレートがあるとやる気が湧いてくる側面もあると思うので、他の方も手元の図をテンプレートとして公開して頂けたら嬉しいです。

FAQ

Q1. なんでネットワーク図にバスルームが登場するの?

A1. ユニットバスの天井裏に備え付けのL2スイッチを発見した話 - hnwの日記

ユニットバスの天井裏に備え付けのL2スイッチを発見した話

(2021/06/23追記: この記事を書いてしばらくして引っ越しました。原状復帰済みです。念のため。)

私は同じ賃貸住宅に10年ほど済んでいるのですが、ごく最近になって自宅内に備え付けのL2スイッチが存在することに気づきました。ソイツはなんとユニットバスの天井裏にいたのです。

このスイッチをGbEスイッチにリプレースしたところ、自宅のコンピューティング環境を改善することができました。本稿ではその顛末を紹介します。皆様のお風呂場探検の参考になれば幸いです。

謎の情報コンセント

読者の皆さんは情報コンセントというものをご存じでしょうか。下の写真のようにイーサネットケーブルを差すコンセントのことを言うそうです。

f:id:hnw:20200920025819p:plain:w300
我が家の情報コンセント

これがない家もあると思いますが、私が今住んでいるマンションには情報コンセントが部屋ごとについています。

この説明は入居時に一切受けていないのですが、試しにイーサネットケーブルをつないでみると部屋間が100Mbpsでつながることがわかりました。

いまどき100Mbpsというのもひどい話ですが、賃貸の設備にケチを付けても仕方がありません。きっと大家がケチって4芯のクロスケーブルが壁に埋まってるんだろう、と思っていました。

しかし、我が家の情報コンセントは全部で3口あります。冷静に考えれば3口がクロスケーブルで接続されるはずはないのですが、当時の私は部屋の間にイーサネットケーブルを通す手間が省けた喜びで思考停止していました。

見知らぬL2スイッチ

そんなこんなで謎の情報コンセントを9年ほど使い続けていました。情報コンセントの両側にはGbEスイッチを置いていたので両者の接続が100Mbpsなのは不満でしたが、壁に埋まっている設備の問題であって自分で何とかできるとは夢にも思っていませんでした。

そんなある日、ネットサーフしていると「お風呂場の天井裏をサーバールームにしてみよう」というブログ記事を見つけました。集合住宅ではユニットバス上部の空きスペースを各種配線スペースとして利用することがあり、イーサネットケーブルが出ていることも多いようです。

tomeapp.jp

これは我が家も同じ状況なのでは?と考えてユニットバスの天井を開けてみると、案の定バッファローの100Mbpsスイッチがピカピカ光っていました。スイッチには4本のCAT5eケーブルが接続されており、3本は各部屋の情報コンセントにつながっていました。残りの一本はマンションのMDF室につながっているようですが現状では使っていないようです。

情報コンセント間が100Mbpsで接続されている謎が解けた瞬間でした。

f:id:hnw:20200920032213p:plain:w300
お風呂場のココ、開けられるんですよ

f:id:hnw:20200920030324j:plain
ユニットバス天井裏で発見したバッファローのスイッチ

湿度を測定する

ユニットバスの天井裏は一定の広さがありますから、先の記事で紹介されているようにサーバルームとして使うのは面白いアイデアです。とはいえ、L2スイッチが湿気でダメになったりしないのでしょうか。

多くの人は問題ないと結論づけているようですが、自分でも確認してみることにしました。Raspberry Pi Zeroを用意し、温湿度センサーを付けた上でユニットバス天井裏に設置してMackerelでグラフ化した結果が以下になります。

f:id:hnw:20200920032748p:plain
入浴前後の湿度の変化 (3月)

f:id:hnw:20200920034921p:plain
入浴前後の湿度の変化 (9月)

センサーは2つ用意したのですが、オレンジ色のグラフの方が実際の値に近いようです1

この結果によれば、ユニットバス天井裏は入浴時に湿度が上がるものの、冬場で+5%、夏場で+2%程度の上昇幅でした。入浴時はユニット内の温度上昇の影響で天井裏も温められるため、飽和水蒸気量が押し上げられて結果的に湿度の上昇が抑えられているようです。また、夏場の方が高温なので飽和水蒸気量が元々大きく、浴室利用の影響が相対的に小さくなるということも言えそうです。

結論としては入浴によるユニットバス天井裏への影響は限定的で、どうやら結露するようなことはなさそうです。

スイッチの置き換え

湿度の心配もなさそうなので、ユニットバス天井裏を活用していきましょう。

部屋間の接続速度が遅い原因はバッファローの100Mbpsスイッチでした。そこで、このスイッチをGbEスイッチに置き換えたところ、部屋間の接続が1Gbpsになりました。賃貸住宅だから仕方ないと思っていた点が改善できたのは嬉しいですね。

リプレースしたスイッチはVLANに対応しているので、機能面でもパワーアップしたことになります。

f:id:hnw:20200920035418j:plain
ちょっと良いスイッチでリプレースしました

マシンの設置と所感

参考にしたブログ記事ではユニットバス天井裏にサーバ類を置いています。なかなか面白いアイデアですので私も同じことをしてみました。

ユニットバス天井裏は非常にほこりっぽい空間なので、まずは念入りに掃除をしました。また、各種機械類を直接ユニットバスの天井裏に置くのは気が引けたので、100均で買ってきたミニすのこを敷くことにしました。

この空間にリモートデスクトップ専用機になっていたThinkpadを置いてみたところ、不自由なく使えることがわかりました。このマシンは稼働時にファンがうるさいのが気になっていたのですが、音が聞こえない場所に置けるようになったのは良い点と言えそうです。

一方で、ここに大きい機器・重い機器を置くのはオススメしません。天井裏へのアクセスは不便なので、あまりに重いものを置くと設置の際に腰をやったりするかもしれませんし2、大きいものだと天井裏へ通すときに事故るかもしれません。湿度その他の理由で壊れる可能性もゼロではありませんから、高額な機器や大電力を消費する機器も置かない方が良いでしょう。

また、事故があったときに気づきにくい場所なので、温度・湿度のモニタリングは必須と言えるでしょう。こうした監視にRaspberry Pi Zeroはかなり良い選択肢です。私の場合うまく使えそうなHATとブレイクアウトボードがあったので雑に天井裏に投げ込むことができました。また、Mackerelにはしきい値を超えるとSlackに警告を飛ばす設定があるので、これも設定してみました。

f:id:hnw:20200920110012j:plain:w300
設置したRaspberry Pi Zero

まとめ

筆者の自宅のユニットバス天井裏に備え付けのL2スイッチが動いていたこと、これを置き換えたところ家のネットワークが増速したという事例を紹介しました。他のお宅でも同じような状況かもしれませんので、一度ユニットバス天井裏を探検してみてはいかがでしょうか。


  1. 青いグラフに対応するセンサーはRaspberry Pi ZeroのCPUとの距離が近く、気温の測定値が周辺気温より高めに、湿度が低めに出ていました

  2. 天井の強度にも不安があります

PHP7から定数配列がOPcacheに乗るので巨大配列が使い放題という話

PHP 7.0のリリースから約5年が経過し、そろそろPHP 8.0のリリースも見えてきました。人によっては使い始めて5年目になるはずのPHP 7.xですが、いまだに新しい発見があったりして面白いですね。

本稿ではPHP 7.0から入った定数配列に関する性能改善について紹介します。

PHP 5時代は配列の組み立てコストが大きかった

プログラミング上のテクニックとして、辞書データを連想配列としてプログラム中に記述し、これを必要に応じて使うというものがあります。たとえば次のコード例を見てみましょう。このような連想配列を持っておけば、プログラム中で国名コードをを扱う際に実在するかをチェックしたり、国名の日本語表記に変換したりといった処理ができるわけです。

<?php
$country_name = [
    'jp' => '日本',
    'us' => 'アメリカ合衆国',
    'ru' => 'ロシア連邦',
    /* 以下略 */
];

ところで、こうした辞書的なデータはPHPでどのように処理されるのでしょうか。PHPでは上記のコードが配列に1要素ずつ追加するopcodeにコンパイルされ、実行時にopcode列を実行することで配列が組み立てられます。つまり、プログラム中に1万要素の連想配列が登場すると1万個のopcode列にコンパイルされて実行されるわけです。この実行コストが高いため、PHPで巨大配列(数万要素オーバー)を作るのは重いといわれてきました。

どれくらい重くなるのか、下記プログラムで実際に試してみましょう1

<?php
function foo($bar) {
    $arr = [
        "x1"=>["foo"=>1,"bar"=>$bar,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        "x2"=>["foo"=>2,"bar"=>$bar,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        /* 約30000行省略 */
        "x29999"=>["foo"=>29999,"bar"=>$bar,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        "x30000"=>["foo"=>30000,"bar"=>$bar,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
    ];
    return $arr;
}
foo(1);

39万要素の二次元配列を返す foo() を呼び出す処理です。これを私の手元の環境(Mac Mini + nginx + PHP 5.6.40)で実行して実行時間を測定しました2

OPcacheなし OPcacheあり
初回アクセス
OPcacheあり
2回目以降
267 ms 436 ms 87 ms

「OPcacheなし」と「OPcacheあり2回目以降」を比較すると、処理時間267msのうちの67%がコンパイル処理だということになります。コンパイル結果をキャッシュすることで速度が3倍になると考えればOPcacheの効果は大きいですね。一方で、連想配列の組み立てだけで87msかかっているわけで、OPcacheがあってもまだ遅いという見方もできます。実際にはここまで大きい連想配列を扱うことはないと思いますが、数万要素くらいなら実戦投入している現場があるはずですから、改善できるなら改善したいですね。

PHP 7でも連想配列の構築はそれなりに重い

PHP 7からは連想配列のデータ構造が改善され、高速・省スペースになったことはよく知られています。PHP 7.xでも連想配列の組み立て処理は重いままなのか確認してみましょう。

さきほどのプログラムをPHP 7.4.9で実行してみました。結果は以下の通りです。

OPcacheなし OPcacheあり
初回アクセス
OPcacheあり
2回目以降
469 ms 9106 ms 25 ms

「OPcacheあり 2回目以降」同士で比較すると実行時間の改善はすごいですね。PHP 7の配列はさすがに速いということでしょうが、それでも25msは無視できない重さです。PHP 7をもってしても巨大配列の構築は高コストというわけです。

ところで、OPcacheの初回の処理がとんでもない重さになっているのも気になりますね。OPcacheの初回処理では最適化処理が走るので一般的にOPcacheなしのときより時間がかかるのですが、それにしても不安になる遅さです3。実戦でここまで遅くなることは少ないでしょうが、大きめの連想配列を扱う場合はPHP 7.4で採用されたコードの事前ロード機能を使ったり、別途ウォームアップ処理を実行した方が無難かもしれません。

定数配列ならコンパイル時に構築されキャッシュされるので高速(PHP 7.0以降)

ようやく本題です。PHP 7.0から定数配列4の扱いが変わっており、他の配列より性能面・メモリ消費面で有利になっています。

先ほどから実験に使っているプログラムでは連想配列の一部に変数を含んでいるため、コンパイル時に配列全体を確定することはできません。もし配列のキーと値の全てが定数であれば、コンパイル時に配列全体を確定できます。このような配列を本稿では定数配列と呼ぶことにします。

定数配列ではコンパイル時に配列全体が構築され、プログラム中でこれを利用するようになります。また、OPcacheが有効であれば初回コンパイル時に作られた配列がキャッシュされ、以降のリクエストで使い回されます。

定数配列の挙動を確認するため、先ほどのプログラムと要素数は同じまま値を全て定数にして実験してみましょう。

<?php
function foo($bar) {
    $arr = [
        "x1"=>["foo"=>1,"bar"=>1,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        "x2"=>["foo"=>2,"bar"=>1,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        /* 約30000行省略 */
        "x29999"=>["foo"=>29999,"bar"=>1,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
        "x30000"=>["foo"=>30000,"bar"=>1,"baz"=>3,"qux"=>4,"quux"=>5,"corge"=>6,"grault"=>7,"garply"=>8,"waldo"=>9,"fred"=>10,"plugh"=>11,"xyzzy"=>12,"thud"=>13],
    ];
    return $arr;
}
foo(1);

上記プログラムを PHP 5.6.40 と PHP 7.4.9 で試した結果が下記になります。

OPcacheなし OPcacheあり
初回アクセス
OPcacheあり
2回目以降
PHP 5.6.40 313 ms 450 ms 85 ms
PHP 7.4.9 331 ms 458 ms 1 ms

PHP 5.6.40では変更前のプログラムと大差ない結果になりました。PHP 5では定数配列だからといって有利というわけではないようです。

一方PHP 7.4.9では全てのケースで改善が見られました。特にOPcacheあり2回目以降の1msというのは衝撃的な結果です。先ほどの実験によれば真面目に組み立てたら25msかかるサイズの配列を1msで返しているわけですから、定数配列の使い回し効果は絶大といえるでしょう。

もちろん初回アクセス時はそれなりに時間がかかるわけですが、これが気になる場合はコードの事前ロード機能を使うなどすれば実質使い放題というわけです。

まとめると、PHP 7ならどんなに巨大な定数配列でも実質タダで使えるわけですよ。やりましたね、PHPユーザーの皆さん!

まとめ

  • PHPの配列はopcode列としてコンパイルされるため、OPcacheありでも巨大配列の生成コストは高い
    • PHP 5ではこの問題が顕著だった
  • PHP 7から定数だけで構成された配列はコンパイル時に生成されるようになった(定数配列)
  • 定数配列はOPcacheのキャッシュ対象になっているため、キャッシュヒットすれば生成コストなしで使える

本稿の内容はイマイチ知られていない気がしますが、これを活用できるプロジェクトは多そうです。性能に影響を与える規模の配列・連想配列(数万要素程度?)を使っている場合、PHP 7の定数配列を利用できないか検討してみてはいかがでしょうか。


  1. 追試したい方のためにプログラム全体をgistにアップロードしました(巨大ソースコードなので、ブラクラ的な意味で閲覧注意です)

  2. ここで示した実行時間はnginxのログ $upstream_response_time で取得し、何回分か平均を取ったものです。

  3. OPcacheのイケてない実装を突いてしまった可能性がありそうです…

  4. PHPの内部ではimmutable arrayと呼ばれているもの。あまり直感的な名前ではないと感じたので、本稿ではこのように呼びます。

Gmailの新着メールをSlackに転送するGASを書いた

みなさん、Slack使ってますか?会社で使ってる人も多いでしょうし、サークルやコミュニティで使っているような人も多いと思います。ここ数年で一気に広がった感じがありますよね。

そうやってSlackに慣れてくるとSlackにさまざまな情報を集約したくなってきます。プログラマであればGitHub連携にはメリットを感じていると思いますし、エゴサ好きな人ならIFTTTでTwitter検索の結果をSlackに通知するなんていう使い方もあります1

そんなある日、私は特定のメールを無料プランのSlackに転送したくなりました。この機能はSlackの公式App「Email」で実現できるのですが、残念ながら有料プランでしか使えません。

さらに言うと、大昔は同じ機能をIFTTTで実現できたんですが、2019年にIFTTTのGmailトリガーが封じられてしまってIFTTTでは不可能になってしまいました2

そんな状況でしたので、GmailのメールをSlackに転送するGoogle Apps Script (GAS)を作ってみました。本稿ではこの概要と導入方法を紹介します。

概要

今回、GmailのメールをSlackに転送する仕組みをGASで実現しました。GitHubに公開してあります。

GASというのはGoogleが提供するスクリプト実行環境で、Googleアカウントを持っていれば誰でも使えます。Googleサービス(ドライブ文書やGmail・カレンダーなど)との親和性が高いので、これらを自動化する環境としては実質一択と言っても良いでしょう。完全無料なのも嬉しいところです。

また、GmailからSlackへの転送にはIncoming Webhookを使っています。これを使うにはSlackのアプリ追加権限が必要です。Slackワークスペースの管理ポリシー次第では使えないこともありますので、ご注意ください。

ちなみに、転送されたメールはSlack上で次のような見た目になります。

f:id:hnw:20200613124149p:plain
メールをSlackに転送したときの見た目

メールのSubjectと本文の先頭4行だけが表示されており、「Show more」を押すと全文が見えるようになっています。

導入の流れ

導入までのステップはざっくり言うと下記のようになります。

  1. Gmailアカウントを新規作成する
  2. 1のアカウントでGASをデプロイする
  3. Slackにメール転送役のbotユーザーを追加する
  4. 3のユーザーに紐付くIncoming WebhookのURLを発行してGASのプロパティに設定する
  5. 1のアカウントにメールを転送する
  6. GASの時間主導型トリガーで起動間隔を設定する(1分おきから1時間おきくらいが適当でしょう)

面倒に見えると思いますが、実際面倒ですね…。Gmailアカウントは既存のものを使うこともできるのですが、導入手順が更にややこしくなるのでオススメしません。

具体的な導入手順やカスタマイズ方法などはREADMEをご確認ください。

使ってみての感想

この仕組みを運用して6ヶ月ほど経ちましたが、自分の用途には問題なく使えています。

自作して良かった点は細かいカスタマイズをしやすいところですね。私の場合はメールの前に任意のテキストを追加したい(これを別のbot向けのコマンドとして利用したい)というニーズがあったり、他にも何点かやりたいことがあったので自作して良かったと思います。

とはいえ有料プランならSlack公式のEmailアプリを使わない手はないでしょう。EmailアプリはHTMLメールを同イメージでSlack上に再現してくれるのが素晴らしい点で、自作で真似しようと思うと結構面倒なんですよね…。


  1. 病むタイプの人にはオススメしません

  2. Office 365 Mailトリガーなら今でも使えます

パスワードをプレーンテキストで保存してはいけないという話とその解決策

プログラムから使うパスワードをどう保存するか

外部サービスにアクセスするプログラムを書く場合、そのサービスの認証方式は何か、認証鍵をどう保存するか、というのはシステム全体のセキュリティを考える上で重要な話題です。

昨今のWebサービスであれば、クライアントごとに権限を絞ったアクセストークンを発行し、万一トークンが漏れた場合には漏れたトークンのみを無効化するような仕組みが提供されていたりします。こうした仕組みがあれば鍵の取り回しについて悩む必要はないでしょう。

一方、パスワード認証のサービスをプログラムから利用する場合、そのパスワードをどう管理すればよいのでしょうか。例えば銀行のネットバンキングシステムに自動ログインするプログラムを書く場合に、プログラムが利用するID・パスワードを平文で保存することに問題はないのでしょうか。

この問題についての議論を私はほとんど見たことがありませんが、関係しそうな文章をOWASPで見つけました。

この文章によれば、「パスワードを平文のプレーンテキストで保存してはいけない、BASE64のような可逆なエンコーディングもダメだ」とのことです。明示的には書いていないのですが、可能ならパスワードは保存しない方が良い、保存するなら暗号化すべき、ということのようです。

パスワード平文保存のリスクを考える

それにしても、パスワードが暗号化されていると何がどれほど安全になるのでしょうか。

仮に、すべてのローカルファイルを閲覧可能な脆弱性があったとすると、パスワードが暗号化されていたとしても大した意味はありません。というのも、プログラムから暗号文を復号できるということは共通鍵もシステムのどこかに保存されているはずですから、攻撃者も共通鍵を使って平文を入手できてしまうからです。

となると、OWASPの指摘はローカルファイルのうち一部のみが漏洩したような場合への対策だと考えられます。この種の漏洩の典型例はショルダーハック(盗み見)ではないでしょうか。

PASSWORD = foobar # 怖い
PASSWORD = Zm9vYmFyCg== # BASE64。依然怖い

上記のように平文パスワードをファイルに保存していて、このファイルを編集中に誰かが後ろを通りかかったり、運悪く写真を撮られてしまったような場合を考えると、システムに脆弱性がなくてもパスワードが漏洩する可能性は十分にあります。

PASSWORD = sj3OFgKZbHCpEaVB1zHz0Pd3amUcTIvDOoDK9Mdk6PlT6A== # 少し安全

このようにパスワードを暗号化しておけば、暗号文と共通鍵の両方を盗み見られない限りパスワードは漏洩しません。このように考えるとOWASPの指摘は十分意味があるように思います。

なんちゃって暗号化ライブラリを作った

私は銀行に自動ログインするプログラムを書いており、当初はパスワードを平文で保存していたのですが、上記のように考えてパスワード暗号化ライブラリを作成しました。

これはNode.js用ライブラリで、ランダム生成したパスワードを元にCBCモードつきの共通鍵暗号(デフォルトはAES)で暗号化・復号を行います。npmに公開してありますのでコマンド一発で試せます。

$ npm install @hnw/easyaes

使い方としては、まず最初にパスワードを生成します(手動で作ってもいいです)。

$ $(npm bin)/easyaes --keygen > $HOME/.easyaes

次に、暗号化したい文字列を標準入力経由で入力して暗号文を得ます。

$ $(npm bin)/easyaes --encrypt
foobar #標準入力から入力した平文。実際にはエコーバックされません
sj3OFgKZbHCpEaVB1zHz0Pd3amUcTIvDOoDK9Mdk6PlT6A== #出力された暗号文

この暗号文は次のようにJavaScriptから復号できます。

const EasyAes=require("@hnw/easyaes");
cipher = new EasyAes();
console.log(cipher.decrypt('sj3OFgKZbHCpEaVB1zHz0Pd3amUcTIvDOoDK9Mdk6PlT6A==')); // foobar

このように暗号化した値を設定ファイルで使ったりコマンドラインオプションから与えたりして、これをアプリケーション内で復号すればショルダーハックに対してセキュアになるわけです。

これはセキュリティの観点では保険的対策でしかないのですが、精神的な安心度はかなり高まるように感じています。というのも、一部銀行ではいまだにパスワード長が8文字までに制限されているので、平文で保存するとショルダーハックのリスクが非常に高いのです。

もっとマトモなソリューションもある

私の場合はプライベート用途かつ管理者が私だけの環境だったので上記の実装で必要十分だと考えていますが、もっとシリアスな状況ではもっと真面目に鍵を管理すべきです。

たとえばお仕事でセキュアに鍵を管理する目的であればAWS KMSとかHashiCorp Vaultなどを使うのが良いでしょう。これらのソリューションはアクセスコントロールを細かく制御できたりログが取れたりするのが良い点だと思います。

また、マシン起動直後だけはパスワード入力を要求して、以降は入力されたパスワードをメモリ上に暗号化して保存しておくなどの選択肢もあるでしょう。

まとめ

パスワードを平文保存するよりはマシな「なんちゃって暗号化」ライブラリを作りました。ショルダーハック対策くらいにしかなりませんが、短いパスワードや暗証番号をテキストファイルに保存するような場合は特に有用だと感じています。

念のため補足してきますと、銀行パスワードのような重要な情報を扱う場合に一番大切なのはマシン自体のセキュリティレベルを高くすることです。例えば、私ならグローバルIPアドレスを持っているマシンや共用マシンではパスワードのローカル保存は避けます1。本稿で紹介したライブラリは十分安全な環境で更に安心を得るためのものであり、その前提がないと無意味だという点にご注意ください。


  1. 個人的には、私自身が管理している宅内NAT環境であれば十分セキュアだと考えていますが、何をもってセキュアと考えるかの線引きは難しいところです。ITリテラシーが高いつもりの人でもマルウェアをインストールしてしまう可能性はあるわけで、どんな環境であろうと銀行パスワードレベルの重要情報はストレージに保存しない、という考え方もあると思います。

既存のCommonJSモジュールを継承して自分好みのモジュールを作る

私は最近Node.jsで趣味のスクリプトを書いています。Node.jsであれば最新のECMAScriptの文法が使えるので、その意味で勉強になって良いですね。

また、何をするにしてもnpmで複数の選択肢が見つかること、それらのモジュールを簡単に試せること、といった点はやはり便利です。私も複数のモジュールを利用して便利にコードを書いています。

ところで、こうしたモジュールを継承して自分好みの挙動に修正したり新しいメソッドを足したりしたい場合にどうすれば良いのでしょうか。これは私のようなECMAScript初心者には難しい問題で、試行錯誤にかなり時間を使ってしまいました。

本稿では、既存のCommonJSモジュールを継承して新たなモジュールを作る方法について紹介します。あくまで私なりの結論ですので、ツッコミをお待ちしております。

CommonJSモジュールとは

まずは前提知識について簡単に紹介します。CommonJSモジュールというのは、主にNode.jsで利用されているソースコード分割の仕組みです。Nodeを使っていると下記のようなコードをよく見ると思いますが、このように require で読み込むモジュールがCommonJSモジュールです。

const puppeteer = require('puppeteer');

CommonJSモジュールではrequireで返す値だけが公開され、それ以外の値は隠蔽されるので、グローバルオブジェクトの汚染で悩まされずにすむわけです。

requireでは何を返しても構いません。実際、ES6クラスを返すモジュールもあれば、普通の関数やオブジェクトを返すモジュールもあります。

ケース1a: ES6クラスの継承(トップレベル)

では、CommonJSモジュールをどう継承するか考えていきます。

まずはrequireでES6クラスが帰ってくる場合を考えてみましょう。この場合は素直に継承を実現できます。

const Foo = require('foo');
class Newfoo extends Foo {
    // 既存のメソッドを上書きしたり、新たなメソッドを追加したり
}
module.exports = Newfoo;

このように自分好みのクラスを作って、それをmodule.exportsにセットすれば既存のモジュールと同じように呼び出して使うことができます。

ケース1b: ES6クラスの継承(セカンドレベル以下)

ケース1aのクラス継承はトップレベルのES6クラスを置き換えたいときにしか使えません。下位のクラスの挙動を変更したい場合は別の方法をとることになります。

具体的な例を紹介します。以下はpuppeteerのElementHandleクラスに新たなメソッドを足す例です。

const {ElementHandle} = require('puppeteer/lib/api');

// via: https://stackoverflow.com/questions/19669786/check-if-element-is-visible-in-dom/21696585
ElementHandle.prototype.isVisible = async function () {
  return await this.executionContext().evaluate(el => {
    if (!el) return false;
    const style = window.getComputedStyle(el);
    if (!style) return false;
    if (style.display === 'none') return false;
    if (style.visibility !== 'visible') return false;
    if (style.opacity < 0.1) return false;
    const bndRect = el.getBoundingClientRect();
    if (el.offsetWidth + el.offsetHeight + bndRect.height + bndRect.width === 0) {
        return false;
    }
    return true;
  }, this);
};

const myPuppeteer = require('puppeteer');

// (本稿では省略)

module.exports = myPuppeteer;

ここで書き換えているElementHandleはメソッド呼び出しを3回くらいしてようやく登場するクラスですので、クラス継承で挙動を変更するのは非現実的です。

このような場合、通常なら直接requireしない下位のクラスを取り出してプロトタイプ継承で書き換えることができます。

ちなみに、prototypeに与える関数はアロー関数で書いてはいけません。上の例のようにfunctionキーワードを使う必要があります。というのも、アロー関数で書くとthisがグローバルオブジェクトを指してしまい、メソッド呼び出しとして動かなくなってしまうのです(参考:Prototypeの関数でアロー関数は使わない - Qiita)。

ケース2: モジュールパターンの書き換え

ところで、CommonJSモジュールの多くはケース1aに当てはまりません。私の知る限り、トップレベルのオブジェクトとしてファクトリメソッドを含んだオブジェクトを返すものが多いように思います。

たとえばlog4jsなどは次のように使います。

const logger = require('log4js').getLogger();
logger.level = 'debug';
logger.debug("Some debug messages");

この場合ケース1の方法で挙動を書き換えるわけにはいきません1。このような場合は必要に応じてオブジェクトを上書きすることになります。

下記は私がlog4jsを上書きして自分好みのデフォルト挙動で使えるようにしたモジュールの抜粋です。

const log4js = require('log4js');
// オリジナルをコピーして使う。ディープコピーが必要な場合はよしなに。
const myLog4js = Object.assign({}, log4js);

// 自分好みのデフォルト値を設定する新規メソッド(本稿では省略)

const origGetLogger = myLog4js.getLogger;
myLog4js.getLogger = function (...args) {
  if (!enabled && !process.env.LOG4JS_CONFIG && defaults['appenders']) {
    configure(defaults);
  }
  return origGetLogger.apply(myLog4js, args);
}

module.exports = myLog4js;

このパターンでの書き換え対象はファクトリメソッドやその他の関数になるはずです。単に関数を書き換えるだけなら何も問題はありませんが、関数から元の関数を呼びだす場合は次のような手順が必要です。

  • 元の関数を変数に保存
  • Function.prototype.apply()で自分自身をthisにセットして呼び出し

apply()を使うと可読性が下がってしまいますが、既存の関数を上書きする場合は他に書きようがないと思います。

そもそも継承を使うべきかどうか

上記で紹介した継承による機能拡張は無制限に使って良いものではありません。継承を使うと親子間が密結合になってしまい、保守性が下がることが多いです。また、パターン1bは既存コードに影響を与えかねない修正なので、より慎重に適用する必要があるでしょう。

今回のように既存のモジュールの挙動を変更する場合、次の点に注意して使うのが良いように思います(異論はあるでしょうが…)。

  • 子クラスの実装量は十分小さい範囲にとどめる
  • 機能追加は原則として新規メソッドで行う
  • 既存の関数を書き換える場合、従来の挙動を維持するよう注意する
    • 既存コードに影響を与えないようにするため
    • 特にセカンドレベル以下のモジュールの書き換えは注意

継承以外にも機能追加を実現するデザインパターンは多数あります。継承は用法・用量を守って正しく使いましょう。

まとめ

既存のCommonJSモジュールを継承して新しいモジュールを作る方法について議論してきました。私の得た結論は次のようなものです。

  • トップレベルのモジュールがクラスを返す場合はES6のクラス継承が使える
  • セカンドレベル以下のモジュールがクラスを返す場合はプロトタイプ継承で挙動を変更できる
    • アロー関数を使うと死ぬので注意
  • モジュールパターンのモジュールは単に上書きすれば良い
    • 元の関数を呼び出す場合はapply()する
  • 継承を使うべきかどうかは要検討

ES6モジュールについては私が使っていないのでわかりませんが、ほぼ同じ考え方が適用できると想像しています。


  1. そもそもクラスではないので継承というのも不適切ですが…