0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

フェイルセーフにLinuxのFirmwareをダイエットして空き容量を増やすツールを作ってみる。

0
Posted at

Linux ファームウェアクリーンアップツールの作り方

はじめに

急に「/」配下のディスクの空きがなくなり、キャッシュなどは/homeは別のディスクだったため、どこか空きを作りたくなりました。
ファームウェアディレクトリ/lib/firmwareには膨大なファームウェアファイルが蓄積され、ディスク容量を圧迫していました。しかし、どのファームウェアが実際に使用されているかを手動で判断するのは困難です。

この記事では、使用されていないファームウェアを安全に特定し、バックアップを取りながら削除するPythonツールの実装方法を解説します。

OSに近い部分をいじるのは危険ですが、
誤操作しても致命的な状態にならない設計(フェイルセーフ)と、
そもそも誤操作を防ぐ設計(フェールプルーフ)を組み込むことで
安全なツールにしていく部分を真似べます。

⚠️ 注意:
このツールは「現在の実行環境で観測された情報」に基づいています。
将来使用される可能性のあるファームウェア(USB機器、再起動後、カーネル更新後など)は検出できません。

ツールの概要

このツールは以下の機能を提供します:

  • 現在使用中のファームウェアを自動検出
  • 不要なファームウェアを特定
  • ドライランモードで安全に確認
  • バックアップと復元機能

実装のポイント

1. 使用中ファームウェアの検出

lspci/lsusbによるデバイス検出

def get_active_fw_dirs() -> set[str]:
    # lspciでデバイスを列挙
    lspci_out = run("lspci -k 2>/dev/null")
    for keyword, fw_dirs in VENDOR_FW_MAP.items():
        if re.search(keyword, lspci_out, re.IGNORECASE):
            active.update(fw_dirs)

dmesgによるファームウェアロード履歴

# dmesgでロード済みファームウェアを確認
dmesg_out = run("dmesg 2>/dev/null | grep -i firmware")
fw_pattern = re.compile(r"firmware: [^ ]+ ([a-zA-Z0-9_\-\.]+)(?:/\S+)?")
for m in fw_pattern.finditer(dmesg_out):
    subdir_or_file = m.group(1)
    if (FIRMWARE_DIR / subdir_or_file).exists():
        active.add(subdir_or_file)

この判定はヒューリスティックであり完全ではありません。

2. 安全な削除候補の選定

必須ファイルの保護

ALWAYS_KEEP = {
    "updates",       # linux-firmware-updatesパッケージ
    "regulatory.db", # 無線LAN規制DB
    "intel-ucode",
    "amd-ucode",
}

GPUベンダー別マッピング

GPU_FW_MAP = {
    "amdgpu":  ["amdgpu", "radeon"],
    "nvidia":  ["nvidia"],
    "i915":    ["i915", "intel"],  # Intel GPU
}

3. バックアップと復元機能

安全なファイル移動

def execute_move(candidates: list[Path]) -> dict:
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup_dir = BACKUP_BASE / timestamp
    backup_dir.mkdir(parents=True, exist_ok=True)
    
    # ファイル移動とマニフェスト作成
    shutil.move(str(src), str(dst))

自動復元スクリプト生成

def _generate_restore_script(manifest: dict, output_path: Path):
    lines = ["#!/bin/bash", "# 自動生成された復元スクリプト"]
    lines.append(f'BACKUP_DIR="{manifest["backup_dir"]}"')
    
    # 復元処理
    for move in manifest["moves"]:
        src = move["dst"]
        dst = move["src"]
        lines.append(f'mv "{src}" "{dst}" && echo "  復元: {dst}"')

使い方

ドライラン(確認のみ)

sudo python3 firmware_cleanup.py

実際に実行

sudo python3 firmware_cleanup.py --execute

復元

sudo python3 firmware_cleanup.py --restore

実行例

$ sudo python3 firmware_cleanup.py

[INFO] lspci 出力を解析中...
  lspci: 'amdgpu' を検出 → ['amdgpu', 'radeon']
  lspci(GPU): 'amdgpu' を検出 → ['amdgpu', 'radeon']

