Threadripper PRO構成でファイルサーバーを新調した

これは自宅のファイルサーバーを新調したときのハードウェア選定と検証内容のメモです。 Threadripper PRO ベースで構成しました。

購入品と価格

購入時点の単価は以下の通り。

パーツ 購入時価格 メモ
(ebayで購入、新品)Gigabyte MC62-G40 Rev 1.0 $489.27 WRX80マザーボード
(ebayで購入、新品)AMD Ryzen Threadripper Pro 3975WX $730.50 32C/64T CPU
(aliで購入、新品)SlimSAS SFF-8654 4i → SATA x4 ケーブル 1m $7.08 SFF-8654 4i から SATA x4 への分岐ケーブル
(国内通販で購入、新品)東芝 MG11ACA24TE 24TB 84,980円 HDD
(国内通販で購入、新品)Antec 900 34,320円 ケース
(国内通販で購入、新品)Noctua NH-U14S TR4-SP3 13,625円 CPUクーラー
(中古)DDR4 ECC UDIMM 32GB N/A 既存パーツを流用

ハードウェア選定

AMD Ryzen Threadripper Pro 3975WX

項目 内容
製品 AMD Ryzen Threadripper Pro 3975WX
世代 / アーキテクチャ Zen 2 世代 Threadripper PRO
コア / スレッド 32コア / 64スレッド
ベースクロック 3.5GHz
最大ブーストクロック 4.2GHz
L2キャッシュ 16MB
L3キャッシュ 128MB
TDP 280W
メモリ DDR4-3200、8チャネル
PCIe PCIe 4.0、128レーン

PCIeレーン数の多さが魅力です。新品で$489.27は破格でした。 なお、中古のものはベンダーロック(例えばLenovo機器からの取り外し品なら同じLenovoのマザーボードとの組み合わせでしか動作しない)がかかってるものが多く、選定の対象外としたほうが無難です。

server_manual__MC62-G40_e_1001.pdf p.8

Gigabyte MC62-G40

項目 内容
製品 Gigabyte MC62-G40
チップセット AMD WRX80
CPUソケット sWRX8
対応CPU AMD Ryzen Threadripper PRO 3000 / 5000 WX-Series
メモリ 8-channel, 8 x DDR4 ECC/Non-ECC DIMM slots, Up to 3200 MT/s
SATA オンボード SATA 6Gb/s x4
SlimSAS SlimSAS SFF-8654 4i x3
PCIe PCIe x16 (Gen4 x16) x 6, PCIe x16 (Gen4 x8) x1
管理機能 BMC / IPMI

SlimSAS x3 を SATA x4 に分岐して 12ポート、オンボード SATA x4 と合わせて最大16ポートのSATAが使用できます。 PCIe拡張スロット数も十分です。

後述しますが、実際に検証するまで分からなかったよい点として、HDDのスタッガードスピンアップに対応していました。 PUISに対応したHDDで機能を有効にしておくと、通電のタイミングではなくBIOS起動後の処理でHDDを順番にスピンアップしてくれます。 これによってスピンアップ時の電源負荷を散らしてくれるので、小さな電源でHDD群を運用できます。電源にも優しい機能です。

課題: CPUの固定にトルクレンチが必要

Threadripper用のもの(あるいは同等品)が手持ちになければ購入する必要があります。

課題: 独自仕様のフロントパネルヘッダー

フロントパネルヘッダーが独自仕様で、使用する場合は注意が必要。マザーボードによっては別途変換用のケーブルが必要です。 BMCで管理を行えればよいため、私はこの端子を使っていません。 その後、電源オン用途だけ使用するようになりました。ピンヘッダーのサイズは通常の2.54mmなので特に使用は難しくはありません。

server_manual__MC62-G40_e_1001.pdf p.22

メモリ

メモリは家にあった DDR4 ECC UDIMM 32GB (M391A4G43MB1-CTD) を使うことにしました。QVLに未記載ですが安定しています。

課題: LRDIMMの互換性

同じくQVLに未記載のLRDIMMも試しましたが、こちらは安定しませんでした。

課題: LRDIMMの発熱

LRDIMMは温度も高くなりやすく、運用時はメモリ周辺に風を当てるファンが必須になりそうでした。

BMC/IPMIまわり

2026年6月15日 追記: BMCのファームウェアアップデート (13.06.25)を実施して評価中です。以下の課題は解消する可能性があります。

課題: DIMM温度センサーの不安定さ

BMCまわりでは、DIMMの温度センサーを見失うことがありました。タイミングの問題なのか、センサーがずっと無効扱いになることがあります。BMCを含めて再起動すると、うまく拾えることもありました。

課題: アラーム閾値を保存できない謎仕様

ファンの速度がデフォルトのアラーム閾値に引っかかりました。閾値を変更しても保存できず、電源オフで初期値に戻ってしまうため、BMC側で対処するのは諦めました。最終的にはPrometheus側の監視ロジックを調整して対応しています。

課題: 謎のライセンス切れ

License is not available for the requested URL. [code:15003] というエラーが発生し、リモートコントロールなどの機能が使えなくなる。これはファームウェアアップデートで解消した。

PrometheusでBMC/IPMIを監視する

BMC/IPMIの監視は ipmi_exporter を使い、Prometheus Operator の ServiceMonitorPrometheusRule で実装しました。

ここではサーバー1台を監視する前提で書きます。BMCのIPアドレスはここでは 192.0.2.10 とします。

ファイル構成

server-metrics/
├── kustomization.yaml
├── ipmi-exporter-deployment.yaml
├── ipmi-exporter-service.yaml
├── ipmi-exporter-servicemonitor.yaml
├── ipmi-exporter-networkpolicy.yaml
└── server-ipmi-rules.yaml

ServiceMonitor では /ipmi を scrape し、__param_target にBMCのIPアドレスを渡します。bmcserver ラベルも付けておくと、PromQLやAlertmanager側で扱いやすくなります。

endpoints:
  - port: http
    path: /ipmi
    interval: 1m
    scrapeTimeout: 30s
    params:
      module:
        - mc62-g40
    relabelings:
      - targetLabel: __param_target
        replacement: 192.0.2.10
      - targetLabel: instance
        replacement: 192.0.2.10
      - targetLabel: bmc
        replacement: 192.0.2.10
      - targetLabel: server
        replacement: MC62-G40

BMC監視は管理系ネットワークへの通信になるため、NetworkPolicyで通信先を絞り、Prometheusから exporter への TCP/9290 と、exporter からBMCへの UDP/623 だけを許可します。

ingress:
  - from:
      - podSelector:
          matchLabels:
            app.kubernetes.io/name: prometheus
    ports:
      - protocol: TCP
        port: 9290

egress:
  - to:
      - ipBlock:
          cidr: 192.0.2.10/32
    ports:
      - protocol: UDP
        port: 623

アラートは、exporterのscrape失敗、IPMIセンサー取得失敗、シャーシ電源OFF、センサー状態、ファン回転数などを見るようにしました。

ファン速度については、BMC側の閾値が運用に合わなかったため、Prometheus側で実運用に合わせて調整しました。 次のように、BMCのstate系メトリクスでは一部のファンを除外し、必要な回転数は別ルールで見る形にしています。

- alert: ServerIPMISensorCriticalState
  expr: |
    ipmi_temperature_state{bmc="192.0.2.10"} >= 2
    or ipmi_fan_speed_state{bmc="192.0.2.10", name!~"SYS_FAN[678]"} >= 2
    or ipmi_voltage_state{bmc="192.0.2.10"} >= 2
    or ipmi_current_state{bmc="192.0.2.10"} >= 2
    or ipmi_power_state{bmc="192.0.2.10"} >= 2
    or ipmi_sensor_state{bmc="192.0.2.10"} >= 2
  for: 5m
  labels:
    severity: critical

- alert: ServerIPMISensorWarningState
  expr: |
    ipmi_temperature_state{bmc="192.0.2.10"} == 1
    or ipmi_fan_speed_state{bmc="192.0.2.10", name!~"SYS_FAN[678]"} == 1
    or ipmi_voltage_state{bmc="192.0.2.10"} == 1
    or ipmi_current_state{bmc="192.0.2.10"} == 1
    or ipmi_power_state{bmc="192.0.2.10"} == 1
    or ipmi_sensor_state{bmc="192.0.2.10"} == 1
  for: 5m
  labels:
    severity: warning
- alert: ServerFanSpeedCritical
  expr: |
    ipmi_fan_speed_rpm{bmc="192.0.2.10", name=~"SYS_FAN[678]"} < 300
  for: 5m
  labels:
    severity: critical

- alert: ServerFanSpeedWarning
  expr: |
    (
      ipmi_fan_speed_rpm{bmc="192.0.2.10", name=~"SYS_FAN[678]"} < 500
      and on(instance, id, name)
      ipmi_fan_speed_rpm{bmc="192.0.2.10", name=~"SYS_FAN[678]"} >= 300
    )
  for: 5m
  labels:
    severity: warning

SlimSASとSATA

課題: SlimSASの Auto でSATAデバイスを認識しない

SlimSASの動作モードを Auto にしていると、SATAディスクが自動認識されませんでした。そのため、BIOS側でSlimSASの動作モードを明示的に設定する必要がありました。

よかった点: スタッガードスピンアップ対応

SlimSAS経由のSATAはスタッガードスピンアップに対応していました。HDD側で PUIS を有効にすると、電源投入直後ではなく、BIOS起動時にドライブが順次スピンアップします。

PUISを有効にする場合は、対象ディスクを確認してから hdparm を実行します。 以下は /dev/sda に対して有効化する例です。

/dev/sda は環境によって変わるため、実行前に lsblk/dev/disk/by-id/ で対象ディスクを確認してください。

hdparm -s1 --yes-i-know-what-i-am-doing /dev/sda

ホットプラグに対応しているので、毎回再起動せずにディスクを取り外すこともできます。

echo 1 > /sys/block/sda/device/delete

ProxmoxでのPCI passthrough

端末にはProxmoxをインストールし、ゲストとしてTrueNASでファイルサーバーを立てています。 ストレージ関連のデバイスをパススルーしてゲストに割り当てて、ゲスト側からデバイスを直接使う構成がおすすめです。

qm set 120 -hostpci0 0000:45:00.0,pcie=1
qm set 120 -hostpci1 0000:67:00.0,pcie=1
qm set 120 -hostpci2 0000:68:00.0,pcie=1

対象デバイスは vfio-pci にバインドしておきます。

echo "options vfio-pci ids=1022:7901" > /etc/modprobe.d/vfio.conf
update-initramfs -u
reboot

まとめ

WRX80 + Threadripper PRO 構成はNASとしては過剰なスペックですが、多機能サーバーとして使用するならPCIeレーン数は多いほうが安心です。 もし同じような環境を構築される場合、注意点としては、少なくとも私のセットアップでは、BMCのセンサー取得が不安定になること、ファン閾値が保存されないこと、SlimSASのSATA認識には明示的な設定が必要だったことに注意してください。

Zstandard 共有辞書をクローラーの保存形式に組み込む

要旨

長期運用しているクローラーでは、保存データ量がそのままストレージ費用に反映されます。圧縮率の改善は、インフラ費用とアーカイブ運用の両方に効く、継続的に取り組む価値のあるテーマです。

今回の検証では、まず Zstandard(以下 zstd)を辞書なしで使う標準方式を参照値として測定しました。そのうえで、サイト単位で学習した辞書を各レコードに適用するサイト単位辞書方式を比較しました。

