hnwの日記

PHPerKaigi 2021でPHPの不変配列が高速かつ省メモリだという話をしました

この3/26〜3/28にPHPerKaigi 2021 という勉強会があり、私は「PHP7から不変配列がOPcacheに乗るのでKVSを置き換えられるかもしれないという話」というタイトルで発表しました。

改めて見直してみると発表タイトルちょっと何言ってるか分からないですね。言いたかったこととしては「PHP5まではPHP単体よりKVSを使った方が断然マシな状況があったけど、PHP7+OPcacheならKVSに勝てる」ということなんですが、全然伝わらないタイトルになっていましたね…。反省です。

内容としてはOPcahce有効のときに限りPHPコンパイル時に全要素を確定できる配列(不変配列)が特別扱いされて、これが高速かつ省メモリですという話を紹介しました。

本ブログの記事「PHP7から定数配列がOPcacheに乗るので巨大配列が使い放題という話」の焼き直しではあるんですが、新たに調べた内容もあり、たとえば以下のグラフは新作です。

f:id:hnw:20210329004404p:plain
OPcacheとKVSの速度比較

f:id:hnw:20210329004306p:plain
OPcacheとKVSのメモリ消費の比較

今回、プレゼンした後に何回か質問されたので、それらについて補足します。

Q1: コレどういうユースケースを想定してるの?

発表した内容は実際に活用してるの?と複数回聞かれました。私の身近では近い利用法をしているプロジェクトがあるのですが、更新が低頻度で巨大なマスターデータを扱うというのは珍しいニーズかもしれません。

私の会社はスマートフォンゲームの開発をしているのですが、スマートフォンゲームではプランナー職の方が巨大なマスターデータを作ることがあるんですね。今回、39万要素の配列を例に出しましたけど、長期運営タイトルだと本当にこれくらいの規模になったりします1。さらに、こうしたデータはサーバだけでなくアプリにも組み込むこともあり、更新の頻度はそこまで高くありません。

そんなわけで、たとえばECサイトとかではあまり使い道がなさそうですが、我々の同業他社さんだと近いニーズがあるように思います。

Q2: OPcache無しのとき不変配列の扱いはどうなるの?

OPcache拡張が有効になっていない場合、全ての配列は従来通りopcode列にコンパイルされるので速度面のメリットはありません。不変配列の判定と構築はOPcacheの最適化フェーズで行われており、PHP本体のコンパイル処理では不変配列に関する特別な処理は何もないのです。

Q3: 不変配列が爆速になるのって2回目以降のアクセスでしょ?

その通りです。説明をサボってました。

1回目のアクセスはOPcacheに乗っていないので、PHPスクリプトコンパイルをしてOPcahceの最適化処理が走って共有メモリ上に配列を構築する必要があるので、かなり遅いです。本気で使うならpreloadingopcache_compile_file()の利用を検討した方がいいでしょう。

逆に2回目アクセスではキャッシュから取り出したopcode列の時点で不変配列を参照しているので、そりゃ速いよねという理屈です。

感想など

今年のPHPerKaigiはオンラインでしたが、非常に面白かったです。講演は事前収録でしたが、自分の収録を見ながらDiscordで出た質問に答えるという体験のは新鮮でした。

また、飽きさせないような仕掛けが多いのも良かったと思います。講演中にニコニコでコメントが流れてくるのも良かったですし、休み時間にDiscordで雑談がはじまったり、Zoomでアンカンファレンスが開催されていたりで、オフラインとは違った面白さを感じました。

最後になりますが、スタッフの皆様、今年もおつかれさまでした。これほどの規模になると運営は本当に大変だと思いますが、来年も期待しておりますのでよろしくお願いいたします。


  1. 縦だけでなく横にも長いことが多いです

特定ホスト名の通信だけVPN経由にするルータ設定(OpenWrt編)

自宅のルーターの設定で、普段の通信はデフォルトゲートウェイを使いたいけど、一部のホスト名の通信だけはVPNトンネルインターフェースを使いたい、という状況がまれにあります。一般的なニーズではないと思いますが、少なくとも私にはそういうニーズがありました。

IPアドレスごとに外向きインターフェースを切り替えたいのであればiptablesの設定だけで実現できます。一方で、ホスト名によってインターフェースを切り替えるのは一般的には困難です(ホスト名の解決はアプリケーション層で行われるのに対し、iptablesネットワーク層トランスポート層での処理になるため)。このような場合に、IPset経由でdnsmasqとiptablesを連携してルーティングを切り替える方法があります。本稿ではこのやり方を説明します。

OpenWrtとは

OpenWrtは組み込み用途のLinuxディストリビューションで、家庭用の有線LANルータ・Wi-Fiルータファームウェアを置き換えることで機能追加をしたりカスタマイズ性を高めたりしようというプロジェクトです。ルータの持つ基本的な機能(PPPoEやファイアウォールの設定)に加え、DDNSVPN・VLAN・QoSなどの設定がGUIから可能になります。また、ルータにsshでログインできるようになるので、トラブルの切り分けがやりやすくなるメリットもあります。自宅のネットワークで色々遊びたい人にはお勧めの選択肢です。

必要パッケージの追加

