書籍購入:『プログラミングの壺 I ソフトウェア設計編』

Amazonにて定価で売っていたので購入。

職業プログラマになって日が浅い頃に図書館で借りて読んだのだけど、なかなか難しい代物で読み進めるのに苦労した。そこで、時間をかけて読み進めるために購入しようと思ったのだけど、私がこの本に出合った頃には既に絶版となっていたのである。で、古本は古本で少々お高いのである。

そんなわけで購入をあきらめて10年以上も経った今、新品が定価で売っているという事実に少々驚いている。

……残念ながら続編2冊は古本でしか入手できないのよね。ちょっと残念。

プログラマとしてある程度の経験を積んだ今、やはり読み進めるのが難しい本だと思う。

まあただ、ペーペーだった頃とは異なり、何となく読み進めるのが難しいと感じる理由が分かった気がする。何のことはない、自分自身の知識や経験に欠損があるのだ

元々が1980年代後半のソフトウェア開発者向けに書かれたエッセイである。おそらく、当時と今とでは、コーディングの題材も、開発で使用するツールや手法も、異なる部分が相応にあると思われる。つまり、当時の著者と読者が暗黙のうちに共有していただろうコンテキストについて、21世紀になってから促成栽培プログラマになった私にとっては未知の部分が多いのである。

付け加えると、著者本人がまえがきで「この本は、ソフトウェアの設計手法やソフトウェアのエンジニアリングに関する中級レベルまたは上級レベルの補助教材として使えると思う」と書く程度の難易度である。つまり、1980年代当時の経験豊富なプログラマ相手の内容な訳で、おそらく「当時の著者と読者が暗黙のうちに共有していただろうコンテキスト」は、初級者向けのものよりも、より大きく、より深いものに仕上がっているように思う。

私は、21世紀のプログラマとしては、それなりに古い題材(構造化設計とか)の本を読んできたと思うし、長らくCプログラマとして泥臭い題材(文字列解析とか)をそこそこ扱ってきた方だと思うのだが――うーん、本書をスムーズに読み進めるには、まだまだ知識や経験が足りてないのかぁ、といった感じである。

まあ、幸いにも本書は手元にある。焦らずじっくりと読み進めることにしよう。

蛇足:個人の感想だが、著者のP.J. Plaugerは職業プログラマ寄りの人物だという印象がある。Visual Studio付属のC++標準ライブラリに彼のコピーライト表記があったからかもしれない(2019年にオープンソース化された以降は分からないけど)。Brian Kernighanはソフトウェア開発の教育分野に転じた印象が強いし、Ken ThompsonRob Pikeプログラマではあるけど研究開発寄りの経歴だよねという印象がある。

2022年の収穫:やっぱりちゃんと単体テストしよう

単体テストという言葉の意味が人によって異なるのでややこしいのだけど、ここで私の言う単体テストは「関数やメソッドを単体で取り出して、そのインタフェース部分と内部実装に着目して実施するテスト」のことである。

10年以上も職業プログラマをやっているにも関わらず、今年一番の収穫が「ちゃんと単体テストしようぜ!」であることに、忸怩たる思いが無くもない。

とはいえ、何しろ年末になって趣味の一環で公開しているC++ライブラリ単体テストを書いたら潜在的にゼロ除算を引き起こしうるバグとかユースケース的に割とヌルポインタアクセスエラーを頻発しそうなバグとかを見つけてしまった訳で、そりゃあ「やっぱり単体テストはやらないとアカンよね」という感想を抱くものである。

ちなみに、もしこの件が無かったとしたら、今年の感想は「やっぱりLint/静的解析ツールを使って静的テストするべきだよね」だったと思う。

今年の前半は久しぶりにJavaScriptPythonに触れる機会があった。JavaScriptについてはESLintを導入してnpm runで全コードをチェックできるようにしたし、PythonについてはVisual Studio CodePython拡張機能を入れた上で型ヒントを書くようにした。

後半にはそこそこ量のシェルスクリプトShellCheckでチェックするようなこともやった。

モダンな静的型付けのコンパイル型言語における静的テストは「コンパイラが頑張る」という方向で進んでいるように思う。

一方で動的型付けのスクリプト言語では、処理系本体による静的テスト性能がどうしても低くなる点をLintのような外部ツールで補完しつつ、それでも漏れる部分は動的テストである単体テストでカバーする方向であるように思う。

そんな中で、TypeScriptだとか、Pythonの型ヒントだとか、静的テストの効果を高めるための仕組みが浸透しつつある点は少し興味深い。

――ということを書こうかなと思っていた矢先に単体テストで初歩的だけど致命的なバグを見つけたのである。