[INFO] dmesg を解析中...
  dmesg: 'regulatory.db' を検出

Linux Firmware Cleanup レポート
============================================================
  モード          : 🔍 ドライラン(変更なし)
  ファームウェア合計: 1234.5 MB
  保持するサイズ  : 1000.0 MB
  削減できるサイズ: 234.5 MB  (19%)

【保持するディレクトリ・ファイル(使用中と判定)】
  ✅  amdgpu                   500.0 MB
  ✅  radeon                   300.0 MB
  ✅  regulatory.db            1.0 MB (強制保持)

【削除候補(移動対象)】
  🗑️  nvidia                   200.0 MB
  🗑️  intel-ucode              100.0 MB
  🗑️  ath10k                   50.0 MB
  🗑️  rtlwifi                  50.0 MB
============================================================

💡 実際に移動するには: sudo python3 firmware_cleanup.py --execute
💡 復元するには      : sudo python3 firmware_cleanup.py --restore

まとめ

このツールを作ることで、次の学びがあります。

  • 安全に不要なファームウェアを特定
  • ディスク容量の最適化(特にルートパーティションの空き確保)
  • 自動バックアップによる安全策
  • 簡単な復元機能

Linuxシステムのメンテナンスに役立ててください。特に、ルートパーティションの空きが逼迫した際の緊急対応として有効です。

ソースコード全文

Ubuntu22で試して動作確認していますが、自己責任でご利用ください。

#!/usr/bin/env python3
"""
Linux Firmware Cleanup Tool
============================
使われていないファームウェアを特定し、安全に移動(削除)するツール。
デフォルトはドライランモード。元の状態に戻すスクリプトも生成します。

Usage:
  sudo python3 firmware_cleanup.py              # ①ドライラン(確認のみ)
  sudo python3 firmware_cleanup.py --execute    # ②実際にファイルを移動
  sudo reboot                                   # ③再起動して、動作に問題なければバックアップファイルを削除する
  sudo python3 firmware_cleanup.py --restore    # ④  ③でWifiなど問題が起きた場合はバックアップから元に戻す
"""

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path


# ============================================================
# 設定
# ============================================================
FIRMWARE_DIR = Path("/lib/firmware")
BACKUP_BASE = Path("/var/lib/firmware-backup")
MANIFEST_NAME = "restore_manifest.json"

# システムに必須の可能性が高いディレクトリ・ファイル(削除候補から除外)
ALWAYS_KEEP = {
    "updates",       # linux-firmware-updatesパッケージ
    "regulatory.db", # 無線LAN規制DB
    "regulatory.db.p7s",
    "intel-ucode",
    "amd-ucode",
}

# GPU ベンダー識別キーワード → firmware サブディレクトリのマッピング
GPU_FW_MAP = {
    "amdgpu":  ["amdgpu", "radeon"],
    "nvidia":  ["nvidia"],
    "i915":    ["i915", "intel"],  # Intel GPU
    "nouveau": ["nvidia"],
}

# PCI/USB 出力で使う正規表現 → firmware サブディレクトリのヒューリスティックマッピング
VENDOR_FW_MAP = {
    # Wi-Fi / Bluetooth
    "ath":       ["ath10k", "ath11k", "ath12k", "qca"],
    "broadcom|brcm|bcm[0-9]+": ["brcm", "cypress"],
    "rtl":       ["rtlwifi", "rtl_bt", "rtw89", "realtek"],
    "mt76":      ["mediatek"],
    "mrvl":      ["mrvl", "libertas"],
    "rsi":       ["rsi"],
    "iwl":       ["iwlwifi"],
    # Audio (Sound Open Firmware, Intel AVS, etc)
    "sof":       ["intel/sof", "intel/sof-tplg"],
    "snd":       ["intel/sof", "intel/sof-tplg", "avs"],
    # Ethernet / NIC
    "bnx2":      ["bnx2", "bnx2x"],
    "bnxt":      ["bnx2x"],
    "qed":       ["qed"],
    "cxgb":      ["cxgb4"],
    "netronome": ["netronome"],
    "mlx":       ["mellanox"],
    "liquidio":  ["liquidio"],
    "dpaa":      ["dpaa2"],
    # その他
    "qcom":      ["qcom"],
    "intel":     ["intel", "intel-ucode", "i915"],
    "ti":        ["ti-connectivity"],
    "asihpi":    ["asihpi"],
    "ueagle":    ["ueagle-atm"],
}