ではOpenWrtでの設定を見ていきましょう。まずは必要パッケージをインストールします。

# opkg update
# opkg install ipset kmod-ipt-ipset dnsmasq-full luci-app-mwan3

dnsmasqは最初からインストールされているものでは機能不足で、full版に差し替える必要があります。

DNS正引きのログ出力

今回のような実験的な取り組みを行う場合、動作確認やトラブル対応のためにDNSクエリをログに出しておくと便利です。

Web管理画面(LuCI)から「Network」「DHCP and DNS」「General Settings」「Log queries」をチェックするとログに全DNSクエリがログに残ります。ログは logread コマンドで確認できます。

# logread
(略)
Mon Feb 22 10:09:02 2021 daemon.info dnsmasq[2794]: 173859 192.168.2.101/24828 query[A] android.googleapis.com from 192.168.2.101
Mon Feb 22 10:09:02 2021 daemon.info dnsmasq[2794]: 173859 192.168.2.101/24828 forwarded android.googleapis.com to 192.168.1.1
Mon Feb 22 10:09:02 2021 daemon.info dnsmasq[2794]: 173859 192.168.2.101/24828 reply android.googleapis.com is 172.217.24.138

IPsetのセット作成・動作確認

IPsetとは、IPアドレスやその他のネットワーク情報を高速に検索できるLinux上のオンメモリデータベースです。iptablesと組み合わせて使う前提の仕組みで、iptablesで大量のルールを扱うのに利用したり、今回のように別ツールと組み合わせて使ったりできます。

IPsetでは1つのデータベースをセットとよび、セットに対してIPアドレスやネットワーク範囲を追加・削除することができます。今回はforce_usというセットを作ります。

# ipset create force_us hash:ip

次に、/etc/config/dhcp を直接変更して、特定ホスト名をDNS正引きするとそのIPアドレスがforce_usセットに追加されるようにします。

config dnsmasq
    (略)
    list ipset '/api.example.com/api2.example.com/force_us'

このように、スラッシュ区切りで複数のホスト名を指定できます。また、ホスト名の後方一致でドメイン名を指定することもできます。

最後に dnsmasq を再起動して動作確認をします。DNS正引きした結果がセットに追加されていれば成功です。

# kdig a api.example.com +short
192.0.2.32
# ipset list force_us
Name: force_us
Type: hash:ip
Revision: 6
Header: family inet hashsize 1024 maxelem 65536
Size in memory: 368
References: 1
Number of entries: 1
Members:
192.0.2.32

mwan3の設定

mwan3は外向きロードバランシング用のOpenWrt独自パッケージで、iptablesのラッパーです。今回のように外向きインターフェースを使い分けたいだけの場合にはオーバースペックですが、Web管理画面から設定できて楽なので利用しています。

mwan3の設定で、先ほど作ったforce_usセットにマッチするIPアドレス宛てなら別のインターフェースを使うよう設定していきましょう。

まずWeb管理画面「Network」「Load Balancing」「Interfaces」の「Add」からvpnusインターフェース(OpenVPNで設定したVPNトンネルインターフェース)を設定します。これで既存インターフェースがmwan3の管理下に置かれて死活監視が行われるようになります。

次に、「Network」「Load Balancing」「Members」からvpnus_m1_w3というメンバーを追加します。ここでインターフェースとして先ほど設定したvpnusインターフェースを指定します。

f:id:hnw:20210222230837p:plain
mwan3のmember設定

さらに「Network」「Load Balancing」「Policies」からvpnus_onlyポリシーを追加します。ここで先ほどのvpnus_m1_w3メンバーを指定します。

f:id:hnw:20210222231019p:plain
mwan3のpolicy設定

最後に「Network」「Load Balancing」「Rules」でforce_us_v4というルールを作ります。IPsetについては先ほど作成した「force_us」を指定し、「Policy assigned」は先ほど作成した「vpnus_only」ポリシーを指定します。

f:id:hnw:20210222231137p:plain
mwan3のrule設定

以上の設定で、特定ホスト宛の通信をVPN経由にすることができました。

ipsetの保存

ルータを再起動しても同じ設定を維持するため、IPsetのforce_usセットをルータ起動時に作るようにします。

/etc/config/firewallを直接変更しましょう。

config ipset
    option enabled '1'
    option name 'force_us'
    option storage 'hash'
    option family 'ipv4'
    option match 'dest_ip'

制限事項

今回の仕組みが期待通り動作するためには、通信を行う前にDNSの正引きが行われる必要があります。逆に言うと、IPアドレスでアクセスするようなサービスでは使えません。ストリーミング系サービスの中にはAPIサーバからストリーミングサーバのIPアドレスが返ってくるようなものがありますが、こうした場合には適用できません。また、端末がルータのDNSを利用していないような場合も無力です。

まとめ

OpenWrtルータ上でdnsmasqとIPsetを連携させて特定ドメイン・特定ホスト宛の通信だけをVPN経由にすることができました。1ヶ月ほど使っていますが、今のところ期待通りに動作しています。

参考URL

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. 2台のRaspberry Pi 4で比較したところ、小さいヒートシンクよりヒートシンクケースの方が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の日記