……ツールによる静的テストは便利だけど、それとは別に、やっぱりちゃんと単体テストせなアカンね。

書籍購入:『DNSがよくわかる教科書』

今までDNSに関わることを巧妙に回避してきたのだけど、先日DNS絡みの問題が起きた時に面子の1人として駆り出されて、全く役に立たなかった(どころか頓珍漢な発言を連発してしまった)ので、反省の意を込めて泥縄式に基礎知識を仕入れることにした。

まだ基礎編の途中までさらっと読み流しただけだが、とりあえず先日駆り出された問題で何が起きていたのか想像できるようになった。上位の権威サーバに登録するネームサーバを間違えたために、ドメイン名に対応するIPアドレスを誰も返せない状態に陥っていた訳なのね。

今のところDNSに深入りする予定はないけど、何かあったときに対応できるように、基本的なところは押さえておきたいなあと思う次第である。やるかどうかは別として。

Dockerコンテナ停止とシグナル(SIGTERM)についての覚え書き

2年ちょっと前にDockerコンテナで「バックグラウンド・プロセスとして動作するデーモン」を起動/停止させる方法について書いた。

この時点では、シグナルまわりについて理解が浅い部分があった。今回は補足としてメモ書きを残しておこうと思う。

前提知識:その1

docker runで起動したコンテナの中のメインプロセスはPID=1となる。docker stopにおいては、コンテナ内のPID=1のプロセスにまずSIGTERMを送信して、一定時間待機してもプロセスが終了しないならSIGKILLを送信して強制終了させる。

SIGTERMの送信については、DockerfileのSTOPSIGNALや、docker runのオプション--stop-signalを使用することで、他のシグナルを送信するように変更することが可能である)

この仕組みに適切に対応するためにも、PID=1のプロセスとして動作させるソフトウェアは、ちゃんとSIGTERMをハンドリングして後片付けを実施するように実装しておく必要がある(このあたりは、Unixデーモンを自作する際の基本だろう)。

ここで、仮にPID=1のプロセスの下に子プロセスや孫プロセスが生成される構成であるならば、シグナルハンドリング後の後片付けの過程において、子プロセスや孫プロセスを適切に終了するように実装しておくべきだろう。

前提知識:その2

Unixシグナルには「受信時のデフォルトの動作」が存在する。SIGKILLSIGSTOPを除けば、Unixソフトウェアを書く際にシグナルハンドラを実装しない限り、シグナル受信時に「デフォルトの動作」が実行される。

SIGKILLSIGSTOPは、シグナルハンドラを用いてソフトウェア側でシグナル受信時の処理を上書きすることができない。いかなる場合にも、システムが定めた「デフォルトの動作」が実行される)

SIGTERM受信時の「デフォルトの動作」は「プロセスの異常終了」である。

ところでLinuxにおいては、PID=1のプロセスのみ、シグナル受信まわりの振る舞いが少し特殊である。具体的には、明示的にシグナルハンドラを実装したシグナルのみ、シグナル受信時に対応する処理が実行される。シグナルハンドラを実装していないシグナルでは、シグナル受信時に「デフォルトの動作」が実行されることはない。*1

つまり、例えばSIGTERMを受信した時、もしも対応するシグナルハンドラが実装されていないならば、「デフォルトの動作」は実行されず――実質的にSIGTERMが無視されたも同然の振る舞いとなる。

このため、「前提知識:その1」でも書いたように、PID=1のプロセスとして動作させるソフトウェアは、ちゃんとSIGTERMをハンドリングして後片付けを実施するように実装しておく必要がある。シグナル受信時に「デフォルトの動作」が実行されることを期待してはならない。

本題

以下の議論においては、docker run実行時にオプション--initを付与していないものとする。

コンテナを起動した時、特に何も指定しなければ、DockerfileのENTRYPOINTないしCMDに記述したコマンドが実行される。

ここで、ENTRYPOINTないしCMDexec形式でコマンドを記述していたならば、そのコマンドがPID=1のプロセスとなる。

一方で、shell形式を使用した場合、そのコマンドは/bin/sh -cのサブコマンドとして実行される。つまりPID=1にはならない。実際にはコンテナに同梱される/bin/shの種類によってPID=1になったりならなかったりするようだが、公式ドキュメントにはshell形式で記述したコマンドについて「実行ファイルはコンテナの PID 1 ではなく」と明記されているので、原則として「PID=1にはならない」と考えておくべきだろう。

さて、「前提知識:その1」にも書いたが、docker stopに起因するSIGTERMはPID=1のプロセスに送信される。