# ============================================================
# ユーティリティ
# ============================================================
def run(cmd: str) -> str:
    """コマンドを実行して stdout を返す。失敗しても空文字を返す。"""
    try:
        result = subprocess.run(
            cmd, shell=True, capture_output=True, text=True, timeout=30
        )
        return result.stdout + result.stderr
    except Exception:
        return ""


def dir_size_mb(path: Path) -> float:
    """ディレクトリ(またはファイル)のサイズを MB で返す。"""
    try:
        if not path.exists():
            return 0.0
        if path.is_file() or path.is_symlink():
            return path.stat().st_size / 1024 / 1024
        result = subprocess.run(
            ["du", "-sb", str(path)], capture_output=True, text=True
        )
        return int(result.stdout.split()[0]) / 1024 / 1024
    except Exception:
        return 0.0


def fmt_mb(mb: float) -> str:
    return f"{mb:.1f} MB"


# ============================================================
# 使用中ファームウェアの検出
# ============================================================
def get_active_fw_dirs() -> set[str]:
    """
    現在のシステムで実際に使われている / 必要なファームウェアディレクトリ名を返す。
    """
    active: set[str] = set()

    # --- 1. lspci でデバイスを列挙 ---
    lspci_out = run("lspci -k 2>/dev/null")
    print("\n[INFO] lspci 出力を解析中...")
    for keyword, fw_dirs in VENDOR_FW_MAP.items():
        if re.search(keyword, lspci_out, re.IGNORECASE):
            active.update(fw_dirs)
            print(f"  lspci: '{keyword}' を検出 → {fw_dirs}")
    for gpu_kw, fw_dirs in GPU_FW_MAP.items():
        if re.search(gpu_kw, lspci_out, re.IGNORECASE):
            active.update(fw_dirs)
            print(f"  lspci(GPU): '{gpu_kw}' を検出 → {fw_dirs}")

    # --- 2. lsusb でUSBデバイスを列挙 ---
    lsusb_out = run("lsusb 2>/dev/null")
    print("[INFO] lsusb 出力を解析中...")
    for keyword, fw_dirs in VENDOR_FW_MAP.items():
        if re.search(keyword, lsusb_out, re.IGNORECASE):
            active.update(fw_dirs)

    # --- 3. dmesg でロード済みファームウェアを確認 ---
    dmesg_out = run("dmesg 2>/dev/null | grep -i firmware")
    print("[INFO] dmesg を解析中...")
    # 例: "firmware: direct-loading firmware intel/sof/sof-hda.ri" -> "intel"
    # 例: "firmware: direct-loading firmware regulatory.db" -> "regulatory.db"
    fw_pattern = re.compile(r"firmware: [^ ]+ ([a-zA-Z0-9_\-\.]+)(?:/\S+)?")
    for m in fw_pattern.finditer(dmesg_out):
        subdir_or_file = m.group(1)
        if (FIRMWARE_DIR / subdir_or_file).exists():
            active.add(subdir_or_file)
            print(f"  dmesg: '{subdir_or_file}' を検出")

    # --- 4. 追加の安全策 ---
    # CPU マイクロコード
    cpuinfo = run("grep -m1 'vendor_id' /proc/cpuinfo")
    if "GenuineIntel" in cpuinfo:
        active.add("intel-ucode")
        print("  CPU: Intel を検出 → intel-ucode を保持")
    if "AuthenticAMD" in cpuinfo:
        active.add("amd-ucode")
        print("  CPU: AMD を検出 → amd-ucode を保持")

    # オーディオトポロジーファイル (.tplg) はルートにある場合が多い
    for item in FIRMWARE_DIR.glob("*.tplg"):
        active.add(item.name)
        print(f"  Audio: '{item.name}' を自動保護")

    return active


