hnwの日記

go-shellwordsでUnixシェル的なものを実装した

私はSlackの入力テキストに対応して外部コマンドを起動するbotをGoで自作しています。このbotに最近「&&」などUnixシェルの演算子を部分的に実装したのですが、その際go-shellwordsが便利だったという話を紹介します。

私の作っているSlack botの簡単な紹介

私の作っているSlack botは入力テキストのパターンマッチを行い対応する外部コマンドを実行するようなものです。実行例を下記に示します。

f:id:hnw:20210117185029p:plain
自作Slack botの実行例

この例では銀行サイトから情報取得するNode.jsスクリプト2つ(口座振替照会、残高照会)を連続で実行して銀行引落に必要な残高があるかを確認しています。

go-shellwordsとは

go-shellwordsはmattnさん作のGoライブラリで、コマンドライン文字列を受け取ってコマンド引数の配列を返すようなものです。

Goのexec.Command()はコマンド引数の配列を受け取る仕様なので、今回のSlack botのように引数を含むユーザー入力を受け取ってコマンドを起動する場合は引数の配列に分割する必要があります。単純なコマンドしか受け付けないのであればstring.Split()で分割してもいいのですが、Unixシェルのようなクォーティングやエスケープに対応したい場合はgo-shellwordsを使うと便利です。

また、go-shellwordsはUnixシェルの特殊文字が出現するとparseを止めてくれるので、「&&」による複数コマンド実行にも対応できます。簡易コードですが、私は次のようなコードを書きました。

   for {
        // コマンドとみなせるところまでparse
        args, err := parser.Parse(line)
        if parser.Position < 0 {
            // 文字列末尾までparseした
            return
        }
        // コマンドの実行(または直前のコマンド実行結果と演算子次第では実行しない)
        // parseできなかったところから演算子を探す
        i := parser.Position
        token := line[i:]
        operators := []string{";", "&&", "||"}
        for _, op := range operators {
            if strings.HasPrefix(token, op) {
                // 演算子を保存
                i += len(op)
                break
            }
        }
        // マッチした演算子の後ろから次のループでparseする
        line = string(line[i:])
    }

エラー処理を省いてあるとはいえ、かなりシンプルに実現できるのがわかると思います。もっとも、今回は「&&」「||」「;」の3つしか対応していないためシンプルに書けた側面もあります。リダイレクトやカッコに対応しようと思ったらもう少し真面目に処理を書く必要があるでしょう。

エラー処理を含む実際のコードに興味がある場合は私の実装(executor.go)をご覧ください。

実装する演算子が左結合だけで済むと楽

「&&」「||」「;」の3つだけでも実装大変じゃない?木構造作る必要あるよね?と考えた人がいるかもしれません。私もそう思っていたのですが、Unixシェルの場合は偶然が重なって先頭から逐次処理していくだけで実現できました。

確かに、普通のプログラミング言語であれば演算子「||」より「&&」の優先度が高いので、例えば A || B && C という論理演算は A || (B && C)と解釈する必要があり、これを実現するには木構造を作る必要がありそうです。

しかし、Unixシェルでは「&&」と「||」の優先度は同じです(参考:Bash Reference Manual 3.2.4 Lists of Commands)。「;」だけはマニュアル上で優先度が低いのですが、同じ優先度と考えても矛盾は生じないようです1

全部の演算子が左結合だと頭からループで実装できるので楽ですね、という手抜きテクニックの紹介でした。

本物のシェルを使わない理由

蛇足かとは思いますが、Unixシェルっぽいことを実現したいときに本物のUnixシェルを使うのはオススメしません。よほど注意しないと外部文字列を元に任意コマンドを実行される脆弱性(OSコマンドインジェクション)を作り込んでしまいます。

今回の処理ならexec.Command("sh","-c",line)などとすればシェルのコマンドラインのフル機能を利用できますが、line がシェルに渡して安全な文字列であることを保証するのは困難だと思います。逆の見方をすれば、Unixシェル的な機能を実現するのに本物のUnixシェルが使えないからこそgo-shellwordsが便利なわけです。