Dockerfileにてexec形式を使用した場合、SIGTERMENTRYPOINTないしCMDに記述したコマンドにたいして送信される*2。なぜならPID=1のプロセスとして動作しているからだ。なので、当該コマンドの実装において、適切なシグナルハンドリングを行うようにしておけば、docker stopにてトラブルが発生することはないだろう。

ところがshell形式を使用した場合、ENTRYPOINTないしCMDに記述したコマンドはPID=1のプロセスにはならないので、SIGTERMが直接届くことはない。

SIGTERMは当該コマンドの親プロセスであるPID=1のプロセスに届く。仮にPID=1のプロセスが/bin/sh -cだとすると、「前提知識:その2」で触れたが、SIGTERMに対応するシグナルハンドラが用意されていない状態では、実質的にSIGTERMは無視されるだろう。そして当然ながら、子プロセスである当該コマンドにSIGTERMが伝播することもない。

公式ドキュメントにも、shell形式のケースでは「実行ファイルは SIGTERM シグナルを受信しません」と書かれている。つまるところ、何も考えずにshell形式を使用して「デーモンとして振る舞うソフトウェア」を実行した場合には、通常はdocker stopでトラブルなくコンテナが即時終了するなんて期待しない方がよいだろう。ほぼ確実にタイムアウトによるSIGKILL送信で終了することになるはずだ。

……一応、shell形式を用いる場合への対応策は、なくはない。

仮にPID=1のプロセスが/bin/sh -cならば、そのサブコマンド――Dockerfileにshell形式で記述するENTRYPOINTないしCMDの中にtrap文を仕込むことで、/bin/sh -cのレベルでSIGTERMをハンドリングできる。

だから、例えば以下のDockerfileをビルドして実行したコンテナは、docker stopにて即座に停止する。

FROM ubuntu:latest

CMD trap 'exit 0' TERM; while :; do sleep infinity & wait $!; done

コツは2つある。1つ目は、trap文でシグナルを捉えることだ。2つ目は、時間がかかる処理(上記の例ではsleep(1))をバックグラウンドで実行させるようにして、/bin/sh -cがシグナルに即応できる状態にしておくことだ。

こういう方法もあるにはあるのだが、ENTRYPOINTCMDの中身がごちゃごちゃしそうなので、実用レベルの技法としてはお勧めできないと考えている。無理にワンライナーで書くよりも、シェルスクリプトに独立させた上でexec形式で実行させた方が良いだろう。

実際のところ、上記の例ではバックグラウンド実行中のsleep infinityを放置したままSIGTERMexit 0しているのだが、これは「sleep(1)なら放置して異常終了させても大丈夫だろう」という前提があるからだ。現実には、バックグラウンド実行中のプロセスを適切に終了させるのが筋な訳で、その辺の対応を付け加えようとした時、無理やりDockerfileにワンライナーで押し込むよりも、シェルスクリプトという「別のファイル」に分かりやすく記述した方が、メンテナンス性が向上するはずだ。

docker run--init

docker run実行時にオプション--initを付けた場合、PID=1のプロセスとして「initとして振る舞うコマンド」が動作するようになる。

この実体は、Dockerデーモンのシステムパス上で最初に見つかったdocker-initという名前の実行可能ファイルである。この記事を書いている時点では、既定ではtini由来の実行ファイルが使用されるようだ。

この機能の使いどころは、正直なところちょっと難しい気がする。

というのも、基本的に「必要なシグナルハンドラを実装した上で、Dockerfileにてexec形式を使用してPID=1のメインプロセスとして動作させる」という風にしておけば、--initは不要なのだ。

言い換えれば、諸々の事情によりメインプロセスとして動作させたいソフトウェアにシグナルハンドラを実装するのが困難な場合には、--initは役に立つ。PID=1で動作するdocker-initが、exec形式で指定した「メインプロセスとして動作させたかったソフトウェア」にシグナルを中継することで、中継されたシグナルの「デフォルトの動作」が実行される。

まあしかし、基本的にサーバ・サイドなどでデーモンとして振る舞うソフトウェアでは、シグナル受信時に「デフォルトの動作」に基づいて終了してもよいケースは少ない。大抵は、終了時に所定の手続きを実施する必要がある。ならば必要なシグナルハンドラを実装して、シグナルの受信をソフトウェア内部で検知して、終了処理を実行しなくてはならない。

そんな訳で、安易に--initに頼るのは良くない気がする。どちらかと言えば、この機能は「いざという時の代替手段」だと思う。