結果として、サイト単位辞書方式は標準方式と比べてアーカイブ本体サイズ比を 25.7〜66.1% 削減しました。さらに、共有辞書の学習サンプル、辞書サイズ、学習条件を調整すると、辞書込み実効サイズ比は元データの 7.689〜14.251% になりました。

zstd の共有辞書は、クローラーの蓄積データに対して保存効率を改善する有力な選択肢だと考えられます。一方で、辞書を使う方式では、書き込み、読み込み、再学習、互換性管理で考慮点が増えます。圧縮率だけで採用を決めず、保存形式全体の設計として評価するのが現実的です。

共有辞書の基本

zstd の辞書圧縮は、代表的なサンプルから辞書を学習し、その辞書を圧縮時と展開時の両方で参照する方式です。辞書には、対象データに繰り返し現れるバイト列や構造が入ります。圧縮対象のレコードが短い場合でも、辞書側に共通パターンがあれば、個々のレコードをより短く表現できます。

クローラーが保存するページには、HTML テンプレート、メタデータ、定型的な属性値、本文の書式、サイト固有のマークアップなどが繰り返し含まれます。共有辞書方式の狙いは、こうした共通部分を各ページの圧縮データに繰り返し持たせるのではなく、辞書側に寄せることです。

ここでいう共有辞書とは、1 つのページ専用の辞書ではなく、複数のページで共有する辞書です。たとえば、あるサイトの一定期間のページ群から辞書を学習し、そのサイトの保存レコードに同じ辞書を使います。圧縮データには、復元に必要な辞書 ID や辞書世代を記録します。

この方式では、圧縮データだけでは展開できません。読み込み時には、圧縮時に使った辞書を解決し、展開処理に渡す必要があります。したがって、共有辞書は圧縮率の改善手段であると同時に、保存形式に新しい管理対象を追加する設計です。

クローラー保存データでの償却

共有辞書を使う場合、辞書ファイルそのものも保存対象になります。したがって、評価では「各レコードの圧縮後サイズ」だけでなく、「共有辞書のサイズをどの保存量に負担させるか」も見る必要があります。

たとえば 20MiB の辞書を 1GiB の保存データだけで使う場合、辞書の負担は相対的に大きくなります。同じ 20MiB の辞書を 16GiB の保存データで共有できる場合、1 レコードあたり、または 1GiB あたりの辞書負担は小さくなります。この考え方を、本稿では辞書サイズの償却と呼びます。

今回の比較では、まずアーカイブ本体サイズ比を見ます。これは、評価対象レコードを圧縮した後のアーカイブ本体サイズを、圧縮前の本文サイズで割って求めます。

アーカイブ本体サイズ比 = 圧縮後のアーカイブ本体サイズ / 圧縮前の本文サイズ

共有辞書を使う方式では、別に辞書込み実効サイズ比も見ます。これは、アーカイブ本体に共有辞書の償却分を加えた実効保存量を、圧縮前の本文サイズで割った比率です。

辞書込み実効サイズ比 = (圧縮後のアーカイブ本体サイズ + 共有辞書の償却分) / 圧縮前の本文サイズ

この指標を見ることで、アーカイブ本体だけでは有利に見える大きな辞書が、辞書サイズ込みでも採用できるかを確認できます。

検証方法

対象は 3 種類のサイトデータです。本文ではサイト名を匿名化し、Site ASite BSite C と表記します。

ケース 主な言語 サイト種別
Site A 日本語 小説投稿サイト
Site B 日本語 小説投稿サイト
Site C 英語 百科事典型サイト

検証では、データを 3 つの役割に分けます。辞書を作るための学習データ、候補辞書を比べるための選定用サンプル、最後に結果を確認するための評価データです。評価データは辞書学習や候補選定には使わず、選んだ候補を全件に適用してアーカイブ本体サイズ比を測るために使います。

まず、標準方式とサイト単位辞書方式を比較します。標準方式は、辞書管理を持たない通常の zstd 圧縮です。辞書方式との比較条件をそろえるため、圧縮レベルは level 22 にしています。

サイト単位辞書方式は、サイト単位で学習した辞書を各レコードの zstd 圧縮に適用する構成です。以下では短く、サイト辞書とも呼びます。辞書方式を導入したときに、まず到達しやすい基準と考えられます。

標準方式とサイト辞書の比較

辞書を使わない標準方式を参照値として置きます。標準方式には共有辞書がないため、ここではアーカイブ本体サイズ比だけを示します。

ケース アーカイブ本体サイズ比
Site A 26.604%
Site B 32.554%
Site C 14.011%

次に、サイト辞書を同じ償却対象データ量で見ます。サイト辞書でも共有辞書を保存するため、辞書込み実効サイズ比は償却対象データ量によって変わります。

ケース 償却対象データ量 アーカイブ本体サイズ比 辞書込み実効サイズ比 共有辞書サイズ
Site A 1GiB 9.014% 9.112% 1.00 MiB
Site A 8GiB 9.014% 9.026% 1.00 MiB
Site A 16GiB 9.014% 9.020% 1.00 MiB
Site B 1GiB 15.083% 15.181% 1.00 MiB
Site B 8GiB 15.083% 15.095% 1.00 MiB
Site B 16GiB 15.083% 15.089% 1.00 MiB
Site C 1GiB 10.415% 10.659% 2.50 MiB
Site C 8GiB 10.415% 10.446% 2.50 MiB
Site C 16GiB 10.415% 10.430% 2.50 MiB

この結果から、zstd 辞書方式では、まず辞書の有無が結果に影響していることが分かります。特に Site ASite B では、単純なサイト辞書だけでアーカイブ本体サイズ比が半分以下になっています。

Site C は英語の定型的な HTML データで、標準方式の時点でも Site ASite B より小さく圧縮されています。ただし、これは「英語のほうが常に縮みやすい」という単純な話ではありません。文字種の違いに加えて、HTML テンプレートの反復量、ページ長の分布、メタデータや属性値の比率が重なった結果として見るのが妥当です。

圧縮と展開のフロー

辞書なしの標準方式では、圧縮データだけで展開できます。保存形式としては単純で、データの寿命が長いアーカイブでも扱いやすい構成です。

書き込み:
  HTTP レスポンス本文
    -> zstd 圧縮
    -> 圧縮データを保存

読み込み:
  圧縮データを取得
    -> zstd 展開
    -> HTTP レスポンス本文

共有辞書方式では、圧縮時と展開時の両方で同じ辞書が必要になります。保存データには、圧縮データ本体に加えて、どの辞書で圧縮したかを示す辞書 ID を記録します。

書き込み:
  HTTP レスポンス本文
    -> 対象サイトまたは対象データ群の辞書 ID を決定
    -> 対応する共有辞書を使って zstd 圧縮
    -> 圧縮データと辞書 ID を保存

読み込み:
  圧縮データと辞書 ID を取得
    -> 辞書 ID から共有辞書を解決
    -> 共有辞書を使って zstd 展開
    -> HTTP レスポンス本文

方式ごとの違いを整理すると、次のようになります。

観点 標準方式 共有辞書方式
圧縮時 レコード単体で圧縮できる 辞書 ID の決定と辞書ロードが必要
展開時 圧縮データだけで復元できる 圧縮時と同じ辞書が必要
保存メタデータ 圧縮方式とレベルが中心 辞書 ID、辞書世代、互換性情報が必要
再学習 不要 新しい辞書を成果物として管理する
障害時 圧縮データがあれば復元可能 辞書欠損時のリカバリが必要

この違いは、圧縮率の改善と引き換えに発生するアーキテクチャ上のコストです。共有辞書方式を採用する場合は、圧縮後サイズだけでなく、辞書 ID の管理、辞書の配布、辞書キャッシュ、古い辞書の保持期間を合わせて設計します。

圧縮・展開ベンチマーク

共有辞書方式では、圧縮時と展開時の両方で、辞書がどこにあるかによって性能特性が変わります。特にランダムアクセスが多いワークロードでは、辞書キャッシュが効く場合と効かない場合の差が出ます。

参考値として、Site A の評価データから 200 件を取り出し、標準方式と共有辞書方式の圧縮・展開時間を測りました。圧縮レベルは level 22、共有辞書サイズは 2.00MiB、測定は 5 回実行し、中央値を示しています。測定対象の raw データは 10.61MiB、1 レコードあたり平均 53.1KiB です。

方式 辞書状態 圧縮中央値(200件) 展開中央値(200件) 圧縮後サイズ比
標準方式 辞書なし 2,155.9ms 5.5ms 26.36%
共有辞書方式 メモリ上に辞書あり 4,060.7ms 2.9ms 9.35%

この測定では、共有辞書方式の圧縮は標準方式より遅くなりました。一方で、展開は辞書がメモリ上にある場合、標準方式より短くなっています。これは、このサンプルでは辞書方式の圧縮後サイズが小さく、展開する入力バイト数が減っているためと考えられます。ただし、展開時間はCPU、データサイズ、辞書キャッシュの状態に影響されるため、この結果だけで一般化するのは避けます。

辞書ロードの影響を見るため、同じ圧縮データに対して展開時の辞書解決を変えた測定も行いました。

辞書の扱い 展開中央値(200件) 説明
メモリ上の辞書を再利用 2.9ms アプリケーション側で辞書キャッシュが効いている状態
ローカルファイルから1回ロード 5.9ms 200件の展開前に辞書を1回読み込む状態
レコードごとに辞書をロード 38.7ms キャッシュが効かず、各レコードで辞書解決が発生する状態

ローカルファイルからのロードは、OSのファイルキャッシュが効いた可能性があります。そのため、永続ストレージの物理I/Oを代表する値ではなく、同一環境での参考値として扱います。

ネットワークから辞書を取得するケースは、今回のローカル測定では実測していません。概算する場合は、次のように分解します。

ネットワーク辞書取得時の読み込み時間
  ~= 圧縮データ取得時間 + ネットワーク往復遅延 + 辞書転送時間 + 展開時間

辞書転送時間
  ~= 共有辞書サイズ / 実効スループット

大きな共有辞書を採用すると、アーカイブ本体サイズ比は改善しやすくなります。一方で、辞書キャッシュが効かない読み込みでは、辞書サイズがそのまま追加取得量になります。したがって、保存量の評価では辞書込み実効サイズ比を見ますが、性能評価ではキャッシュヒット率、辞書ロード時間、辞書切り替え頻度を別に測る必要があります。

共有辞書のチューニング

ここからは、標準的なサイト辞書からさらに保存効率を改善するために試したチューニング手法です。zstd の共有辞書そのものの一般論ではなく、クローラーの保存データに合わせて辞書を設計するための候補探索です。

今回のチューニングでは、辞書サイズ、学習サンプルの作り方、辞書学習パラメータ、候補選定の安定性を調整しました。各要素の役割は次のとおりです。

手法 変えるもの 期待する効果 増えるコスト
Large Dictionary 共有辞書サイズ 長い定型構造やページ種別ごとの差分を辞書に入れやすくする 辞書保存量、ロード時間、メモリ使用量
学習サンプル設計 辞書に渡すレコード構成 URL構造、本文長、圧縮しにくいレコードの偏りを抑える 学習前処理、サンプル管理
学習パラメータ調整 サンプル数やtrainer条件 保存形式を変えずに辞書の中身を調整する 再現性管理、build時間
安定性診断 要求辞書サイズと実辞書サイズのズレ、クラスタ数 圧縮率だけが良い不安定な候補を避ける 診断値の記録と判定ロジック