まとめ

  • Unixシェルっぽい文字列処理をするのにgo-shellwordsを使うと楽だし安全
  • シェルの演算子のうち「&&」「||」「;」だけ実現するのは意外と楽

  1. A && B ; C && D を例に考えると、(A && B) ; (C && D)(A && B ; C) && D はどちらもCが成功したときだけDを実行するわけですから等価です。他の例を考えても完全に等価だと思うのですが、数学的に示せるかはわかっていません。数学的帰納法の出番かなあ…?

PHPとPythonとRubyの連想配列のデータ構造が同時期に同じ方針で性能改善されてた話

PHPPythonRuby連想配列のデータ構造がそれぞれ4〜5年ほど前に見直され、ベンチマークテストによっては倍以上速くなったということがありました。具体的には以下のバージョンで実装の大変更がありました。

  • PHP 7.0.0 HashTable高速化 (2015/11)
  • Python 3.6.0 dictobject高速化 (2016/12)
  • Ruby 2.4.0 st_table高速化 (2016/12)

これらのデータ構造はユーザーの利用する連想配列だけでなく言語のコアでも利用されているので、言語全体の性能改善に貢献しています1

スクリプト言語3つが同時期に同じデータ構造の改善に取り組んだだけでも面白い現象ですが、さらに面白いことに各実装の方針は非常に似ています。独立に改善に取り組んだのに同じ結論に至ったとすれば興味深い偶然と言えるでしょう2

本稿では3言語の連想配列の従来実装と新実装の概要を説明した上で、新実装が速くなるカラクリについて私なりに解説してみます。

最初にお断りしておくと、斬新なハッシュ実装が発明されたような話ではありません。地道な改善と捉えた方が実状に近いと思います。

PHPPythonRubyの新旧実装の概要

各言語の連想配列のデータ構造がどう変わったのか見ていくことにしましょう。次の図はPHP5の連想配列のデータ構造です。

f:id:hnw:20210110024405p:plain
PHP5の連想配列のデータ構造

これはアルゴリズムの教科書にも載っているような連想配列の実装「チェイン法」の素直な実装になっています。キーに対応するハッシュ値を計算し、ハッシュ値ごとにキーと値の組を格納します。それぞれの値は連結リストで管理されています。

これを改善したものがPHP7の実装です。

f:id:hnw:20210110024434p:plain
PHP7の連想配列のデータ構造

これはチェイン法の変形の実装と捉えることができます。前段の配列はPHP5ではポインタの配列でしたが、PHP7では添字の配列になりました。またキーと値の組はこれまで1要素ごとに動的に確保していたのをPHP7ではあらかじめ配列を確保した上で添字ベースで連結リストを実現しています。

次にPythonも見ていきましょう。Python 3.5までの連想配列の実装は次のようなものでした。

f:id:hnw:20210110024547p:plain
Python3.5以前の連想配列のデータ構造

これまた教科書通りの連想配列の実装で、「オープンアドレス法」と呼ばれる方式です。データ構造としては配列1個のみのシンプルな仕組みですが、ハッシュ値が衝突した場合は決まったルールで要素を辿っていく必要があります。

Python 3.6ではこれに改善を加えています。

f:id:hnw:20210110024633p:plain
Python3.6の連想配列のデータ構造

これはオープンアドレス法の変形になっており、添字を管理する配列と要素を管理する配列の2段構えになっています。衝突した場合のルールなどはPython 3.5以前と全く同じです。

Rubyについては旧実装がPHP5に近く、新実装はPython 3.6に似ているという認識です3

なぜ速くなるのか?

PHP7とPython3.6の連想配列のデータ構造を見ると、データの持ち方が非常に似ているのがわかると思います。衝突解決の方針が異なっているだけで、2段構えの前段の配列で添字を管理する点、後段の配列は格納順に要素を追加していく点などは完全に同じです。

それにしても、なぜこれで速くなるのでしょうか。劇的に計算量が改善するような変更ではありませんし、Pythonについては間接参照が1回増えているので不利になってもおかしくありません。

性能改善は複数の要因が絡み合った結果でありアプリケーションにも依存するので、一概に「この点が性能改善に寄与した」とは言えないのですが、私は新実装のキモは次の2点だろうと考えています。

  • データサイズの減少、メモリの参照局所性の向上
  • 連想配列の構築時に連続するメモリ領域にシーケンシャルに書き込まれる点

要はメモリの読み書きに関連する改善が支配的だったのではないか?ということです。高級言語を使っているとつい忘れがちですが、現代のCPUから見るとメモリは非常に遅いのです。