ちなみにDockerfileでshell形式を使用した場合、仮にdocker-initの子プロセスが/bin/sh -cとなるならば、シグナルが到達するのは/bin/sh -cまでとなり、ENTRYPOINTCMDに記述したコマンドには到達しない。/bin/sh -cSIGTERMの「デフォルトの動作」に基づいて終了しようと試みるだろう*3

以前書いた記事についての補足

以前書いた「バックグラウンド・プロセスとして動作するデーモン」を起動/停止させるケースでは、諸々の制約より、件のデーモンはPID=1のプロセスではなく子プロセスとして動作する仕組みになっていた。

そこで、まずラッパーとなるシェルスクリプトを用意して、exec形式で実行させるようにした。こうすることで、ラッパースクリプトはPID=1のプロセスとして動作するので、docker stopの際にSIGTERMを受信することが可能になる。

あとはラッパースクリプト内にSIGTERMに対応するシグナルハンドラを用意して、その中で子プロセスとして動作しているデーモンを正規の手段で終了させているだけである。

記事の蛇足として以下の記述をしたが:

なお、秘伝のデーモンがシグナル受信だけで適切に終了する(つまり本稿で言うなら「/etc/init.dスクリプトで停止」しなくても問題ない)場合、もしかしたら冒頭に挙げたCMD /etc/init.d/magic-daemon start; tail -f /dev/nullのような書き方をした上で、docker runの時にオプション--initを付与するだけで事足りるかもしれないが、あまり試していないので真偽は不明である。

今考えてみれば、このケースでは--initの有無に関係なく、シグナルが到達するのは/bin/sh -cになる。だから/bin/sh -c側でシグナルハンドラを用意するなどの対応が必須となる。

「本題」の後半に「shell形式を用いる場合への対応策」としてtrap文を用いる方法について述べたが、--init付きでdocker runするケースでも、やることは全く同じである。

あえてDockerfileに押し込むならこんな感じになるだろう。

FROM amazonlinux:latest

# 色々と準備(省略)

# コンテナ起動時に秘伝のデーモン magic-daemon を起動する。
# その後、コンテナを終了させないようにする。
CMD trap '/etc/init.d/magic-daemon stop; exit 0' TERM; /etc/init.d/magic-daemon start; while :; do sleep 1 & wait $!; done

しかし、こうやって無理やりワンライナーで書くのは少し無理がある。

結局は、以前の記事で最終的にラッパースクリプトdaemon-runner.shを用意したように、独立したシェルスクリプトに処理を記述した方がメンテしやすいだろう。

まとめ

とりあえずこうしておけば安パイだと思う。

  1. DockerfileのENTRYPOINTCMDではexec形式を使用すること。
  2. Dockerコンテナのメインプロセスとして動作させるソフトウェアには、最低でもSIGTERMのシグナルハンドリング処理を実装すること。他にも対応すべきシグナルが存在するならば、それらのハンドリング処理も実装すること。
  3. 以下の場合にのみ、docker run実行時にオプション--initを付与することを検討すること。
    1. Dockerコンテナのメインプロセスとして動作させたいソフトウェアにSIGTERMを含むシグナルのハンドリング処理を実装することが困難な場合。
    2. (あまりよろしくない手法なので控えるべきだが)Dockerコンテナのメインプロセスとして動作させたいソフトウェアの「シグナル受信時の処理」が、各シグナルの「デフォルトの動作」で問題ない場合。

*1:2年前の時点では、この「LinuxのPID=1」固有の振る舞いは把握していなかった。

*2:ただしdocker run実行時にオプション等で実行するコマンドを入れ替えていない場合に限る。

*3:もちろんtrap文を仕込んだ場合は話が別だが。

パケットフィルタリングで不正パケットをDROPするかREJECTするか問題

最近iptablesのルールを弄っていて「不正パケットをDROPするのとREJECTするのでは、どちらがベターなのか?」と疑問に思って少し調べたので、メモを残しておく。

現時点での個人的見解は「外部公開サーバではDROPを使った方がよさそう。他の環境では正直DROPREJECTのどちらでも大差なさそうだが、DROPが優勢か?」といった感じだ。

考えるにあたり、まずパケットフィルタリングを使用するシチュエーションとして以下のパターンを想定してみた。

  1. HTTPサーバなどの「外部にサービスを公開するサーバ」において、公開ポート以外に届いたパケットをフィルタリングするケース。
  2. (1) に加えて、例えば「国内からのアクセスだけ許可する」などの目的で、公開ポートに届いたパケットもフィルタリングするケース。
  3. 開発用サーバで送信元IPアドレスでフィルタリングする場合のように「特定のユーザにだけサーバを公開し、それ以外からはサーバの存在を隠蔽したい」というケース。