Large Dictionary は、辞書ファイルを大きくするだけでは効果が安定しにくい場合があります。大きくした容量に保存量へ効くパターンを入れるため、学習サンプル設計と組み合わせて評価します。

学習サンプル設計では、URL 構造、本文長、訓練データ上での圧縮しにくさを使って、辞書に渡すレコードの代表性を整えます。これは読み込み時に多数の辞書を切り替えるためではありません。単一または少数の共有辞書を維持したまま、辞書に入る素材の偏りを抑えるための設計です。

チューニング手法の比較

チューニング候補は、共有辞書をどの程度の保存量で償却できるかによって採用しやすさが変わります。小さな保存量に対して大きな辞書を使うと、アーカイブ本体サイズ比が良くても、辞書込み実効サイズ比では不利になる場合があります。

次の表は、償却対象データ量ごとに、サイト辞書とチューニング候補の辞書込み実効サイズ比を比較したものです。

ケース 償却対象データ量 サイト辞書 実効サイズ比 チューニング候補 実効サイズ比 チューニング候補 共有辞書サイズ
Site A 1GiB 9.112% 8.467% 6.25 MiB
Site A 8GiB 9.026% 7.819% 22.99 MiB
Site A 16GiB 9.020% 7.689% 22.99 MiB
Site B 1GiB 15.181% 14.251% 6.25 MiB
Site B 8GiB 15.095% 13.310% 20.96 MiB
Site B 16GiB 15.089% 13.191% 20.96 MiB
Site C 1GiB 10.659% 9.952% 6.25 MiB
Site C 8GiB 10.446% 9.540% 30.43 MiB
Site C 16GiB 10.430% 9.317% 80.00 MiB

チューニング候補のアーカイブ本体サイズ比と共有辞書サイズは次のとおりです。

ケース 償却対象データ量 アーカイブ本体サイズ比 辞書込み実効サイズ比 共有辞書サイズ
Site A 1GiB 7.618% 8.467% 6.25 MiB
Site A 8GiB 7.394% 7.819% 22.99 MiB
Site A 16GiB 7.394% 7.689% 22.99 MiB
Site B 1GiB 13.665% 14.251% 6.25 MiB
Site B 8GiB 12.908% 13.310% 20.96 MiB
Site B 16GiB 12.908% 13.191% 20.96 MiB
Site C 1GiB 9.803% 9.952% 6.25 MiB
Site C 8GiB 9.327% 9.540% 30.43 MiB
Site C 16GiB 8.963% 9.317% 80.00 MiB

同じアーカイブ本体サイズ比の行で、辞書込み実効サイズ比だけが変わる場合があります。これは同じ候補を選んでいても、共有辞書を負担する保存量が大きくなることで、評価データあたりの辞書コストが小さくなるためです。一方で、Site C の 16GiB では、より大きな辞書を使う候補のほうが実効保存量を下げています。

自動辞書トレーニングの診断とガードレール

辞書は、学習データを渡せば常に意図どおりのサイズと性質で得られるわけではありません。今回の検証では、要求した辞書サイズと実際に生成された辞書サイズのズレを診断値として扱いました。

大きめの辞書サイズを要求しても、trainer が返す実際の辞書が大きく下回る場合があります。この状態を単純に失敗とみなす必要はありませんが、圧縮率が伸びず、かつ実際の辞書サイズが要求サイズに対して極端に小さい場合は、不安定な候補として扱います。

診断では、次の値を記録します。

診断値 目的
要求辞書サイズ trainer に要求した辞書サイズ
実辞書サイズ 実際に生成された辞書サイズ
実辞書サイズ / 要求辞書サイズ 要求に対して辞書がどの程度作られたか
学習サンプル数 辞書学習に使ったレコード数
学習データの総バイト数 辞書学習に使ったデータ量
縮退フラグ 辞書が不安定な状態に見える候補を識別するためのフラグ

この検証では、実辞書サイズが要求辞書サイズの 25% 未満で、性能も良くなく、学習データ量に対して辞書が十分に形成されていない候補を、不安定な点として扱う方針にしました。不安定な点は即座に失格にするのではなく、同等性能の安定した候補があれば選ばない、という扱いです。

また、クラスタ型の候補では、学習されたクラスタ数や代替扱いになったクラスタ数も見ます。候補の圧縮率が良く見えても、解釈可能なクラスタ構造がほとんど残っていない場合は、運用に載せる候補としては扱いにくくなります。

候補選定では、圧縮後サイズだけでなく、次の観点も合わせて見ます。

観点 採用方針
圧縮後サイズ 最小値から一定範囲内なら同等候補として扱う
build 時間 同等候補では短いものを優先する
辞書や補助成果物のサイズ 同等候補では小さいものを優先する
実辞書サイズの安定性 実辞書サイズが極端に崩れていないものを優先する
構造の解釈可能性 クラスタ数などが極端に潰れていないものを優先する

この方針は、保存量の最小化だけを追うよりも保守的です。ただし、長期保存のアーカイブでは、わずかな圧縮率差より、再現性、展開可能性、運用時の説明しやすさが重要になる場面があります。

Appendix: チューニング手法の具体例

ここからは、チューニング手法を疑似コードで整理します。実行可能な Python コードではなく、方式ごとに何を変えているかを説明するためのものです。実際の保存形式では、圧縮データに加えて、復元に必要な辞書 ID や辞書世代もメタデータとして保存します。

normalize_routesample_quotatrain_dictionary_with_options などは抽象化した補助関数です。実装では、サイトの URL 体系、本文長の分布、保存対象の読み込みパターンに合わせて定義します。

標準方式は、各レコードをそのまま zstd で圧縮します。

import zstandard as zstd

LEVEL = 22
KiB = 1024
MiB = 1024 * KiB

SITE_DICTIONARY_SIZE = 256 * KiB
LARGE_DICTIONARY_SIZE = 2 * MiB


def compress_standard(records):
    compressor = zstd.ZstdCompressor(level=LEVEL)
    archive = []

    for record in records:
        payload = compressor.compress(record.body)
        archive.append({
            "record_id": record.id,
            "dictionary_id": None,
            "payload": payload,
        })

    return archive

上記の辞書サイズは説明用の例です。実運用では、保存量、辞書の共有コスト、読み込み時のメモリ使用量を見ながら決めます。

サイト単位辞書方式では、学習データからサイトごとの辞書を作り、保存対象レコードにはその辞書を適用します。

def train_site_dictionary(train_records):
    samples = [record.body for record in train_records]
    return zstd.train_dictionary(SITE_DICTIONARY_SIZE, samples)


def compress_with_site_dictionary(site_id, train_records, records):
    dictionary = train_site_dictionary(train_records)
    compressor = zstd.ZstdCompressor(level=LEVEL, dict_data=dictionary)
    dictionary_id = f"{site_id}:site:v1"

    archive = []
    for record in records:
        payload = compressor.compress(record.body)
        archive.append({
            "record_id": record.id,
            "dictionary_id": dictionary_id,
            "payload": payload,
        })

    return archive

学習サンプル設計では、辞書に渡すレコードをそのまま全件並べるのではなく、URL 構造、本文長、訓練データ上での圧縮しにくさを使って代表性を整えます。bucket は辞書を分けるためではなく、学習サンプルを偏らせないために使います。

def select_dictionary_samples(train_records):
    buckets = {}

    for record in train_records:
        route = normalize_route(record.url)
        length = length_bucket(len(record.body))
        hardness = standard_compression_ratio(record.body)
        key = (route, length, hardness_bucket(hardness))
        add_record_to_bucket(buckets, key, record)

    samples = []
    for key, records in buckets.items():
        quota = sample_quota(
            bucket_key=key,
            record_count=len(records),
            raw_bytes=sum(len(record.body) for record in records),
        )
        samples.extend(sample_records(records, quota))

    samples = rebalance_by_raw_bytes(samples)
    samples = deduplicate_near_identical_samples(samples)
    return samples

辞書学習パラメータ調整では、同じ保存形式のまま、辞書サイズ、学習に使うサンプル数、trainer 条件の組み合わせを変えます。辞書は学習データから作り、候補選定用のサンプルでアーカイブ本体サイズ比を比較します。

def train_dictionary_candidates(samples):
    training_plans = [
        {
            "name": "site-sized",
            "dictionary_size": SITE_DICTIONARY_SIZE,
            "sample_limit": 2_000,
            "trainer_options": {"search": "standard"},
        },
        {
            "name": "large-balanced",
            "dictionary_size": LARGE_DICTIONARY_SIZE,
            "sample_limit": 8_000,
            "trainer_options": {"search": "wide"},
        },
    ]

    candidates = []
    for plan in training_plans:
        selected = limit_samples(samples, plan["sample_limit"])
        dictionary = train_dictionary_with_options(
            dictionary_size=plan["dictionary_size"],
            samples=[record.body for record in selected],
            trainer_options=plan["trainer_options"],
        )
        candidates.append({
            "name": plan["name"],
            "dictionary": dictionary,
            "dictionary_size": plan["dictionary_size"],
            "trainer_options": plan["trainer_options"],
        })

    return candidates

Large Dictionary は、基本構造はサイト単位辞書方式と同じですが、辞書容量を大きく取り、上記のサンプル設計と学習条件の調整を組み合わせます。圧縮時に使う辞書は、サイト単位または少数の大きな辞書のままです。

def train_shared_dictionary(train_records, selection_records):
    samples = select_dictionary_samples(train_records)
    candidates = train_dictionary_candidates(samples)

    best = choose_smallest_stable_candidate(
        candidates,
        selection_records,
    )

    return best["dictionary"]


def compress_with_shared_dictionary(site_id, dictionary, records):
    compressor = zstd.ZstdCompressor(level=LEVEL, dict_data=dictionary)
    dictionary_id = f"{site_id}:large:v1"

    archive = []
    for record in records:
        payload = compressor.compress(record.body)
        archive.append({
            "record_id": record.id,
            "dictionary_id": dictionary_id,
            "payload": payload,
        })

    return archive

候補選定では、圧縮後サイズだけでなく、辞書の安定性も見ます。

def choose_smallest_stable_candidate(candidates, selection_records):
    measurements = []

    for candidate in candidates:
        compressed_bytes = replay_on_selection_sample(
            candidate["dictionary"],
            selection_records,
        )
        diagnostics = inspect_dictionary_training(candidate)
        measurements.append({
            "candidate": candidate,
            "compressed_bytes": compressed_bytes,
            "actual_dictionary_size": diagnostics.actual_dictionary_size,
            "requested_dictionary_size": diagnostics.requested_dictionary_size,
            "build_seconds": diagnostics.build_seconds,
            "artifact_bytes": diagnostics.artifact_bytes,
            "is_unstable": diagnostics.is_unstable,
        })

    best_bytes = min(row["compressed_bytes"] for row in measurements)
    tie_threshold = max(32, best_bytes * 0.005)

    comparable = [
        row
        for row in measurements
        if row["compressed_bytes"] <= best_bytes + tie_threshold
    ]
    stable = [row for row in comparable if not row["is_unstable"]]
    pool = stable if stable else comparable

    return min(
        pool,
        key=lambda row: (
            row["artifact_bytes"],
            row["build_seconds"],
            row["actual_dictionary_size"],
        ),
    )["candidate"]

この疑似コードの要点は、最小の圧縮後サイズを出した候補を常に採用するわけではないことです。差が小さい場合は、辞書サイズ、build 時間、実辞書サイズの安定性を見て、長期運用しやすい候補を選びます。