キャッシュヒット率を高めた方が有利という話

メモリがCPUと比べてどれくらい遅いのか、Intelの資料4から読み取った数字を紹介します。

  • L1キャッシュヒット 4 Clock
  • L2キャッシュヒット 10 Clock
  • L3キャッシュヒット 40 Clock
  • キャッシュミス 200〜330 Clock

キャッシュミスが起こると数百クロックのメモリストールが発生するというのは恐ろしい数字ですよね。また、下位のキャッシュもまあまあ遅いことがわかります。ですから、現代のCPUではいかにキャッシュヒット率を高めるかが重要になります。扱うデータサイズを小さくできればその分メモリアクセスが減るはずですし、同時に使われるデータをできるだけ近くに格納すればキャッシュヒット率がアップするはずです5

PHPについて言えば旧実装ではメモリ利用に無駄が多かったのが、新実装になって1要素あたりのメモリ使用量が半分以下になっています(72バイト→32バイト)。RubyPHPほどではありませんが、ポインタアクセスを廃したことで特に64bit環境でメモリ使用量を削減できています6。またPythonはこれまでデータの隙間が多かった7のに対し、新実装では要素の配列を密に使えるようになり、サイズの小さい前段の添字配列だけが疎になるように変わっています。これらが性能改善に大きく貢献しているというのは直感的におかしくない話だと思います。

メモリのシーケンシャルアクセスは有利という話

改善要因の2点目に挙げた「連想配列の構築時に連続するメモリ領域にシーケンシャルに書き込まれる点」については補足が必要かもしれません。

古いプログラマ(私を含む)の常識として、連想配列は原則として格納順を保持しないというのがあると思います。しかし、今回紹介した新実装の後段、要素を格納する配列は添字の小さい方から順に使っていくことになるので、自然に実装すれば勝手に格納順が保持されます。これを利用してPython 3.7からは連想配列の要素の順序保持が仕様になっています。

私の意見は、この実装が機能面だけでなく性能面でもメリットを生んでいるのではないか?ということです。

Pythonの旧実装を考えると、複数の要素を持つ連想配列を構築する際のメモリ書き込みはランダムアクセスになっていました。一方、新実装ではシーケンシャルなアクセスになります。最近のCPUでは連続するメモリ領域のアクセスに対してハードウェアプリフェッチャーがよしなに働いてアクセスしそうなメモリ領域を勝手にキャッシュに乗せてくれるので、ランダムアクセスよりシーケンシャルアクセスの方が有利なのです。

スクリプト言語では言語コアでも連想配列を多用するため、連想配列の構築が頻繁に行われます。つまり、連想配列の構築コストを下げられれば性能面への寄与は大きいように思います。これがそこまで支配的な要因になるのかは正直なところわかりませんが、可能性としては十分ありうるように思います。

落穂拾い

今回スクリプト言語3つの連想配列の実装の経緯を調べてみたのですが、影響がゼロとは言えないにせよ、結果的に実装が似たような部分もありそうです。一方で、実装時期が近い点についてはOS・CPUの64bit化が関係しているかもしれません。2010年頃から64bit環境が一般的になって64bitポインタが性能面の足かせになるような状況が個別に表面化し、結果として同時期に改善されたというのはありうる話です(こうした因果関係は後から判断するのが難しい部分もありますが…)。

本稿では3言語の連想配列の実装が非常に似ているという話をしてきましたが、細かい点はかなり異なっています。PHPでは前段の添字を管理する配列に32bit整数を使っていますが、PythonRubyでは連想配列のサイズが小さいときに添字を8bit整数の配列で管理してメモリ消費を抑える工夫が見られます。スクリプト言語では小さいサイズの連想配列を大量に使う傾向があるので、このような最適化は合理的なのでしょう。これはPHPでも採用の余地がありそうです。

連想配列の実装としてチェイン法とオープンアドレス法のどちらが優れているかについては何とも言えないところです。Rubyがチェイン法からオープンアドレス法に乗り換えているので、実はそちらの方が有望ということなのかもしれません。ただ、結果的にこれだけ似た実装になってしまうと両者で大差ないという可能性もありそうです。

落穂拾い2(追記:2020-01-11)

@_ko1さん(Rubyコアコミッター)から、「Ruby 2.4の実装時はPHPPythonの実装を認識していた」とのこと。完全に独立な実装とは言えないようです。