最初に (1) の場合、外部に公開されているポート*1が存在する時点で、Nmapなどのツールにて「そのIPアドレスでホストが稼働している」ことが容易に推測できてしまう。

現在の普通の外部公開サーバでは「公開ポート以外はファイアウォールで閉じる」という構成が一般的な訳だが、そんなことは攻撃者だって百も承知だろう。だから、ホストの存在が分かっていれば、公開ポート以外のフィルタリングがDROPREJECTのどちらであっても、攻撃者からすれば「十中八九フィルタリングされてますね、これは」で済んでしまうはずだ。

あくまでも攻撃者の興味は「開いているポート」にある。「フィルタリングされているポート」も「フィルタリングされていないが、閉じているポート」も、そこから内部に侵入できないという点でほぼ等しく価値がない。

そういう意味では、DROPREJECTのどちらでも大差はない気がする。

ただしREJECTの場合はICMPエラーメッセージを送り返す。DoSやDDoSの可能性まで考慮すると、REJECTのこの振る舞いが仇となる可能性がある。

単純な話、送信元を偽装したパケットでDoSやDDoSされると、「偽装された送信元IPアドレス」に向けてICMPエラーメッセージを送り返してしまう訳で、結果的にDoSやDDoSの片棒を担ぐことになってしまう可能性がある。

それと、ICMPエラーメッセージを返すということは、単純に考えても「受信パケット数と同じ個数のICMPパケット」の送信(応答)が発生する訳で、例えば通信量による従量課金が適用される環境では懐へのダメージが大きくなるかもしれない。

こういったケースまで考慮するならば、REJECTよりもDROPを使用した方が安心だろう。

次に (2) の場合だが、公開サーバである以上は、例えばWhoisDNSなどの情報を辿ってホストの存在を探り当てることは難しくないし、極端なことを言うならproxy経由で「ホストにアクセス可能なネットワーク空間」に潜り込まれてしまえば (1) と同じ条件になってしまう。

ホストの存在が分かってしまう以上、やはりDROPREJECTのどちらでも大差はなくて、しかしREJECTの場合はICMPエラーメッセージの応答が(以下略)、といった具合だろう。

結論は (1) と同じく「REJECTよりもDROPを使用した方が安心」だ。

最後に (3) の場合だが、これはちょっと判断が難しい。このケースでは「第3者からホストの存在を隠蔽したい」という暗黙の要求があると思うのだが、それは技術的に可能だろうか?

まずDROPは、IPの仕組みからすると少々不自然である。もしも本当に「ホストが存在しない/シャットダウンしている」ならば、大抵の場合はホストの最寄りのルータがホスト到達不能を意味するICMPエラーメッセージを送り返すだろう。しかしDROPは受信パケットを破棄するだけで、何も応答しない。「応答がない」という不自然さより、Nmapなどのツールにて「そのIPアドレスではホストが稼働しているが、パケットフィルタリングされている」と容易に推測されてしまう。

つまりDROPでは「ホストの存在を隠蔽する」という目的は達成できない。

ではREJECTではどうだろうか? 例えばiptablesでは、REJECTルールに「--reject-with icmp-host-unreachable」を付加することで、送り返すICMPエラーメッセージの中身を「ホスト到達不能」にすることができる。この機能を使用することで、あたかも「ホストが存在しないからルータがホスト到達不能を送り返した」かのように見せかけることができる。これによってホストの存在を隠蔽できそうである。

残念ながらこの方法は万全ではないようだ。本当にホストが存在しない場合、大抵のルータは「ホスト到達不能」を数秒に1個送り返すという、通信レート制限がかかっているかのような振る舞いをするらしい。一方でiptablesREJECTによる「ホスト到達不能」の返信は毎度律義に実行される。だからNmapによるスキャンを2回以上行い、「ホスト到達不能」の返信の挙動を確認することで、「ホストが存在しない」と「iptablesによるREJECT」のどちらによるものなのか推測することが可能なようだ。

……書き方が伝聞調なのは、私自身は経験がないのだが、Nmapのドキュメントにそのものズバリの記述があって、そこから引っ張ってきているからである。

It seems that Demetris is receiving ICMP host unreachable messages when trying to scan these IPs (or at least this one). Routers commonly do that when a host is unavailable and so they can't determine a MAC address. It is also occasionally caused by filtering. Demetris scans the other hosts on the network and verifies that they behave the same way. It is possible that only ICMP packets are filtered, so Demetris decides to try a TCP SYN scan. He runs the command nmap -vv -n -sS -T4 -Pn --reason 10.10.10.0/24. All ports are shown as filtered, and the --reason results blame some host unreachable messages and some nonresponsive ports. The nonresponsive ports may be due to rate limiting of host unreachable messages sent by the router. Many routers will only send one of these every few seconds. Demetris can verify whether rate limiting is the cause by running the scan again and seeing if the host unreachable messages come for exactly the same set of ports. If the ports are the same, it may be a specific port-based filter. If Nmap receives host-unreachable messages for different ports each time, rate limiting is likely the cause.