まとめ

今回のデータでは、zstd の共有辞書によって保存量の低下が確認できました。元データを 100% とすると、標準方式のアーカイブ本体サイズ比は 14.011〜32.554%、サイト辞書のアーカイブ本体サイズ比は 9.014〜15.083% でした。

辞書込み実効サイズ比で見ると、サイト辞書は 1GiB で 9.112〜15.181%、8GiB で 9.026〜15.095%、16GiB で 9.020〜15.089% でした。さらに共有辞書をチューニングすると、1GiB で 8.467〜14.251%、8GiB で 7.819〜13.310%、16GiB で 7.689〜13.191% になりました。

共有辞書方式は、保存量を下げる余地がある一方で、辞書 ID、辞書世代、辞書キャッシュ、再学習、欠損時の復元手順を設計に含める必要があります。また、自動辞書トレーニングでは、要求辞書サイズと実辞書サイズのズレや、学習結果の縮退を診断し、同等性能なら安定した候補を選ぶほうが運用しやすくなります。

圧縮率の改善、辞書込みの実効保存量、読み込みワークロードへの影響を同じ判断材料として扱うことが、クローラーの長期保存形式では重要です。

ルーティングテーブルだけでは届かなかった DNS: Tailscale subnet router 越しに Kubernetes worker node を足した話

これはなに

自宅 Kubernetes に worker node を1台追加したときのメモ。

既存の Kubernetes node は server network 側、追加した worker は別 network 側にあった。

Tailscale の subnet router を使って site-to-site networking 的につなぎ、routing table を設定した。 node は Ready になる。RKE2 agent も起動する。API server にもつながる。

しかし、Pod から DNS が引けなかった…。

nslookup kubernetes.default.svc.cluster.local 10.96.0.10
;; connection timed out; no servers could be reached

最終的には、route でも Tailscale ACL でも kube-proxy でもなく、通信経路は正しく設定できているが、flannel.1 の checksum offload に問題があることが判明した。

sudo ethtool -K flannel.1 tx-checksum-ip-generic off

これで直った。

ただし、そこにたどり着くまでにかなり遠回りした。せっかくなので、どうトラブルシュートしたかをまとめておく。

構成

ざっくりこういう構成。

Network topology with example IP ranges

router-01
  LAN gateway:        192.0.2.1/24
  server gateway:     198.51.100.1/24
  interconnect gw:    203.0.113.1/24

└── switch-01
    ├── server / Kubernetes underlay segment
    │   network:      198.51.100.0/24
    │   pod CIDR:     10.244.0.0/16
    │   service CIDR: 10.96.0.0/12
    │
    │   └── hypervisor-a / Proxmox
    │       ├── cp-01            198.51.100.11    podCIDR 10.244.0.0/24
    │       ├── cp-02            198.51.100.12    podCIDR 10.244.1.0/24
    │       ├── cp-03            198.51.100.13    podCIDR 10.244.3.0/24
    │       ├── worker-01        198.51.100.21    podCIDR 10.244.4.0/24
    │       ├── worker-02        198.51.100.22    podCIDR 10.244.5.0/24
    │       ├── worker-03        198.51.100.23    podCIDR 10.244.6.0/24
    │       └── site-router-01
    │           home-side:       192.0.2.15
    │           server-side:     198.51.100.30
    │           Tailscale IP:    100.64.10.15
    │
    └────  2.5GbE hub
             └── edge-host-01 / VMware
                 └── edge-worker-01
                     home-side:       192.0.2.14
                     Tailscale IP:    100.64.10.14
                     podCIDR:         10.244.2.0/24

Kubernetes 側は RKE2。CNI は Canal なので、Pod-to-Pod networking は Flannel VXLAN、NetworkPolicy まわりは Calico という構成になる。

edge-worker-01 は 192.0.2.1/24 側にいるので、198.51.100.0/28 や 198.51.100.16/28 へ出るには site-router-01 を next hop にする。

ip route replace 198.51.100.0/28 via 192.0.2.15 dev ens32 src 192.0.2.14
ip route replace 198.51.100.16/28 via 192.0.2.15 dev ens32 src 192.0.2.14

反対側、つまり server network 側の node から 192.0.2.14 へ戻る route も必要になる。

ip route replace 192.0.2.14/32 via 198.51.100.253

ここまでで host level の通信は動く。edge-worker-01 は cluster に join するし、kubectl get nodes でも Ready になる。

しかし Pod から DNS は引けない。

まず route と ACL を疑う

最初に疑ったのは Tailscale の subnet route と ACL。

今回の構成では Tailscale の subnet router で site-to-site networking をしていた。

Tailscale の subnet router は、tailnet と物理 subnet の間に置く gateway として使うもので、Tailscale を直接入れていない機器や network にも到達できるようになる。

基本的な考え方はこのあたり。

https://tailscale.com/docs/features/subnet-routers https://tailscale.com/docs/features/site-to-site

この時点では、「Kubernetes の制御通信は通るけど Pod DNS は通らない」という現象を見て、Tailscale policy file の書き方が悪いのでは、と思っていた。

なので ACL を整理した。Kubernetes control plane と Flannel VXLAN を同じものとして扱わないようにした。

/* K8S flannel VXLAN overlay */
{
  "action": "accept",
  "src": ["ipset:k8s-vxlan-home-client-01s"],
  "proto": "udp",
  "dst": ["ipset:k8s-vxlan-native-peers:8472"]
},
{
  "action": "accept",
  "src": ["ipset:k8s-vxlan-native-peers"],
  "proto": "udp",
  "dst": ["ipset:k8s-vxlan-home-client-01s:8472"]
}

control plane 側は別にする。

/* K8S control plane / kubelet */
{
  "action": "accept",
  "src": ["edge-worker-01-home"],
  "dst": ["ipset:k8s-control-plane-native:6443,9345"]
},
{
  "action": "accept",
  "src": ["ipset:k8s-control-plane-native"],
  "dst": ["edge-worker-01-home:10250"]
}

わかりやすくなったのでこの整理自体は良かったが、これで直ったわけではない。

Service ではなく Pod-to-Pod networking を見る

確認用 Pod を edge-worker-01 に固定して、Service VIP と CoreDNS Pod IP の両方を直接引いてみる。 Service VIP だけを見ると kube-proxy や Service 周りの問題と混ざるので、CoreDNS の Pod IP も指定する。これで、Service ClusterIP の問題なのか、Pod-to-Pod の overlay network の問題なのかを分けられる。

POD="edge-worker-dnscheck-$(date +%s)"
NS="debug-jobs"
NODE="edge-worker-01"

printf '%s\n' \
'apiVersion: v1' \
'kind: Pod' \
'metadata:' \
"  name: ${POD}" \
"  namespace: ${NS}" \
'spec:' \
"  nodeName: ${NODE}" \
'  restartPolicy: Never' \
'  containers:' \
'    - name: dnscheck' \
'      image: busybox:1.36.1' \
'      command:' \
'        - sh' \
'        - -c' \
'        - |' \
'          set -x' \
'          date -u' \
'          cat /etc/resolv.conf' \
'          nslookup kubernetes.default.svc.cluster.local 10.96.0.10' \
'          nslookup kubernetes.default.svc.cluster.local 10.244.0.11' \
'          nslookup kubernetes.default.svc.cluster.local 10.244.3.37' \
'          nslookup example.com 10.96.0.10' \
| kubectl apply -f -

for i in $(seq 1 60); do
  PHASE="$(
    kubectl -n "${NS}" get pod "${POD}" \
      -o jsonpath='{.status.phase}' 2>/dev/null || true
  )"

  echo "phase=${PHASE}"

  if [ "${PHASE}" = "Succeeded" ] || [ "${PHASE}" = "Failed" ]; then
    break
  fi

  sleep 1
done

kubectl -n "${NS}" get pod "${POD}" -o wide
kubectl -n "${NS}" logs "${POD}" --timestamps
kubectl -n "${NS}" delete pod "${POD}" --wait=false

10.96.0.10 は CoreDNS の Service ClusterIP。10.244.0.1110.244.3.37 は CoreDNS の実 Pod IP。

結果は全部 timeout。

Service ClusterIP だけ落ちるなら kube-proxy を見る。しかし CoreDNS Pod IP を直接指定しても落ちるので、Service proxy ではなく Pod-to-Pod networking の問題と見てよい。

RKE2 の要件にも、Flannel VXLAN を使う場合は node 間で UDP/8472 が通る必要がある、とある。

https://docs.rke2.io/install/requirements

つまり edge-worker-01cp-01/02 の間で、Flannel VXLAN の outer packet が通っているかを見る必要がある。

flannel がどの IP を使っているか

kubectl get nodes の annotation で、Flannel の public-ippodCIDR を見る。

kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.annotations.flannel\.alpha\.coreos\.com/public-ip}{"\t"}{.spec.podCIDR}{"\n"}{end}'

整理するとこうだった。

cp-01            flannel public-ip=198.51.100.11   podCIDR=10.244.0.0/24
cp-03            flannel public-ip=198.51.100.13   podCIDR=10.244.3.0/24
edge-worker-01   flannel public-ip=192.0.2.14      podCIDR=10.244.2.0/24

次に、control plane 側で edge-worker-01 の PodCIDR がどこへ向いているかを見る。

ip route | grep '10.244.2.0/24'
sudo bridge fdb show dev flannel.1 | grep '192\.0\.2\.14'
10.244.2.0/24 via 10.244.2.0 dev flannel.1 onlink
02:00:4d:d0:35:00 dst 192.0.2.14 self permanent

つまり、10.244.2.0/24 宛の traffic は flannel.1 に入り、VXLAN の outer destination として 192.0.2.14 が使われる。

raw UDP は通る

次に UDP/8472 自体が通るかを確認した。

master 側から edge-worker に向けて短い UDP packet を投げたり、その逆を試した。tcpdump で site-router-01ens18 / ens20 と、master 側の ens18 を見る。

raw UDP は通った。戻り方向も通った。

これは重要で、少なくとも次の仮説はかなり弱くなった。

  • route が全面的におかしい
  • site-router-01 の forwarding が死んでいる
  • Proxmox bridge が単純に UDP/8472 を落としている
  • INPUT chain で UDP/8472 が単純に落ちている
  • Tailscale ACL で落ちている

ところが、Pod DNS はまだ落ちる。

この時点で「UDP/8472 が通るなら Flannel VXLAN も通るはず」と考えたくなるが、そうではなかった。raw UDP と Flannel が生成する正規の VXLAN frame は違う。

raw UDP test は tcpdump ではこう見えた。

OTV, flags [.] (0x76), overlay 7892065, instance 7220592

一方、Flannel VXLAN はこう見える。

OTV, flags [I] (0x08), overlay 0, instance 1

tcpdump が OTV と表示しているのはさておき、見るべきは flags [I]instance 1 のほう。

本物の VXLAN packet を見る

edge-worker-01 上で tcpdump する。

sudo tcpdump -tttt -vvv -nni ens32 \
  'udp dst port 8472 and src host 192.0.2.14 and (dst host 198.51.100.11 or dst host 198.51.100.13)'

Pod から CoreDNS に問い合わせると、こういう packet が出る。

192.0.2.14.64625 > 198.51.100.11.8472: [bad udp cksum ...]
OTV, flags [I] (0x08), overlay 0, instance 1
IP 10.244.2.58 > 10.244.0.11.53: A? kubernetes.default.svc.cluster.local.