確かに、当時の議論を確認するとPythonの旧実装をかなり参考にしてますね。ただ、Python 3.6とほぼ同時期に実装が行われているので、偶然の一致の部分も多少ありそうに思いました。

@methaneさん(Pythonコアコミッター、Python 3.6のdict改善の実装者)から、「Python 3.6の変更は挿入順の維持が主目的、メモリ使用量削減はメリットだが性能面ではそこまで大きなインパクトはないかも」とのこと。

ごめんなさい、タイトルが嘘でしたね…。確かにPythonの旧実装は余計なポインタ参照もないので64bit環境でも不利じゃないよなーとは思ってました。

まとめ

PHPPythonRuby連想配列の性能およびメモリ効率が2015〜2016年ごろに改善され、それらの実装が似ているという話を紹介しました。また、性能改善の要因はメモリの読み書きに関連するものではないか?という私の意見を紹介しました。

3つの実装が似ているという指摘はあまり見たことがないので記事にまとめてみましたが、他の言語でもこれを真似すべき、という話をしたいわけではありません。3言語とも各言語を使ったベンチマークテストを実施した上で新実装を採用したわけで、これが別の言語・別のアプリケーションでも最適なデータ構造とは限りません。大事なのは「推測するな、計測せよ」ですよね。

性能改善の要因については私の妄想の部分も多分にあると思いますし、PythonRubyについては今回ざざっとソースコードを眺めた程度なので致命的な間違いがあるかもしれません。識者の方のツッコミをお待ちしております。

参考URL


  1. Pythonの実装変更は性能面のメリットは小さかったという指摘をもらいました。本稿「落穂拾い2」を参照のこと。

  2. Rubyの実装では他の実装も意識しつつ開発していたようです。本稿「落穂拾い2」を参照のこと。

  3. https://github.com/ruby/ruby/blob/master/st.c のコメント部に新実装の説明が書いてありました

  4. Performance Analysis Guide for Intel® Core™ i7 Processor and Intel® Xeon™ 5500 processorsより

  5. メモリキャッシュは64バイトなどのキャッシュラインサイズ単位でキャッシュされるため、1バイトアクセスしたら周囲の64バイトのアクセスもタダみたいなもんです

  6. Ruby2.3以前は要素のチェインを双方向連結リストで管理していた点も不利だったように思います

  7. オープンアドレス法では衝突頻度を下げるためにハッシュテーブルサイズを大きめに取る必要があり、教科書的実装だとメモリを飛び飛びに使うことになるため

Raspberry Pi+Mackerelで気軽に温度監視できるようにした話

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

私はRaspberry Pi 4を持っているのですが、ヒートシンクが熱々になって心配なので温度監視をする必要性を感じていました。今回Raspberry Pi用のSoC温度を取得するMackerelプラグインを作ってmkrで楽々インストールできるようにしたので、その所感などを紹介します。

Raspberry Pi 4は噂通り熱い

Raspberry Pi 3および4はそれまでの機種より性能が上がった代わりに発熱もひどくなっており、ヒートシンクはほぼ必須、可能ならファンをつけた方がいいとも言われています。

とはいえ自宅で常時起動するならファンレスの方が嬉しいですよね。そこで私は次の写真のようなヒートシンクケースを買いました。これはeBayなどネット通販で買えるもので、送料込みで900円程度でした。見た目としてもボードむき出しよりは随分マシなので気に入っています。

f:id:hnw:20201204230655j:plain
ヒートシンクケースをつけたRaspberry Pi 4

ただ、使っているとどうしても温度が気になります。電源を入れているだけでケース全体が暖かくなりますし、しばらく負荷をかけていると結構熱くなってきます。そもそもこのヒートシンクケース自体どれほどの放熱性能なのかもわかりません(そんなものを買うなという話もありますが)。

Raspberry PiのSoC温度を取るMackerelプラグインを作った

そんなこんなでRaspberry Piの温度監視がしたくなった私はMackerelプラグインを作りました。

これはRaspberry Pi OSのvcgencmdコマンドを利用して、SoC温度・動作クロック・動作電圧・スロットル状態を取得するMackerelプラグインです。

このプラグインを使うとMackerelで次のようなグラフが取れるようになります。