Bypassing Firewall Rules | Nmap Network Scanning

Nmapのドキュメントの記述を信じるならば、REJECTで「ホスト到達不能」を送り返すように偽装しただけでは、見破られてしまう可能性は十分にあるだろう。つまり「ホストの存在を隠蔽する」という目的が達成できるかどうか怪しいところである。

ならば、結局のところDROPREJECTのどちらでも大差はない気がする。まあ、REJECTを使う方法でも気休め程度の効果はあるかもしれないが……。

(もしかしたら、頑張ればiptablesでも「通信レート制限がかかっているかのような」ホスト到達不能の返信を再現できるかもしれないが、そこまでする意味があるかどうか、私には判断しかねる)

ところでREJECTを使う場合は、全ての受信NGケースにてREJECTして「ホスト到達不能」を返すようにする必要がある。DROPREJECTが混在していたり、REJECTにて複数種類のICMPエラーメッセージを返すようになっていると、振る舞いのちぐはぐさよりホストの存在が明らかになってしまうはずだ。

だから、例えばルータのポートフォワーディング機能で自宅サーバの特定ポートを公開するようなケースでは、ルータ側のパケットフィルタ設定も含めてREJECTにできないか検討することになる――まあ大概のルータのパケットフィルタではDROPが使われていて、しかもREJECTに変更できないことも多いはずだ。下手にサーバ側のフィルタリングだけREJECTにして、DROPREJECTが混在した状況にてしまうと、攻撃者に余計な情報を与えて刺激してしまいかねない。それならば、サーバ側もDROPにして統一した方がマシだろう。

VPSの類を使用している場合も、事業者によっては「管理コンソールから設定できるパケットフィルタ」と「サーバ・インスタンスのOSのパケットフィルタ」を併用できる訳で、ここでもDROPREJECTの統一性の問題が起こりうるだろう。併用する場合、管理コンソール側でREJECTを使うように変更できる気がしないので……やはりDROPで統一することになる気がする。

――「DROPREJECTのどちらでも大差はない」と書いたが、ホスト単体ではなく周辺環境まで考慮すると、現実にはDROPを使うことが多いのかもしれない。

あれこれ考えてみたが、一般的な外部公開サーバではDROPを使うようにしておいた方が無難そうだ。第3者からホストを隠蔽したいケースでは、理論的にはDROPREJECTのどちらでも大差なさそうな感じだが、しかし運用環境を考慮してDROPを使うことが多いのかもしれない。

*1:HTTPサーバならTCPの80番や443番が定番だろう。

各プラットフォームにおけるMACアドレスランダム化の振る舞い

これを書いている時点では、コンシューマ向け機器としては、AndroidiOSWindowsにおいてMACアドレスのランダム化を利用できる。

では具体的に、MACアドレスをランダム化したらどのように振る舞うのか? 参考文書へのポインタと現時点での振る舞いの要約を書き残しておく。

Android

要約
  • Android 8以降では、端末がネットワークに関連付けられていない場合に、新しいネットワークを探索する時にランダムなMACアドレスが使用される。
  • Android 9では、開発者向けオプション経由で、Wi-Fi接続時にランダムなMACアドレスを使用することができる。
    • デフォルトでは、Wi-Fi接続時のMACアドレスランダム化は無効化されている。
  • Android 10以降では、Wi-Fi接続時のMACアドレスランダム化が有効化されている。
    • 接続先ネットワークごとに、個別にランダム化の有効/無効を切り替えることが可能。
  • AndroidMACアドレスランダム化の振る舞いは「永続的なランダム化」と「非永続的なランダム化」の2種類がある。
    • 永続的なランダム化
      1. ネットワークプロファイル(SSIDやセキュリティタイプ)ごとに、初回接続時にランダムなMACアドレスが生成される。
      2. 2回目以降の接続時には、初回に生成されたランダムなMACアドレスが使いまわされる。
      3. 端末を工場出荷状態にリセットすると、生成済みの「ランダムなMACアドレス」は削除される。
    • 非永続的なランダム化
      1. DHCPのリース期間が期限切れとなり、かつ端末が前回切断してから4時間以上経過した状態にて、切断したネットワークに再度接続する際に、ランダムなMACアドレスが生成し直される。
      2. 使用中の「ランダムなMACアドレス」が、生成されてから24時間以上経過した後に、当該ネットワークに再度接続する際に、ランダムなMACアドレスが生成し直される。
      3. 上記以外のケースでは、前回生成された「ランダムなMACアドレス」が使いまわされる。
  • Android 10では常に「永続的なランダム化」が使用される。
  • Android 11では、デフォルトでは「永続的なランダム化」が使用される。開発者向けオプション経由で、常に「非永続的なランダム化」を使用するように変更できる。
  • Android 12では、デフォルトでは、一部のネットワークにたいして「非永続的なランダム化」が使用されて、それ以外では「永続的なランダム化」が使用される。開発者向けオプション経由で、常に「非永続的なランダム化」を使用するように変更できる。
    • 「非永続的なランダム化」が使用される条件だが、公式文書を読んでもいまいちよく分からなかった……。