# ============================================================
# 削除候補の決定
# ============================================================
def get_candidates(active: set[str]) -> list[Path]:
    """
    /lib/firmware 直下のサブディレクトリ・ファイルのうち、
    active に含まれないものを削除候補として返す。
    """
    candidates = []
    # active の中に 'intel/sof' のような階層がある場合、親の 'intel' を保持対象にする
    active_roots = set()
    for a in active:
        active_roots.add(a.split('/')[0])

    for item in sorted(FIRMWARE_DIR.iterdir()):
        name = item.name
        if name in ALWAYS_KEEP:
            continue
        if name in active_roots:
            continue
        # 隠しファイルはスキップ
        if name.startswith("."):
            continue
        candidates.append(item)
    return candidates


# ============================================================
# レポート表示
# ============================================================
def print_report(active: set[str], candidates: list[Path], dry_run: bool):
    total_fw_mb = dir_size_mb(FIRMWARE_DIR)
    candidate_mb = sum(dir_size_mb(p) for p in candidates)
    keep_mb = total_fw_mb - candidate_mb

    print("\n" + "=" * 60)
    print("  Linux Firmware Cleanup レポート")
    print("=" * 60)
    print(f"  モード          : {'🔍 ドライラン(変更なし)' if dry_run else '⚡ 実行モード'}")
    print(f"  ファームウェア合計: {fmt_mb(total_fw_mb)}")
    print(f"  保持するサイズ  : {fmt_mb(keep_mb)}")
    print(f"  削減できるサイズ: {fmt_mb(candidate_mb)}  ({candidate_mb/total_fw_mb*100:.0f}%)")
    print()

    # 実際に保持されるルートディレクトリ/ファイルを表示
    print("【保持するディレクトリ・ファイル(使用中と判定)】")
    active_roots = set(a.split('/')[0] for a in active)
    for name in sorted(active_roots):
        p = FIRMWARE_DIR / name
        if p.exists():
            print(f"{name:<25} {fmt_mb(dir_size_mb(p)):>10}")
    for name in sorted(ALWAYS_KEEP):
        p = FIRMWARE_DIR / name
        if p.exists():
            print(f"{name:<25} {fmt_mb(dir_size_mb(p)):>10} (強制保持)")

    print()
    print("【削除候補(移動対象)】")
    for p in candidates:
        print(f"  🗑️  {p.name:<25} {fmt_mb(dir_size_mb(p)):>10}")

    print("=" * 60)


# ============================================================
# 実行:ファイルを移動
# ============================================================
def execute_move(candidates: list[Path]) -> dict:
    """
    候補ファイル/ディレクトリをバックアップ先に移動し、
    復元用マニフェストを返す。
    """
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup_dir = BACKUP_BASE / timestamp
    backup_dir.mkdir(parents=True, exist_ok=True)

    manifest = {
        "timestamp": timestamp,
        "backup_dir": str(backup_dir),
        "moves": [],
    }

    moved_count = 0
    for src in candidates:
        dst = backup_dir / src.name
        try:
            shutil.move(str(src), str(dst))
            manifest["moves"].append({"src": str(src), "dst": str(dst)})
            print(f"  移動: {src}{dst}")
            moved_count += 1
        except Exception as e:
            print(f"  [ERROR] {src} の移動に失敗: {e}", file=sys.stderr)

    manifest_path = backup_dir / MANIFEST_NAME
    manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False))
    print(f"\n{moved_count} 件を移動しました。")
    print(f"   バックアップ先: {backup_dir}")
    print(f"   マニフェスト : {manifest_path}")

    # 復元スクリプトを生成
    restore_script = backup_dir / "restore.sh"
    _generate_restore_script(manifest, restore_script)
    print(f"   復元スクリプト: {restore_script}")

    return manifest