f:id:hnw:20201204211828p:plain
4コア使用時のSoCの温度変化のグラフ

f:id:hnw:20201204211841p:plain
4コア使用時のクロック変化のグラフ

これは4コア全部を使い切ったときのグラフです。それなりに熱くなるのですが、Raspberry Pi用として売られている15mm四方のヒートシンクよりはマシ1であることがわかりました。

Mackerelプラグインはmkr対応しておくと便利

Mackerelプラグインをインストールしたい場合、ソースコードを持ってきてビルドしてバイナリをコピーしてもいいのですが、ちょっぴり面倒ですね。それよりオススメなのがmkrコマンドでビルド済みバイナリをインストールする方法です。例えば今回のプラグインであれば次の手順でインストールできます。

$ wget -q https://github.com/mackerelio/mkr/releases/download/v0.42.0/mkr_0.42.0-1.v2_armhf.deb
$ sudo apt install ./mkr_0.42.0-1.v2_armhf.deb
(略)
$ sudo mkr plugin install hnw/mackerel-plugin-raspberrypi
           Downloading https://github.com/hnw/mackerel-plugin-raspberrypi/releases/download/v0.0.11/mackerel-plugin-raspberrypi_linux_arm.zip
           Installing /opt/mackerel-agent/plugins/bin/mackerel-plugin-raspberrypi
           Successfully installed hnw/mackerel-plugin-raspberrypi

コマンド一発で気軽にインストール・アップデートできるのはいいですね。

mkrコマンドはMackerelのCLIツールで、プラグインインストール機能を持っています。これはGitHub Releaseからプラグインのバイナリファイルを探して最新版をインストールしてくれるものです。GitHub Releaseに公開するzipファイルは命名規則に従っている必要があります。

私はこのzipファイルの作成にGitHub Actionsを利用しています。Gitでv0.0.11のようなタグを打つと複数アーキテクチャのバイナリがビルドされ、GitHub Releaseにファイルが作られるような仕組みになっています。

GitHub Actionsだとバイナリのリリースが楽ちん

GitHubでタグ付けすると自動ビルド・自動デプロイされる仕組みをTravis CIやCircleCIで構築する事例は珍しくありません。私もそうした経験はあるのですが、今回同様の仕組みをGitHub Actionsで作ってみて感動したのはGitHub自身とのインテグレーションの簡単さです。

Travis CIからGitHub Releaseにデプロイしたい場合、GitHubにデプロイキーを登録して秘密鍵を暗号化するなど面倒な手順が必要です。それがGitHub Actionsだと一切手間なしで実現できるのです。GitHub内のやりとりなので簡単なのは当然といえば当然なのですが、この手の作業は頻繁にやるものではないので、精神的なハードルが下がるのは素晴らしいことだと感じます。

また、外部の秘密情報を必要としないので、GitHub Actionsの設定ファイルをコピペするだけで他リポジトリでも使えるのも利点です。Goで書いたMackerelプラグインをお持ちの方は下記ファイルをリポジトリにcommitしてタグを打つだけでmkr対応ができるはずです2

Mackerelプラグインのmkr対応、面倒そうだと思ってやっていなかった人も多いのではないでしょうか。それがコピペ一発で出来るなんて最高じゃないですか?

まとめ

  • Raspberry Piに特化したMackerelプラグインを作成してmkr対応した、気軽に温度監視ができるようになった
  • GitHub Actionsのおかげでバイナリのリリースが超低コストで実現できる、神

  1. 以前はRaspberry Pi 4が手元に2台あったのですが、小さいヒートシンクよりヒートシンクケースの方が5℃くらい低い温度になっていました。

  2. 今回紹介したプラグインとは別リポジトリですが、4アーキテクチャ(386, amd64, arm, arm64)のバイナリをビルドする設定になっているので流用しやすいと思います

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と連携することで閲覧・編集がきわめて簡単にできることは案外知られていないように思います。本稿では私が使っているネットワーク図のテンプレートプロジェクトを紹介します。

f:id:hnw:20200922195631p:plain
テンプレートのネットワーク図

diagrams.netとは

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

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

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

f:id:hnw:20200922201444p:plain
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スイッチを発見した話

私は同じ賃貸住宅に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と呼ばれているもの。あまり直感的な名前ではないと感じたので、本稿ではこのように呼びます。