iOS

要約
  • 端末がネットワークに関連付けられていない場合に、新しいネットワークを探索する時にランダムなMACアドレスが使用される。
  • 端末がネットワークに関連付けられていないか、もしくは端末のプロセッサがスリープ状態の場合に、ePNOスキャン実行時にランダムなMACアドレスが使用される。
  • iOS 14・iPadOS 14・watchOS 7以降では、デフォルトではWi-Fi接続時にランダムなMACアドレスが使用される。
    • 接続先ネットワークごとに、個別にランダム化の有効/無効を切り替えることが可能。
  • Wi-Fi接続時のMACアドレスランダム化の振る舞い:
    1. Wi-Fiネットワークごとに、初回接続時にランダムなMACアドレスが生成される。
    2. 端末を工場出荷状態にリセットすると、生成済みの「ランダムなMACアドレス」は削除される。
    3. iOS 15・iPadOS 15・watchOS 8以降では:
      1. 端末が前回切断してから6週間以上経過した状態にて、切断したネットワークに再度接続する際に、ランダムなMACアドレスが生成し直される。
      2. 端末にてネットワークの設定を削除してから2週間以上経過した状態にて、当該ネットワークに再度接続する際に、ランダムなMACアドレスが生成し直される。

Windows

要約
  • Windows 10にて「ランダムなハードウェアアドレス」などの名称で導入されたようだが、どのバージョンからなのかは不明。割と初期のころから導入済みだったようだが……。
  • 振る舞いとしては「オフ・オン・毎日変更する」の3種類を選択できる。
    • 「オフ」は文字通り「ランダムなハードウェアアドレスを使わない≒デバイスMACアドレスを使用する」ということだと思われる。
    • 「オン」や「毎日変更する」の振る舞いがよく分からない。
    • わざわざ「毎日変更する」を用意しているということは、「オン」はAndroidでいう「永続的なランダム化」みたいな振る舞いだと思うのだが、どうなのだろうか? 仮にそうだったとして、一度生成された「ランダムなMACアドレス」が削除されるパスは存在するのだろうか?
    • 「毎日変更する」にしても、律儀に「24時間経過したらMACアドレスを再生成してネットワークに再接続」みたいなことはしない気がする*1のだけど、実際にはどんな感じだろうか?
参考文書

なし。MACアドレスランダム化の挙動についてまとまった公式の文書が見つからない……。

まとめ

AndroidiOSも、徐々に「ランダムなMACアドレスを生成し直す」方向に向かっているようだ。

それはそうとして、MicrosoftMACアドレスランダム化の具体的な挙動について情報を公開してほしい(すでに公開しているなら、もうちょっと分かりやすいところに置いてくれないだろうか)。

*1:なぜならWi-Fi接続が一時的に切れることになるから。

Raspberry Pi向けのクロスコンパイル環境の整え方 2022年夏

4年ほど前にRaspberry Pi向けのC/C++ロスコンパイル環境を整えたのだけど、当時と今では環境構築の方法が異なるようだ。

ということで、現時点(2022年8月)での「Linux上でRaspberry Pi向けクロスコンパイル環境を構築する方法」についてメモを残しておく。

古い方法:GitHubに公開されているツールチェーンを使う

4年前の時は、https://github.com/raspberrypi/toolsで公開されていたツールチェーンをUbuntuにcloneして使った。

git clone https://github.com/raspberrypi/tools

最近このリポジトリを見たところ「このツールチェーンはもう古いから、別のを使え」との一文が……。

最近の方法:ホスト環境の「公式リポジトリのクロスコンパイル用ツールチェーン」を使う(※ディストロの種類やバージョンに注意)

前項のリポジトリには、代替として「Ubuntuなどの公式リポジトリのクロスコンパイル用ツールチェーン」を使う案が提示されている。