# ============================================================
# 復元スクリプト生成
# ============================================================
def _generate_restore_script(manifest: dict, output_path: Path):
    lines = ["#!/bin/bash", "# 自動生成された復元スクリプト", f"# 生成日時: {manifest['timestamp']}", "set -e", ""]
    lines.append(f'BACKUP_DIR="{manifest["backup_dir"]}"')
    lines.append("")
    lines.append('if [ "$(id -u)" -ne 0 ]; then')
    lines.append('  echo "このスクリプトは root で実行してください (sudo ./restore.sh)"')
    lines.append("  exit 1")
    lines.append("fi")
    lines.append("")
    lines.append('echo "ファームウェアを復元します..."')
    for move in manifest["moves"]:
        src = move["dst"]  # バックアップ先が新しいsrc
        dst = move["src"]  # 元の場所が新しいdst
        dst_parent = str(Path(dst).parent)
        lines.append(f'mkdir -p "{dst_parent}"')
        # 復元先に既に存在する場合はスキップする bash 処理
        lines.append(f'if [ ! -e "{dst}" ]; then')
        lines.append(f'  mv "{src}" "{dst}" && echo "  復元: {dst}"')
        lines.append('else')
        lines.append(f'  echo "  [SKIP] すでに存在します: {dst}"')
        lines.append('fi')
    lines.append("")
    lines.append('echo "復元完了!"')
    output_path.write_text("\n".join(lines) + "\n")
    output_path.chmod(0o755)


# ============================================================
# 復元モード
# ============================================================
def do_restore():
    """最新のバックアップからファームウェアを復元する。"""
    if not BACKUP_BASE.exists():
        print("[ERROR] バックアップディレクトリが見つかりません。", file=sys.stderr)
        sys.exit(1)

    backups = sorted(BACKUP_BASE.iterdir(), reverse=True)
    if not backups:
        print("[ERROR] バックアップが存在しません。", file=sys.stderr)
        sys.exit(1)

    latest = backups[0]
    manifest_path = latest / MANIFEST_NAME
    if not manifest_path.exists():
        print(f"[ERROR] マニフェストが見つかりません: {manifest_path}", file=sys.stderr)
        sys.exit(1)

    manifest = json.loads(manifest_path.read_text())
    print(f"バックアップ {manifest['timestamp']} から復元します...")

    restored = 0
    for move in manifest["moves"]:
        src = Path(move["dst"])  # バックアップ場所
        dst = Path(move["src"])  # 元の場所
        if not src.exists():
            print(f"  [SKIP] {src} が見つかりません")
            continue

        if dst.exists():
            print(f"  [INFO] すでに存在するためスキップ(または手動で削除してください): {dst}")
            continue

        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.move(str(src), str(dst))
        print(f"  復元: {dst}")
        restored += 1

    print(f"\n{restored} 件を復元しました。")


# ============================================================
# メイン
# ============================================================
def main():
    global FIRMWARE_DIR
    parser = argparse.ArgumentParser(
        description="Linux ファームウェア不要ファイル削除ツール(デフォルト: ドライラン)"
    )
    parser.add_argument(
        "--execute",
        action="store_true",
        help="実際にファイルを移動する(デフォルトはドライラン)",
    )
    parser.add_argument(
        "--restore",
        action="store_true",
        help="最新のバックアップから元に戻す",
    )
    parser.add_argument(
        "--firmware-dir",
        default=str(FIRMWARE_DIR),
        help=f"ファームウェアディレクトリ(デフォルト: {FIRMWARE_DIR}",
    )
    args = parser.parse_args()

    FIRMWARE_DIR = Path(args.firmware_dir)

    # root チェック(restore / execute 時)
    if (args.execute or args.restore) and os.geteuid() != 0:
        print("[ERROR] --execute / --restore は root 権限が必要です。sudo で実行してください。")
        sys.exit(1)

    if args.restore:
        do_restore()
        return

    # 使用中FWを検出
    active = get_active_fw_dirs()

    # 削除候補を決定
    candidates = get_candidates(active)

    # レポート表示
    dry_run = not args.execute
    print_report(active, candidates, dry_run)

    if dry_run:
        print("\n💡 実際に移動するには: sudo python3 firmware_cleanup.py --execute")
        print("💡 復元するには      : sudo python3 firmware_cleanup.py --restore")
    else:
        if not candidates:
            print("削除候補はありません。")
            return
        confirm = input("\n上記のファイルを移動します。続行しますか? [y/N]: ").strip().lower()
        if confirm != "y":
            print("中止しました。")
            sys.exit(0)
        execute_move(candidates)


if __name__ == "__main__":
    main()
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?