198.51.100.13 / 10.244.3.37 宛も同じように出る。

site-router-01ens18 でも見える。ens20 でも見える。つまり、edge-worker から出た正規 VXLAN packet は subnet router を通過している。

ところが master 側では、同じ条件で 0 packets だった。

sudo tcpdump -tttt -vvv -nni ens18 \
  'udp dst port 8472 and src host 192.0.2.14 and dst host 198.51.100.11'
0 packets captured

raw UDP は見えるのに、Flannel の正規 VXLAN は見えない。

この時点でだいぶ嫌な予感がしてくる。route ではない。ACL でもない。packet の種類によって挙動が違う。

ログに出ている bad udp cksum を疑うことにした。

bad udp cksum と checksum offload

送信側の tcpdump で bad udp cksum が出るのは、それだけならよくある。NIC の checksum offload が有効な場合、tcpdump は checksum がまだ完成していない packet を見ることがある。

しかし今回は、edge-worker だけではなく、site-router-01ens18 / ens20 でも bad udp cksum が見えていた。raw UDP は通るが、正規の Flannel VXLAN だけが落ちる。しかも DNS は完全に timeout する。

このへんは自分だけで見ていたら、たぶん route と firewall を延々といじっていた気がする。AI に「raw UDP は通るが正規 VXLAN だけ落ちる」という形で投げたら、checksum/offload の線がかなり濃いという返しになった。正直ここでようやく flannel.1 側を疑う発想になった。

Flannel の troubleshooting にもこの回避策がある。

https://github.com/flannel-io/flannel/blob/master/Documentation/troubleshooting.md

VMware と Flannel の組み合わせで flannel.1 の checksum offload を切る話も出てくる。

https://community.replicated.com/t/flannel-with-vmware/1396

直す

edge-worker-01 でこれを実行する。

sudo ethtool -k flannel.1 | grep -E 'tx-checksum|tx-checksum-ip-generic|tcp-segmentation|generic-segmentation'
sudo ethtool -K flannel.1 tx-checksum-ip-generic off
sudo ethtool -k flannel.1 | grep -E 'tx-checksum|tx-checksum-ip-generic|tcp-segmentation|generic-segmentation'

実行前はこう。

tx-checksumming: on
  tx-checksum-ip-generic: on

実行後はこう。

Actual changes:
tx-checksum-ip-generic: off
tx-tcp-segmentation: off [not requested]
...
tx-checksumming: off
  tx-checksum-ip-generic: off

その後、同じ DNS check を再実行する。

nslookup kubernetes.default.svc.cluster.local 10.96.0.10
nslookup kubernetes.default.svc.cluster.local 10.244.0.11
nslookup kubernetes.default.svc.cluster.local 10.244.3.37
nslookup artifact-bucket.example.com 10.96.0.10

今度は全部通った。

Server: 10.96.0.10
Name: kubernetes.default.svc.cluster.local
Address: 10.96.0.1

Server: 10.244.0.11
Name: kubernetes.default.svc.cluster.local
Address: 10.96.0.1

Server: 10.244.3.37
Name: kubernetes.default.svc.cluster.local
Address: 10.96.0.1

外部名も引ける。

artifact-bucket.example.com canonical name = object-storage.example.net

phase=Succeeded になった。これで今回の DNS 問題は直った。

永続化

flannel.1 は Canal/flannel によって作られる interface なので、単に boot 時に一回叩けばOKとは限らない。interface 作成時にも適用されるようにしておく。

edge-worker-01 に script を置く。

sudo tee /usr/local/sbin/disable-flannel1-tx-checksum.sh >/dev/null <<'SCRIPT'
#!/bin/sh
set -eu

if ip link show flannel.1 >/dev/null 2>&1; then
  /usr/sbin/ethtool -K flannel.1 tx-checksum-ip-generic off
fi
SCRIPT

sudo chmod +x /usr/local/sbin/disable-flannel1-tx-checksum.sh

udev rule。

sudo tee /etc/udev/rules.d/90-flannel1-tx-checksum.rules >/dev/null <<'RULE'
SUBSYSTEM=="net", ACTION=="add|change|move", KERNEL=="flannel.1", RUN+="/usr/local/sbin/disable-flannel1-tx-checksum.sh"
RULE

sudo udevadm control --reload

保険で systemd service も置く。

sudo tee /etc/systemd/system/disable-flannel1-tx-checksum.service >/dev/null <<'UNIT'
[Unit]
Description=Disable tx-checksum-ip-generic on flannel.1
After=network-online.target rke2-agent.service
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/bin/sh -c 'for i in $(seq 1 60); do if ip link show flannel.1 >/dev/null 2>&1; then exec /usr/local/sbin/disable-flannel1-tx-checksum.sh; fi; sleep 1; done; exit 1'
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
UNIT

sudo systemctl daemon-reload
sudo systemctl enable --now disable-flannel1-tx-checksum.service

確認。

sudo ethtool -k flannel.1 | grep -E 'tx-checksumming|tx-checksum-ip-generic'

期待値。

tx-checksumming: off
  tx-checksum-ip-generic: off

わかったこと

今回の問題は、最初に見えていたより layer が多かった。

  • Tailscale subnet router による site-to-site routing
  • Linux の routing table / policy routing
  • RKE2 agent の join
  • Flannel VXLAN の UDP/8472
  • Calico の iptables/nftables rule
  • flannel.1 の checksum offload
  • VMware / Proxmox / 物理 network

このうち、最初に疑ったのは Tailscale ACL と route だった。確かにそこも重要だが、最終的な DNS timeout の直接原因はそこではなかった。

決め手になったのは、10.96.0.10 だけでなく CoreDNS Pod IP へ直接 timeout すること、raw UDP は通ること、正規の Flannel VXLAN だけ bad udp cksum 付きで落ちること、そして flannel.1tx-checksum-ip-generic を off にすると即座に DNS が通ることだった。

AI との壁打ちは、この手の layer 分けにはかなり便利だった。GPT 5.5 xhigh と 5.5 pro thinkingを使った。観測結果を貼っていくと「その仮説ならこの tcpdump とは矛盾する」という形で鬼ロジカルシンキング力で適切な方向にガイドしてくれた。

SSDで組んだアレイにTRIM設定を忘れたら自宅 Kubernetes が崩壊した話

ある日の朝起きると自宅 Kubernetes クラスタが崩壊していた。

症状としてはシンプルにKubernetes 上で動いてる多くのサービスに接続できない。

切り分けていくと Kubernetes の control plane を構成するノードが散発的にクラスタから外れていた。

根本的な原因は Proxmox ホスト側の I/O 詰まりで、ZFS プールに対して trim をかけることで事象が解消した。

自宅 K8S 環境

  • ハイパーバイザ: Proxmox VE 8.4
  • ホストストレージ:
    • OS用ストレージ: ZFS mirror (SATA SSD x3)
    • VM用ストレージ: ZFS dRAID2 (NVMe SSD x12; SATA SSD x2 for SLOG)
  • Kubernetes:
    • control plane 3 台
      • etcd は各 control plane 上で動作
  • VM ディスク:
    • ZFS 上の zvol

Kubernetes の control plane の VM が ZFS の zvol に乗っていて、その下に NVMe の dRAID プールがいる。

トラブルシュートで確認したこと

cpu/mem/io 負荷

CPU やメモリの負荷は平常レベルだが IO の負荷だけが高い。

/proc/pressure/io

  • host
some avg10=12.42 avg60=17.79 avg300=25.63
full avg10=11.89 avg60=17.11 avg300=24.70

ゲスト側はもっとひどい。

  • guest
some avg10=30.38 avg60=42.98 avg300=49.92
full avg10=23.72 avg60=35.67 avg300=41.33

etcdctl のヘルスステータス