# Raspberry Pi OS 32bit版向けのツールチェーンを入れる場合:
sudo apt-get install gcc-arm-linux-gnueabihf

# Raspberry Pi OS 64bit版向けのツールチェーンを入れる場合:
sudo apt-get install gcc-aarch64-linux-gnu

ただしこの方法には「明示されていない罠」が存在する。少なくとも、ターゲット環境がRaspberry Pi OS (bullseye) であるならば、ホスト環境はUbuntu 20.04ないしDebian 11である必要がある。

例えばUbuntu 22.04にて上記の方法でクロスコンパイル環境を整えた場合、深く考えずに実行ファイルをクロスコンパイルしたならば、十中八九Raspberry Pi OS上で動作しない。

理由はglibcのバージョン違いにある。

Debian 11 (bullseye) のlibc6libc6-amd64のバージョンから推測できるが、Raspberry Pi OS (bullseye) のglibcのバージョンは2.31である。

一方でUbuntu 22.04の公式リポジトリからARM向けクロスコンパイル用ツールチェーンをインストールした場合、クロスコンパイル用のglibcは、32bit向け64bit向けも、バージョンは2.35である。

普通にビルドするとglibcは動的リンクされるので、Ubuntu 22.04でクロスコンパイルすると「実行時にglibc 2.35が動的リンクされる」想定の実行ファイルが生成される。

このファイルをRaspberry Pi OS (bullseye) に持っていって実行しても、実行時にglibc 2.35が見つからず、動的リンクに失敗してしまい、起動できない。

この問題への対策は、2通り考えられる。

1つ目は、ホスト環境として「クロスコンパイル用のglibcのバージョンがRaspberry Pi OS (bullseye) のglibcと同じ2.31となるディストリビューション」を選択する方法である。

Debian 11 (bullseye) なら確実に大丈夫だ。Ubuntuでも20.04ならglibc 2.31なので問題は生じない。

2つ目は、クロスコンパイル時にglibcを静的リンクしてしまう方法である。glibcを動的リンクするのではなく、静的リンクして丸抱えしてしまえば、実行時にバージョン違いの問題に悩まされることはない。

とはいえ静的リンクすると実行ファイルが肥大化するし、glibcにセキュリティ修正などが発生するたびにアプリを再ビルドして「修正済みのglibc」を実行ファイルに同梱しなくてはならなくなる。

そんな訳で、個人的には、静的リンクは「最終手段」として温存しておきたいところである。

ということで、Raspberry Pi OS (bullseye) で動かすソフトウェアをクロスコンパイルするならば、Debian 11 (bullseye) かUbuntu 20.04にクロスコンパイル環境を構築するのがよさそうだ。Windows使いならどちらもWSLでインストールできる。他の仮想環境ないし実機上にホスト環境を用意するなら、今ならRaspberry Pi Desktopもアリかもしれない。

最近の別解:Raspberry Pi GCC Toolchainsを使う

サードパーティ*1になるが、Raspberry Pi GCC Toolchainsという、Raspberry Pi向けに色々とチューニングされたGCCツールチェーンを配布しているプロジェクトがある。

このプロジェクトが配布しているクロスコンパイル用ツールチェーンならば、ある程度はホスト環境について自由が利くようだ。

ただしこのツールチェーンの実績や安定性については不明である。

*1:つまりRaspberry Pi公式のプロジェクトではなさそう。

書籍購入:『コーディングを支える技術』

個人的な調べ物の資料として購入。

言語機能について歴史を踏まえて書いてある本なので、この本の記述を踏み台にして歴史的文献に当たればよさそう。

――そうだよね、「コの業界は歴史が浅い」と言いつつも、高水準プログラミング言語の観点でも60年を超える積み重ねがあるんだよなあ。

書籍購入:『インサイドWindows 第7版 上』

そろそろ日本語版の下巻が出るようなので、第6版からの買い替え。

あとで読む。

Windowsも色々と変わっているので、いざという時にポインタとなる資料がないと心許ない。

書籍購入:『Linuxで動かしながら学ぶTCP/IPネットワーク入門』

LinuxのNetwork Namespaceについての参考資料として購入。

本来意図しているだろう「TCP/IPネットワークの入門書」としては……個人的に、TCP/IPまわりは独学かつ「机上の知識」ベースな人なので、「仮想環境とはいえ実際の挙動を確認できるのは便利だよね」と思いながら読み進めている。

まあ、ある程度カチッとしたカリキュラムを考えるならば、「本書+座学系のTCP/IP入門書」のセットで、チューター付きで講義と実習をする感じかなあ。