etcdctl endpoint health --clusterTOOK` と、ノードごとの health 状態。

ETCDCTL=/path/to/etcdctl

while true; do
  date -u
  sudo ETCDCTL_API=3 "$ETCDCTL" \
    --cacert=/path/to/server-ca.crt \
    --cert=/path/to/server-client.crt \
    --key=/path/to/server-client.key \
    --endpoints=https://127.0.0.1:2379 endpoint health --cluster -w table
  sleep 5
done

平常時は 10ms 前後で返る。

+------------------------+--------+-------------+
| ENDPOINT               | HEALTH | TOOK        |
+------------------------+--------+-------------+
| https://10.0.0.11:2379 | true   |  7.5ms      |
| https://10.0.0.12:2379 | true   |  7.7ms      |
| https://10.0.0.13:2379 | true   | 21.1ms      |
+------------------------+--------+-------------+

一方で、異常時はこうなる。

Fri Mar  6 10:17:41 UTC 2026
+------------------------+--------+--------------+--------------------------------+
| ENDPOINT               | HEALTH | TOOK         | ERROR                          |
+------------------------+--------+--------------+--------------------------------+
| https://10.0.0.11:2379 | true   | 994.107885ms |                                |
| https://10.0.0.12:2379 | true   | 2.805447218s |                                |
| https://10.0.0.13:2379 | false  | 4.275625994s | Unable to fetch the alarm list |
+------------------------+--------+--------------+--------------------------------+
Error: unhealthy cluster

通常は数 ms で返るのに、散発的に数百 ms、ひどいと数秒単位で止まる。

iommu=pt

Proxmoxのブートオプションに iommu=pt を追加して再起動した。チャッピーが試せって言うから…(特に効果なし)

ホストログに AMD-Vi: IO_PAGE_FAULT が何度か出ていたので、まずは IOMMU 周りを疑った。

ZFSへの trim 設定

次に iostat -x 1zpool iostat -v 1 を眺めた。

最初の採取では特定の NVMe だけが遅いようにも見えていた。
ただしより長期間のデータ全体としてはどのディスクでも一定確率で遅延が起きているように見えた。

たとえば ZFS の dRAID 配下では、各 NVMe へほぼ均等に書き込みが分散していた。

data  write 24.4M
  nvme0n1  2.03M
  nvme1n1  2.03M
  ...
  nvme11n1 2.03M

にも関わらず同時に iostat -x 1 の瞬間値では、あるタイミングで 1 本だけ突出して遅く見えることがあった。

Device   w/s   wkB/s  w_await aqu-sz %util
nvme7n1   59     580    62.56   5.49 89.10

この段階では、

  • どの M.2 ポートが悪いのか
  • どの PCI-Express バスが悪いのか
  • 特定スロットだけが悪いのか

を一発で断定できる感じではなかった。

ここまで見ていて、もしかして TRIM 周りでは、と思った。

SSDの特性上、TRIM が行われていないとかなりパフォーマンスが劣化するシーンがある。

長く使った SSD アレイで TRIM が走っていないなら、書き込みレイテンシが悪化してもおかしくない。

今回のVM用アレイを操作するレイヤーになる ZFS には autotrim と、明示的な zpool trim がある。手動 zpool trimautotrim の on/off に関係なく実行できる。 (openzfs.github.io)

さらにその上のレイヤーでは Proxmox のディスクの discard がゲストからの TRIM/UNMAP を下位ストレージへ伝えるための設定で、thin provisioning や未使用領域の回収に効く。 (pve.proxmox.com)

実際に確認してみると、ZFS プールの autotrim は off だった…。

ZFS のプールを trim して事象解消

そこで data プールに対して trim を実行した。

zpool set autotrim=on data
zpool trim data

この後、ホストの load average がかなり下がった。

さらに /proc/pressure/io を見ると、以前とは別物レベルまで改善していた。

trim 前のホスト:

some avg10=12.42
full avg10=11.89

trim 後のホスト:

some avg10=0.34
full avg10=0.34

ゲスト側も同様に改善した。

guest-0 /proc/pressure/io
some avg10=1.78 avg60=1.21 avg300=1.80
full avg10=1.44 avg60=0.98 avg300=1.37

そして etcd も安定するようになった。

+------------------------+--------+-------------+
| ENDPOINT               | HEALTH | TOOK        |
+------------------------+--------+-------------+
| https://10.0.0.11:2379 | true   |  7.2ms      |
| https://10.0.0.12:2379 | true   |  6.4ms      |
| https://10.0.0.13:2379 | true   |  6.6ms      |
+------------------------+--------+-------------+

総括

  • SSDのTRIMを設定していないとIOが刺さって死ぬことがある

OrangePi R2S セットアップの記録

OrangePi R2S は $30ドル+送料 で入手できる 2.5GBE x 2, 1GBE x 2 が特徴のRISC-V シングルボードコンピューター。 今回はこれをファイアーウォールに仕立ててみる。

公式サイト: http://www.orangepi.org/html/hardWare/computerAndMicrocontrollers/details/Orange-Pi-R2S.html

スペック

"OrangePi_R2S_X1_User Manual_v1.0.pdf" p7

ボード外観

"OrangePi_R2S_X1_User Manual_v1.0.pdf" p9

購入から起動まで

USB Type-Cポートに5V 3Aを供給すると電源が入り、Power Indicatorが赤色に点灯する。 eMMCにはデフォルトでOpenWRTが書き込まれていて、これが自動的にブートする。 Ubuntuに変更したければガイドに従いイメージの書き込みを行う。

管理コンソールへの接続

管理コンソールへの接続を行うため、WANではなく、LAN側のいずれかのポートにPCを接続する。 IPv4/IPv6共にDHCPサーバーが有効であるため、PCにIPが自動的に割り当てられる。 ブラウザで http://192.168.2.1 (非TLS)に接続すれば管理コンソールが開く。

"OrangePi_R2S_X1_User Manual_v1.0.pdf" p89

デフォルトのユーザーアカウントは root で、パスワードが設定されていない。

rootのパスワード設定(System -> Administration -> Router Password)

Web管理コンソールのHTTPS化

luci-ssl のインストール

opkg update
opkg install luci-ssl
uci set uhttpd.main.redirect_https='1'
uci commit uhttpd
service uhttpd reload

注記: opkg updateで次のエラーが出るが、 luci-sslのようなコアではないものをインストールするのには問題がない。

Downloading https://downloads.openwrt.org/releases/24.10.0/targets/ky/riscv64/packages/Packages.gz

*** Failed to download the package list from https://downloads.openwrt.org/releases/24.10.0/targets/ky/riscv64/packages/Packages.gz

上記URL中の ky/riscv64 は この環境の DISTRIB_TARGET だが、公式OpenWrt の標準ターゲットではない。 つまり、RISC-V用の勝手ビルドなので存在しない適当なアドレスになっていて解決策はないはずだが、正しいURLなど、もしエラーを解消する方法があれば教えてください…

root@OpenWrt:~# cat /etc/openwrt_release
DISTRIB_ID='OpenWrt'
DISTRIB_RELEASE='24.10.0'
DISTRIB_REVISION='r28427-6df0e3d02a'
DISTRIB_TARGET='ky/riscv64'
DISTRIB_ARCH='riscv64_riscv64'
DISTRIB_DESCRIPTION='OpenWrt 24.10.0 r28427-6df0e3d02a'
DISTRIB_TAINTS='no-all'

~~ファイアーウォール機器にするための設定~~

ここからはファイアーウォール機器にするための設定になります。

現在の設定をバックアップ

sysupgrade -b /tmp/backup.tar.gz

WAN側から管理できるようにする

WAN側からのSSH接続の許可 (Network -> Firewall -> Traffic Rules)

WAN側からWeb管理コンソールへの接続許可 (Network -> Firewall -> Traffic Rules)

WAN側からSSH/Web管理コンソールへ接続する

上記設定後、WAN側から接続して作業を続行します。

ネットワークの再作成

lan, br-lanを破棄してbr-25gを作る

uci delete network.lan

sec="$(uci -q show network | sed -n "s/^\(network\.@device\[[0-9]\+\]\)\.name='br-lan'$/\1/p")"
echo "br-lan device section = $sec"
[ -n "$sec" ] && uci delete "$sec"

uci add network device
uci set network.@device[-1].name='br-25g'
uci set network.@device[-1].type='bridge'
uci add_list network.@device[-1].ports='eth2'
uci add_list network.@device[-1].ports='eth3'
uci set network.@device[-1].stp='0'

uci set network.br25g='interface'
uci set network.br25g.device='br-25g'
uci set network.br25g.proto='none'
uci set network.br25g.auto='1'

uci commit network
/etc/init.d/network reload

ファイアーウォール ルールを再作成

  • 基本的なWAN側からのパケットのフィルタ
  • WAN側からSSH, Web管理コンソールへアクセスできるようにする
  • br-25gの2つのポートについて、eth2が上流、eth3を下流とする
: > /etc/config/firewall
uci show firewall
uci -q batch <<'EOF'
set firewall.defaults=defaults
set firewall.defaults.input='DROP'
set firewall.defaults.output='ACCEPT'
set firewall.defaults.forward='DROP'
set firewall.defaults.synflood_protect='1'
set firewall.defaults.flow_offloading='0'
set firewall.defaults.flow_offloading_hw='0'

set firewall.wan=zone
set firewall.wan.name='wan'
add_list firewall.wan.network='wan'
set firewall.wan.input='DROP'
set firewall.wan.output='ACCEPT'
set firewall.wan.forward='DROP'
set firewall.wan.masq='0'
set firewall.wan.mtu_fix='0'

set firewall.wan_dhcp_renew=rule
set firewall.wan_dhcp_renew.name='Allow-DHCP-Renew'
set firewall.wan_dhcp_renew.src='wan'
set firewall.wan_dhcp_renew.proto='udp'
set firewall.wan_dhcp_renew.dest_port='68'
set firewall.wan_dhcp_renew.family='ipv4'
set firewall.wan_dhcp_renew.target='ACCEPT'

set firewall.wan_ping=rule
set firewall.wan_ping.name='Allow-Ping'
set firewall.wan_ping.src='wan'
set firewall.wan_ping.proto='icmp'
set firewall.wan_ping.icmp_type='echo-request'
set firewall.wan_ping.family='ipv4'
set firewall.wan_ping.target='ACCEPT'

set firewall.wan_dhcpv6=rule
set firewall.wan_dhcpv6.name='Allow-DHCPv6'
set firewall.wan_dhcpv6.src='wan'
set firewall.wan_dhcpv6.proto='udp'
set firewall.wan_dhcpv6.dest_port='546'
set firewall.wan_dhcpv6.family='ipv6'
set firewall.wan_dhcpv6.target='ACCEPT'

set firewall.wan_icmp6=rule
set firewall.wan_icmp6.name='Allow-ICMPv6'
set firewall.wan_icmp6.src='wan'
set firewall.wan_icmp6.proto='icmp'
set firewall.wan_icmp6.family='ipv6'
set firewall.wan_icmp6.target='ACCEPT'

set firewall.wan_ssh=rule
set firewall.wan_ssh.name='Allow-SSH-from-WAN'
set firewall.wan_ssh.src='wan'
set firewall.wan_ssh.proto='tcp'
set firewall.wan_ssh.dest_port='22'
set firewall.wan_ssh.target='ACCEPT'

set firewall.wan_https=rule
set firewall.wan_https.name='Allow-HTTPS-from-WAN'
set firewall.wan_https.src='wan'
set firewall.wan_https.proto='tcp'
set firewall.wan_https.dest_port='443'
set firewall.wan_https.target='ACCEPT'
EOF

uci commit firewall
fw4 check
/etc/init.d/firewall restart

ブリッジ用ルールは nftables の table bridge なのでfw4 のライフサイクル(restart/reload)に合わせて 自分で nft をロードする必要がある。

mkdir -p /etc/nft-bridge.d
cat > /etc/nft-bridge.d/br25g-bridge.nft <<'EOF'
table bridge br25g_filter {
  chain forward {
    type filter hook forward priority 0; policy accept;

    #
    # 0) 下流 -> 上流 の必須L2ブロードキャスト/マルチキャスト
    #

    # IPv4 DHCP 等: L2ブロードキャスト
    iifname "eth3" oifname "eth2" ether daddr ff:ff:ff:ff:ff:ff counter accept comment "ALLOW downstream broadcast (DHCPv4 etc)"

    # IPv6: all-nodes / all-routers / DHCPv6 multicast
    iifname "eth3" oifname "eth2" ether daddr 33:33:00:00:00:01 counter accept comment "ALLOW IPv6 multicast all-nodes"
    iifname "eth3" oifname "eth2" ether daddr 33:33:00:00:00:02 counter accept comment "ALLOW IPv6 multicast all-routers"
    iifname "eth3" oifname "eth2" ether daddr 33:33:00:01:00:02 counter accept comment "ALLOW DHCPv6 multicast (ff02::1:2)"

    # IPv6: solicited-node multicast (33:33:ff:xx:xx:xx) をマスクで許可
    iifname "eth3" oifname "eth2" ether daddr & ff:ff:ff:00:00:00 == 33:33:ff:00:00:00 counter accept comment "ALLOW IPv6 solicited-node multicast (ND)"

    #
    # 1) 同一L2維持に必須(従来どおり)
    #
    ether type arp counter accept comment "ALLOW ARP"
    ether type ip6 ip6 nexthdr icmpv6 icmpv6 type {133,134,135,136} counter accept comment "ALLOW ICMPv6 ND/RA (parsed)"

    # DHCPv4/DHCPv6(パースできる場合)
    iifname "eth3" oifname "eth2" ether type ip  ip protocol udp udp sport 68  udp dport 67  counter accept comment "ALLOW DHCPv4 c->s (parsed)"
    iifname "eth2" oifname "eth3" ether type ip  ip protocol udp udp sport 67  udp dport 68  counter accept comment "ALLOW DHCPv4 s->c (parsed)"
    iifname "eth3" oifname "eth2" ether type ip6 ip6 nexthdr udp udp sport 546 udp dport 547 counter accept comment "ALLOW DHCPv6 c->s (parsed)"
    iifname "eth2" oifname "eth3" ether type ip6 ip6 nexthdr udp udp sport 547 udp dport 546 counter accept comment "ALLOW DHCPv6 s->c (parsed)"

    #
    # 2) 上流 -> 下流 は許可
    #
    iifname "eth2" oifname "eth3" counter accept comment "ALLOW upstream->downstream"

    #
    # 3) 下流 -> 上流 は「戻り」+「ルータMAC宛のみ」許可
    #
    iifname "eth3" oifname "eth2" ct state established,related counter accept comment "ALLOW return traffic (ct est/rel)"
    iifname "eth3" oifname "eth2" ether daddr ac:8b:a9:10:db:8f counter accept comment "ALLOW to router MAC only"

    #
    # 4) それ以外の下流 -> 上流 は遮断
    #
    iifname "eth3" oifname "eth2" counter drop comment "DROP downstream->upstream (non-router MAC, new)"
  }
}
EOF

/etc/firewall.user を “ロード用スクリプト” として用意し、UCIfirewall include に登録して fw4 に管理させる。

cat > /etc/firewall.user <<'EOF'
#!/bin/sh
nft delete table bridge br25g_filter 2>/dev/null
nft -f /etc/nft-bridge.d/br25g-bridge.nft
EOF
chmod +x /etc/firewall.user

uci add firewall include
uci set firewall.@include[-1].path='/etc/firewall.user'
uci set firewall.@include[-1].type='script'
uci set firewall.@include[-1].enabled='1'
uci set firewall.@include[-1].fw4_compatible='1'

uci commit firewall
fw4 check
/etc/init.d/firewall restart

nft list table bridge br25g_filter

Linux のブリッジはパケットをL2で転送するが、br-netfilter が有効だと ブリッジを通過する IPv4/IPv6iptables でも検査する挙動になる。 今回の構成は「ブリッジ通過フレームを table bridge で制御する」方針なのでbr-netfilter を無効化して bridge 側に一本化する。

cat > /etc/sysctl.d/99-bridge-nf.conf <<'EOF'
net.bridge.bridge-nf-call-iptables=0
net.bridge.bridge-nf-call-ip6tables=0
net.bridge.bridge-nf-call-arptables=0
EOF

/etc/init.d/sysctl restart

sysctl net.bridge.bridge-nf-call-iptables net.bridge.bridge-nf-call-ip6tables net.bridge.bridge-nf-call-arptables 2>/dev/null

不要なサービスを停止

今回の構成ではDHCP/RAサーバー機能は不要なので無効化する。

uci -q delete dhcp.lan
uci -q delete dhcp.wan
uci commit dhcp
/etc/init.d/dnsmasq restart

/etc/init.d/odhcpd disable

今回の構成ではSamba機能は不要なので無効化する。

/etc/init.d/samba4 disable

ファイアーウォールの動作確認

# カウンターをリセット
nft reset counters table bridge br25g_filter
# カウンターを確認
nft -a list chain bridge br25g_filter forward
  • DHCPv4 が動いているか

    • DHCP Discover/Request は L2 ブロードキャストで出るため、次が増えるのが自然: ALLOW downstream broadcast (DHCPv4 etc)(ff:ff:ff:ff:ff:ff) 一方で、L3/L4 でパースしている ALLOW DHCPv4 c->s (parsed) が 0 のままでも、L2 側で許可できていれば DHCP は成立する場合がある。
  • IPv6 が動いているか(ND/RA)

    • IPv6 は下記が増えるのが自然: ALLOW IPv6 multicast all-nodes(33:33:00:00:00:01) ALLOW IPv6 multicast all-routers(33:33:00:00:00:02) ALLOW IPv6 solicited-node multicast (ND)(33:33:ff:xx:xx:xx) ALLOW ICMPv6 ND/RA (parsed)(RS/RA/NS/NA)
  • 分離が効いているか(下流→上流の他端末遮断)

    • 下流端末は上流側へ色々投げがちなので、次が増えるのは想定内: DROP downstream->upstream (non-router MAC, new) ここが 0 で、代わりに他の許可が増えている場合は “想定より穴がある” 可能性がある。

実際の正常動作時のカウンターの例

root@OpenWrt:~# nft -a list chain bridge br25g_filter forward
table bridge br25g_filter {
    chain forward { # handle 1
        type filter hook forward priority 0; policy accept;
        iifname "eth3" oifname "eth2" ether daddr ff:ff:ff:ff:ff:ff counter packets 11 bytes 2335 accept comment "ALLOW downstream broadcast (DHCPv4 etc)" # handle 2
        iifname "eth3" oifname "eth2" ether daddr 33:33:00:00:00:01 counter packets 0 bytes 0 accept comment "ALLOW IPv6 multicast all-nodes" # handle 3
        iifname "eth3" oifname "eth2" ether daddr 33:33:00:00:00:02 counter packets 0 bytes 0 accept comment "ALLOW IPv6 multicast all-routers" # handle 4
        iifname "eth3" oifname "eth2" ether daddr 33:33:00:01:00:02 counter packets 0 bytes 0 accept comment "ALLOW DHCPv6 multicast (ff02::1:2)" # handle 5
        iifname "eth3" oifname "eth2" ether daddr & ff:ff:ff:00:00:00 == 33:33:ff:00:00:00 counter packets 174 bytes 12528 accept comment "ALLOW IPv6 solicited-node multicast (ND)" # handle 6
        ether type arp counter packets 7036 bytes 323656 accept comment "ALLOW ARP" # handle 7
        ip6 nexthdr ipv6-icmp icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } counter packets 4833 bytes 347768 accept comment "ALLOW ICMPv6 ND/RA (parsed)" # handle 9
        iifname "eth3" oifname "eth2" ip protocol udp udp sport 68 udp dport 67 counter packets 0 bytes 0 accept comment "ALLOW DHCPv4 c->s (parsed)" # handle 10
        iifname "eth2" oifname "eth3" ip protocol udp udp sport 67 udp dport 68 counter packets 2 bytes 681 accept comment "ALLOW DHCPv4 s->c (parsed)" # handle 11
        iifname "eth3" oifname "eth2" ip6 nexthdr udp udp sport 546 udp dport 547 counter packets 0 bytes 0 accept comment "ALLOW DHCPv6 c->s (parsed)" # handle 12
        iifname "eth2" oifname "eth3" ip6 nexthdr udp udp sport 547 udp dport 546 counter packets 0 bytes 0 accept comment "ALLOW DHCPv6 s->c (parsed)" # handle 13
        iifname "eth2" oifname "eth3" counter packets 37848 bytes 9109894 accept comment "ALLOW upstream->downstream" # handle 14
        iifname "eth3" oifname "eth2" ct state established,related counter packets 773 bytes 128154 accept comment "ALLOW return traffic (ct est/rel)" # handle 15
        iifname "eth3" oifname "eth2" ether daddr ac:8b:a9:10:db:8f counter packets 86 bytes 5102 accept comment "ALLOW to router MAC only" # handle 16
        iifname "eth3" oifname "eth2" counter packets 875 bytes 142058 drop comment "DROP downstream->upstream (non-router MAC, new)" # handle 17
    }
}

ベンチマーク

Thread  Time(s) Throughput(KB/s) Avg B / Compl
======  ======= ================ =============
     0    0.000            0.000     65536.000
     1    0.000            0.000     65536.000
     2    0.000            0.000     65536.000
     3    0.000            0.000     65536.000


#####  Totals:  #####


   Bytes(MEG)    realtime(s) Avg Frame Size Throughput(MB/s)
================ =========== ============== ================
    15101.437500     100.001       1454.055          151.013


Throughput(Buffers/s) Cycles/Byte       Buffers
===================== =========== =============
             2416.206     136.706    241623.000


DPCs(count/s) Pkts(num/DPC)   Intr(count/s) Pkts(num/intr)
============= ============= =============== ==============
    55574.601         0.934      129467.780          0.401


Packets Sent Packets Received Retransmits Errors Avg. CPU %
============ ================ =========== ====== ==========
    10890240          5191793         361     69     24.424

パフォーマンスが出ない…と思ったら、ドライバがRSSなどに対応してない。嘘でしょ…

[   18.279304] r8125 Ethernet controller driver 9.014.01-NAPI loaded

9.014.01-NAPI-RSS のようなRSS対応版が存在するようだ。 同じ事象で困っている人がいた。 https://www.reddit.com/r/OrangePI/comments/1okp321/orange_pi_r2s_subpar_switch_performance/

Secure Password Management in WSL: Building an Integrated Backup Environment with Pass and GPG

Secure Password Management in WSL: Building an Integrated Backup Environment with Pass and GPG

Introduction

Password management is an eternal challenge for developers. Especially in WSL (Windows Subsystem for Linux) environments, how to backup credentials managed on the Linux side becomes a critical issue.

This article explains how to build a system that combines the command-line password manager "Pass" with GPG encryption to automatically backup to the Windows side.

Goals

  • Security: Strong security through GPG encryption
  • Convenience: Quick access from the command line
  • Automation: Automatic backup via Git integration
  • Recoverability: Reliable restoration from Windows-side backups
  • Unified Management: Managing passwords and GPG keys in a single repository

Understanding Pass and GPG

What is Pass?

Pass, short for "password store," is a simple password management tool for Unix/Linux. Developed by Jason A. Donenfeld in 2012, it has the following features:

  • Text file-based: Each password is stored as an individual file
  • Combination of standard tools: Utilizes GPG, Git, tree commands, etc.
  • Extensibility: Easily customizable with shell scripts

What is GPG (GNU Privacy Guard)?

GPG is encryption software based on the OpenPGP standard:

  • Public key cryptography: Uses public and private key pairs
  • Digital signatures: Ensures data integrity and authentication
  • Web of Trust: Decentralized trust model

Role Division between Pass and GPG

┌─────────────────────────────────────────┐
│            Pass (Management Layer)       │
│  - Directory structure management        │
│  - Git integration                       │
│  - Command-line interface               │
└────────────────┬────────────────────────┘
                 │ Encryption/Decryption requests
                 ↓
┌─────────────────────────────────────────┐
│           GPG (Encryption Layer)         │
│  - Actual encryption/decryption process  │
│  - Key management                        │
│  - Security guarantee                    │
└─────────────────────────────────────────┘

Detailed Interaction

  1. Password Storage Flow ```bash pass insert github.com/personal

    ↓ Pass receives input

    ↓ Requests GPG to encrypt

    ↓ GPG encrypts with public key

    ↓ Pass saves as .gpg file

    ```

  2. Password Retrieval Flow ```bash pass show github.com/personal

    ↓ Pass reads .gpg file

    ↓ Requests GPG to decrypt

    ↓ GPG decrypts with private key (passphrase required)

    ↓ Pass displays plaintext

    ```

Why Choose Pass?

Pass Features

  • Unix philosophy: Each password as an individual GPG-encrypted file
  • Git integration: Built-in version control
  • Simple: No unnecessary features, robust
  • Standard: Available in many Linux distributions

Comparison with Other Options

  • KeePass: GUI-based and feature-rich, but limited CLI operations
  • Bitwarden: Cloud sync is convenient, but not locally self-contained
  • 1Password: Paid and feature-rich, but overkill

System Architecture

Architecture Diagram

Windows Host
│
├─ C:\gpg-keys-backup\
│  └─ wsl-personal-pass.git/ (Bare Repository)
│     ├─ GPG key backups
│     └─ Encrypted passwords
│
└─ WSL Ubuntu
   └─ ~/secure/pass-with-gpg/
      ├─ .git/ (Auto-push configured)
      ├─ gpg-keys/
      │  ├─ pass-secret-key.asc
      │  ├─ pass-public-key.asc
      │  └─ key-info.txt
      └─ password-store/
         └─ *.gpg (Encrypted passwords)

Data Flow

1. pass insert/generate
   ↓
2. GPG encryption
   ↓
3. Git auto-commit
   ↓
4. Git Hooks (post-commit)
   ↓
5. Auto-push to Windows repository

Prerequisites

Environment Requirements

Complete Setup Procedure

Setting Environment Variables

First, set the following variables according to your environment:

# Windows-side backup directory (change according to your environment)
export BACKUP_DIR="/mnt/c/gpg-keys-backup"
export REPO_NAME="wsl-personal-pass.git"

# WSL-side working directory
export WORK_DIR="$HOME/secure/pass-with-gpg"

# GPG key settings (name and email for Pass)
export GPG_NAME="Pass Manager"
export GPG_EMAIL="pass@localhost"

1. Installing Required Tools

# System update
sudo apt update

# Install required tools
sudo apt install -y gnupg pass git tree

# Verify installation
gpg --version
pass --version
git --version

2. Creating Windows-side Git Repository

Execute in Windows Command Prompt or PowerShell:

# Create backup directory
mkdir C:\gpg-keys-backup
cd C:\gpg-keys-backup

# Initialize Git repository
git init --bare wsl-personal-pass.git

Or, execute from WSL:

# Create Windows-side directory from WSL
mkdir -p "$BACKUP_DIR"
cd "$BACKUP_DIR"

# Initialize Git repository
git init --bare "$REPO_NAME"

3. Creating GPG Key (Pass-specific)

# Generate GPG key
gpg --batch --generate-key <<EOF
Key-Type: RSA
Key-Length: 4096
Subkey-Type: RSA
Subkey-Length: 4096
Name-Real: $GPG_NAME
Name-Email: $GPG_EMAIL
Expire-Date: 2y
%no-protection
%commit
EOF

# Automatically retrieve generated key ID
export PASS_GPG_ID=$(gpg --list-secret-keys --keyid-format LONG | grep -A1 "^sec" | grep "$GPG_EMAIL" -B1 | head -1 | awk '{print $2}' | cut -d'/' -f2)

# Verify
echo "Generated GPG Key ID: $PASS_GPG_ID"
gpg --list-secret-keys --keyid-format LONG

4. Building Integrated Repository

# Create working directory
mkdir -p "$WORK_DIR"
cd "$WORK_DIR"

# Initialize as Git repository
git init
git remote add origin "$BACKUP_DIR/$REPO_NAME"

# Create directory structure
mkdir -p gpg-keys
mkdir -p password-store

# Export GPG keys
cd gpg-keys
gpg --armor --export-secret-keys "$PASS_GPG_ID" > pass-secret-key.asc
gpg --armor --export "$PASS_GPG_ID" > pass-public-key.asc
gpg --list-keys --fingerprint "$PASS_GPG_ID" > key-info.txt
cd ..

# Create README
cat << EOF > README.md
# WSL Personal Pass Repository

This repository contains:
- \`gpg-keys/\`: GPG keys for Pass
- \`password-store/\`: Passwords managed by Pass

## Configuration
- GPG Key ID: $PASS_GPG_ID
- Created: $(date +%Y-%m-%d)
EOF

# Initial commit
git add .
git commit -m "Initial setup: GPG keys for Pass"
git branch -M master
git push -u origin master

5. Initializing Pass

cd "$WORK_DIR"

# Use password-store directory as Pass store
export PASSWORD_STORE_DIR="$WORK_DIR/password-store"

# Initialize Pass
pass init "$PASS_GPG_ID"

# Create symbolic link (for access from usual location)
ln -sf "$WORK_DIR/password-store" ~/.password-store

6. Setting Up Auto-commit & Push

# Create post-commit hook
# Note: This hook is in the .git directory and not under Git control
# Must be recreated during restoration
cat << 'EOF' > "$WORK_DIR/.git/hooks/post-commit"
#!/bin/bash
echo "Auto-pushing to backup repository..."
git push origin master
EOF
chmod +x "$WORK_DIR/.git/hooks/post-commit"

# Script to detect Pass changes and auto-commit
cat << 'SCRIPT' > "$WORK_DIR/auto-commit.sh"
#!/bin/bash
cd "$(dirname "$0")"

if [ -n "$(git status --porcelain)" ]; then
    git add .
    git commit -m "Auto-commit: $(date +%Y-%m-%d\ %H:%M:%S)"
fi
SCRIPT
chmod +x "$WORK_DIR/auto-commit.sh"

7. Adding bashrc Configuration

# Add configuration to ~/.bashrc
cat << 'BASHRC' >> ~/.bashrc

# Pass configuration
export PASS_GPG_ID="$(gpg --list-secret-keys --keyid-format LONG | grep -A1 "^sec" | grep "pass@localhost" -B1 | head -1 | awk '{print $2}' | cut -d'/' -f2)"
export PASSWORD_STORE_DIR="$HOME/secure/pass-with-gpg/password-store"

# Auto-commit after Pass operations
pass() {
    command pass "$@"
    local exit_code=$?
    
    if [ $exit_code -eq 0 ]; then
        case "$1" in
            init|insert|add|generate|rm|remove|mv|cp|copy|edit)
                (cd "$HOME/secure/pass-with-gpg" && ./auto-commit.sh)
                ;;
        esac
    fi
    
    return $exit_code
}

# Backup GPG keys on update
backup-gpg-keys() {
    local work_dir="$HOME/secure/pass-with-gpg"
    cd "$work_dir/gpg-keys" || return 1
    
    local gpg_id="$PASS_GPG_ID"
    gpg --armor --export-secret-keys "$gpg_id" > pass-secret-key.asc
    gpg --armor --export "$gpg_id" > pass-public-key.asc
    gpg --list-keys --fingerprint "$gpg_id" > key-info.txt
    
    cd "$work_dir"
    if [ -n "$(git status --porcelain)" ]; then
        git add .
        git commit -m "GPG key update: $(date +%Y-%m-%d\ %H:%M:%S)"
    fi
}

# Utility aliases
alias pass-status='cd "$HOME/secure/pass-with-gpg" && git status -s && echo "=== Recent commits ===" && git log --oneline -5'
alias pass-tree='tree -a "$HOME/secure/pass-with-gpg" -I ".git"'
alias pass-sync='cd "$HOME/secure/pass-with-gpg" && git pull && git push'
BASHRC

# Apply configuration
source ~/.bashrc

8. Creating Restoration Documentation

# Create restoration documentation
cat << 'RESTORE' > "$WORK_DIR/RESTORE.md"
# Restoration Procedure

## 1. Clone Repository

\`\`\`bash
git clone /mnt/c/gpg-keys-backup/wsl-personal-pass.git ~/secure/pass-with-gpg
ln -sf ~/secure/pass-with-gpg/password-store ~/.password-store
\`\`\`

## 2. Restore GPG Keys

\`\`\`bash
cd ~/secure/pass-with-gpg/gpg-keys
gpg --import pass-secret-key.asc
gpg --import pass-public-key.asc

# Get imported key ID
export IMPORTED_KEY_ID=\$(gpg --list-secret-keys --keyid-format LONG | grep -A1 "^sec" | tail -1 | awk '{print \$1}' | cut -d'/' -f2)

# Set trust level
gpg --edit-key \$IMPORTED_KEY_ID trust quit
# Select 5 for ultimate trust
\`\`\`

## 3. Configure Environment

Add the Pass configuration above to ~/.bashrc and run source ~/.bashrc

## 4. Verify Operation

\`\`\`bash
pass list
\`\`\`
RESTORE

# Commit
cd "$WORK_DIR"
git add RESTORE.md
git commit -m "Add restore documentation"
git push

9. Security Settings

# Set directory permissions
chmod 700 "$WORK_DIR"
chmod 700 "$WORK_DIR/gpg-keys"
chmod 600 "$WORK_DIR/gpg-keys/"*
chmod 700 "$WORK_DIR/.git/hooks/post-commit"
chmod 700 "$WORK_DIR/auto-commit.sh"

10. Verification and Testing

# Verify GPG key
echo "=== GPG Key Info ==="
gpg --list-secret-keys --keyid-format LONG

# Create initial test entry
echo "=== Creating test entry ==="
pass generate test/initial-setup 16

# Check status
echo "=== Repository status ==="
pass-status

# Display tree structure
echo "=== Directory structure ==="
pass-tree

# Test sync
echo "=== Testing sync ==="
pass-sync

Usage

Basic Operations

# Add password
pass insert github.com/personal

# Generate password (32 characters)
pass generate aws/prod/api-key 32

# Show password
pass show github.com/personal

# Copy password to clipboard (auto-clear after 45 seconds)
pass -c github.com/personal

# List passwords
pass list

# Search passwords
pass find github

Management Commands

# Check backup status
pass-status

# Display directory structure
pass-tree

# Manual sync
pass-sync

# After updating GPG key
backup-gpg-keys

Troubleshooting

Common Issues and Solutions

GPG Errors

# Reset GPG agent
gpgconf --kill gpg-agent
gpgconf --launch gpg-agent

Git Push Errors

# Verify and fix remote
cd "$WORK_DIR"
git remote -v
git remote set-url origin "$BACKUP_DIR/$REPO_NAME"

Pass Not Working

# Check environment variables
echo $PASS_GPG_ID
echo $PASSWORD_STORE_DIR

# Manually reset
export PASS_GPG_ID=$(gpg --list-secret-keys --keyid-format LONG | grep -A1 "^sec" | tail -1 | awk '{print $1}' | cut -d'/' -f2)
source ~/.bashrc

Security Best Practices

1. GPG Key Protection

  • Regularly change GPG key passphrase
  • Always backup after key updates with backup-gpg-keys
  • Use dedicated keys for Pass, don't mix with other purposes

2. Access Restrictions

  • Restrict access to Windows-side backup directory
  • Maintain WSL working directory permissions at 700
  • Always set private key file permissions to 600

3. Operational Considerations

  • Perform restoration tests quarterly
  • Regularly check backup status with pass-status
  • Delete passwords when no longer needed

4. Passphrase Policy

  • Recommend 20+ characters for GPG key passphrase
  • Use combinations of non-dictionary words
  • Include numbers and symbols

Summary

This setup achieves the following:

  • ✅ Secure password management in WSL environment
  • ✅ Automatic backup to Windows side
  • ✅ Change tracking via Git history
  • ✅ Reliable disaster recovery procedures
  • ✅ Fast command-line access

Credential management is an unavoidable challenge in WSL development environments. By combining Pass and GPG, you can build an environment that balances security and convenience.

References

Redmi Buds 6 Liteを睡眠時の耳栓がわりに使ってみたら意外とイケた

耳栓をつけないと音が気になって寝れない。

耳栓さえつければ入眠はできるので問題ないんだけど、ずっと耳栓をつけてると耳道に負荷はかかっているようでたまにヒリヒリしたりする。

以前から睡眠時にアクティブノイズキャンセリング(ANC)がついたイヤフォンを使うというアイディアはあったんだけど、睡眠時に装着するものなのでできるだけ小さいものではないと、寝返りをしたり横になったタイミングで違和感を感じて目が覚めたりイヤフォンが外れたりする。

Apple耳うどんは性能的にはよさそうだったがApple製品は買わないことにしているので見送った。

それから何年も経ってようやくXiaomiが同じようなサイズでANCがついたイヤフォンをリリースしてくれた。なんと2480円(税込み)という衝撃価格。

www.amazon.co.jp

ANC性能はなかなかで、フィット感も悪くない。

しばらく耳栓代わりに使ってみたが、正直期待していたより寝心地(?)がよく、耳栓よりトータルでは優れているかもしれない。

Redmi Buds 6 Liteが優れている点:

  • 耳への負荷
  • つけ心地(仰向けで寝ているとき)

Redmi Buds 6 Liteが劣っている点:

  • 遮音性能
  • つけ心地(横向きで寝ているとき)
  • バッテリー稼働であること

Redmi Buds 6 Liteの最大の弱点が「バッテリー稼働であること」で、スペック的には持続時間は7時間。睡眠時は音楽再生はせず無音なのでこれより伸びるんだけど、8-9時間が上限。バッテリーがヘタったときはどんどん短くなってしまいそう。自然な目覚ましと考えるとまあ悪くはないが…。