Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Goで実装したアプリケーションのメトリックをOpenTelemetryで計装する

これはOpenTelemetry Advent Calendarの14日目です。

qiita.com

どんな話がいいかなと考えていたのですが、ここでは「アプリケーションとOpenTelemetry Collectorがどのように関わってメトリックを(Prometheusなどの)バックエンドサービスに送信するのか」を見ていこうと思いました。今からOpenTelemetryを触るならOpenTelemetry Collectorは実質必須なコンポーネントだと思うで、関係を把握しておくと嬉しいことがあるかもしれません。

OpenTelemetry Collectorとは何か

記憶によると、2020年頃はOpenTelemetry Collectorが存在していなかったので、過去に書いたOpenTelemetryでメトリックを記録するではアプリケーションにExporter*1を組み込んでいました。その構成は今でも可能だとは思いますが、現在はOpenTelemetry Collectorという層を一つ挟んで、OpenTelemetry Collectorがバックエンドサービスへ送る構成が主流です。そのため、トレースやログも同様ですが、メトリックを計装する場合もアプリケーションとOpenTelemetry Collectorの両方を実行する必要があります。

アプリケーションのコードを書く

まず、OpenTelemetryで10秒ごとに1をカウントするだけのコードを書いてみましょう。このコードでは localhost:4317*2でOpenTelemetry Collectorが待ち受けている想定ですが、まだCollectorは実行していません。後で実行するので今は気にせず読み進めてください。

package main

import (
    "context"
    "log"
    "time"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
    "go.opentelemetry.io/otel/sdk/metric"
)

func main() {
    log.SetFlags(0)
    ctx := context.Background()

    // export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 としても同じ
    // https://opentelemetry.io/docs/concepts/sdk-configuration/otlp-exporter-configuration/
    exp, err := otlpmetricgrpc.New(ctx,
        otlpmetricgrpc.WithInsecure(),
        otlpmetricgrpc.WithEndpoint("localhost:4317"),
    )
    if err != nil {
        log.Fatalln(err)
    }
    meterProvider := metric.NewMeterProvider(metric.WithReader(
        metric.NewPeriodicReader(exp, metric.WithInterval(time.Minute)),
    ))
    defer meterProvider.Shutdown(ctx)
    otel.SetMeterProvider(meterProvider)

    meter := global.Meter("github.com/lufia/otel-demo")
    counter, err := meter.SyncInt64().Counter("demo-app/counter")
    if err != nil {
        log.Fatalln(err)
    }
    for {
        counter.Add(ctx, 1)
        time.Sleep(10 * time.Second)
    }
}

カウントしているのは以下の部分です。簡単ですね。

counter, _ := meter.SyncInt64().Counter("demo-app/counter")
counter.Add(ctx, 1)

カウントの型

上記のコードでは SyncInt64Counter を使いましたが、他にもいくつか利用できます。大きく「同期」か「非同期」があって、単純な数値演算の場合は同期を使うとよいでしょう。同期と比べると非同期のほうがやや手間がかかるのですが、メモリ状況の取得など、数値演算よりも重い処理は非同期のほうを検討してもいいかもしれません。

また、同期と非同期でそれぞれ、どのような種類のメトリックなのかを表す型があります。まとめると以下のように分類できます。

  • SyncInt64, SyncFloat64の場合に使える型
    • Counter
    • UpDownCounter
    • Histgram
  • AsyncInt64, AsyncFloat64の場合に使える型
    • Counter
    • UpDownCounter
    • Gauge

関連ドキュメント

値の他にも、単位やラベルなどをオプションとして渡すことができます。以下のドキュメントにはコード例なども書かれているので、Goに馴染みのある人ならすぐに分かるのではないかと思います。

OpenTelemetry Collectorを実行する

アプリケーションのコードはできたので、ここからはOpenTelemetry Collectorを localhost:4317 で待ち受けるように準備しましょう。Linuxのパッケージでインストールする方法など、公式にいくつかの導入方法が存在しますが、まずは最も手軽に実行できるDockerコンテナで動かしてみます。

Docker以外の実行方法も用意されているので、興味があれば以下のドキュメントを参照してください。

DockerでOpenTelemetry Collectorを実行

公式にイメージが提供されているので、docker run するだけで実行できます。

$ docker run otel/opentelemetry-collector:latest

実際に使う場合は、それぞれの環境で必要なコンポーネントを調整したくなるでしょう。Dockerで実行する際に設定ファイルをOpenTelemetry Collectorへ与えたい場合は、ボリュームマウントで/etc/otelcol/config.yamlとします。

$ docker run -v $(pwd)/config.yaml:/etc/otelcol/config.yaml otel/opentelemetry-collector:latest

設定ファイルの書き方は以下のドキュメントを参照ください。

OpenTelemetry Collectorを使う場合、アプリケーションはOTLPプロトコルでCollectorへメトリックを送信します。アプリケーションからのメトリックを受け取るために、OpenTelemetry Collector側ではotlpreceiverを有効にしています。これはデフォルトで4317ポートを使うのでオプションなどは何も構成していません。また、受け取ったメトリックをfileexporterで標準出力*3に書き出すように設定しました。

receivers:
  otlp:
    protocols:
      grpc:

exporters:
  file:
    path: /dev/stdout

service:
  pipelines:
    metrics:
      receivers: [otlp]
      exporters: [file]

以上をまとめると、以下のコマンドでOpenTelemetry Collectorを実行します。

$ docker run -v $(pwd)/config.yaml:/etc/otelcol/config.yaml -p 4317:4317 otel/opentelemetry-collector:latest

これで、先に挙げたカウントプログラムを実行すると、ログに以下のようなJSONが流れてくると思います。

{
  "resourceMetrics": [
    {
      "resource": {
        "attributes": [
          {
            "key": "service.name",
            "value": {
              "stringValue": "unknown_service:main"
            }
          },
          {
            "key": "telemetry.sdk.language",
            "value": {
              "stringValue": "go"
            }
          },
          {
            "key": "telemetry.sdk.name",
            "value": {
              "stringValue": "opentelemetry"
            }
          },
          {
            "key": "telemetry.sdk.version",
            "value": {
              "stringValue": "1.11.2"
            }
          }
        ]
      },
      "scopeMetrics": [
        {
          "scope": {
            "name": "demo-app"
          },
          "metrics": [
            {
              "name": "demo-app/counter",
              "sum": {
                "dataPoints": [
                  {
                    "startTimeUnixNano": "1670849814514923712",
                    "timeUnixNano": "1670849934524305041",
                    "asInt": "12"
                  }
                ],
                "aggregationTemporality": 2,
                "isMonotonic": true
              }
            }
          ]
        }
      ],
      "schemaUrl": "https://opentelemetry.io/schemas/1.12.0"
    }
  ]
}

OpenTelemetry Collectorを自前でビルドする

上ではDockerでOpenTelemetry Collectorを実行しましたが、もうひとつ自分でビルドしたCollectorを実行する方法も試してみます。

現在、OpenTelemetry Collectorは otelcol と呼ばれるコア部分と一部コンポーネントの実装と、ベンダーに依存したコンポーネントなどを追加した otelcol-contrib といった2つのディストリビューションが存在します。ソースコードは以下のリポジトリで管理されています。

また、ビルドした実行ファイルは以下のリポジトリで提供されています。

自前でビルドする場合、コアに含まれているコンポーネントだけで足りるならopentelemetry-collectorをビルドすればいいのですが、おそらく他のコンポーネントを使いたくなると思うので、ここでは otelcol-contrib をビルドすることにしました。

ビルド方法

ビルドの手順としては、Goをインストールした状態で、以下を実行するだけ*4です。

$ git clone https://github.com/open-telemetry/opentelemetry-collector-contrib.git
$ cd opentelemetry-collector-contrib

$ make otelcontribcol

数分待って bin/otelcontribcol_xxx に実行ファイルが作られたら完了です。xxx の部分は環境によって変化します。

$ ./bin/otelcontribcol_${GOOS}_${GOARCH} --config=config.yaml

カスタムビルド

必要なコンポーネントだけ組み込んだOpenTelemetry Collectorも作れるらしいので、興味がある人は以下を試してみてください。手順をみる限りは、opentelemetry-collector-contribに含まれていないコンポーネントも追加できそうにみえますね。

他の手段でメトリックを取得する

最初に、アプリケーションで計装するコードを実装しましたが、全部を自分で実装するのは大変です。OpenTelemetryでは他の手段でも取得できるようなコンポーネントなどが用意されています。

レシーバに他のコンポーネントを追加する

レシーバを設定しておくと、それに従ってOpenTelemetry Collectorがメトリックを集めてくれるようになります。昨日の記事でも使われていますが、例えばホストのCPUやメモリなどはhostmetricsコンポーネントで取得できます。

--- config.yaml  2022-12-12 22:57:49.022845490 +0900
+++ config-host.yaml  2022-12-12 22:57:36.169550825 +0900
@@ -1,4 +1,8 @@
 receivers:
+  hostmetrics/basic:
+    scrapers:
+      cpu:
+      memory:
   otlp:
     protocols:
       grpc:
@@ -10,5 +14,5 @@
 service:
   pipelines:
     metrics:
-      receivers: [otlp]
+      receivers: [hostmetrics/basic, otlp]
       exporters: [file]

contribのreceiverには hostmetrics の他にもいろいろあるので眺めてみる面白いかもしれません。

ライブラリを組み込む

コンポーネントのほかにも、ライブラリとして用意されていることもあります。opentelemetry-go-contrib には net/http のレイテンシや送受信バイト数を取得できるミドルウェアなど、よく使うものが用意されています。OpenTelemetry公式のほかにも、go-redisにも同等の実装があるらしいので、利用しているライブラリを調べてみるといいかもしれません。

ただしopen-telemetry/opentelemetry-goのMetrics APIはまだAlphaなので、(以前よりは穏やかになったとはいえ)まだ変化しています。そのため、公式のライブラリはだいたい新しいAPIに対応できていると思いますが、サードパーティのものは追従できていない場合もあるかもしれませんので、そこは注意してお使いください。

*1:バックエンドサービスへメトリックやトレースなどを送るコンポーネント

*2:ここでは直接書いていますが、OTEL_EXPORTER_OTLP_ENDPOINTなどOpenTelemetry Protocol Exporterに書かれている環境変数を参照する方が良いと思います

*3:ログファイルに書き込む場合は事前に作っておく必要があった

*4:Plan 9でも動くかなと思って試してみたけど定数が定義されていないなどでビルドエラーになった

Linux版のSteamでWindowsのゲームをする話(主にGNOME+Wayland環境)

最近、SteamをLinuxにインストールしてWindowsのゲームを遊んでいます。Steamのインストール自体はそんなに困ることはありませんでしたが、コントローラーやパフォーマンスのところでいくつか悩んだところがあったので忘れないように記事にします。グラフィックスやハードウェア関連は本当に素人なので勘が働かなくて難しかったですね...

手元の環境

2022年12月現在、私物では2021年のVAIO Z勝色特別仕様にArch Linuxを入れて使っています。セットアップは12インチMacBookにArch Linuxをインストールしたときの内容とほとんど同じですが、ゲームに関係ありそうな要素はこの辺りでしょうか。

  • GNOME
  • Wayland
  • PipeWire+WirePlumber

Steamのインストール

Steamを動作させるには32bitアプリケーションのサポートが必要です。Arch Linuxではmultilibリポジトリから各種32bitパッケージのインストールが可能ですが、あまり環境を汚したくないのでFlatpakでSteamをインストールしました。

$ flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
$ flatpak --user install flathub com.valvesoftware.Steam
$ flatpak --user install flathub com.valvesoftware.Steam.CompatibilityTool.Proton

手順としてはこれだけです、簡単ですね。Steamで販売されているゲームの一部はLinuxに対応していますが、基本的にはWindowsのゲームを動かすことになると思うのでProtonも必要になります。OpenGLやVulkanのAPI(org.freedesktop.Platform.GL32.default)は、Steamが依存しているライブラリとして一緒にインストールされるので気にする必要はありません。

また、Steamと一緒にVAAPIもインストールされますが、これはVideo Accelaration APIらしいです。

Steamの起動

Arch LinuxでFlatpakをインストールするとXDG_DATA_DIRSPATHが通っているので、Flatpakでインストールしたアプリケーションは自然とGNOMEのアプリケーション一覧に出現します。そのため通常はランチャーからSteamを起動すればいいのですが、コマンドラインからSteamを起動する方法も知っておくと便利です。

$ flatpak run com.valvesoftware.Steam [-steamdeck] [-gamepadui]

上記コマンドの-steamdeck-gamepaduiは省略可能です。これらのオプションを与えると、ゲームパッドでも操作できるモードでSteam自体が全画面で実行されます。ゲームコンソールを起動した直後のイメージですね。

SteamはXWaylandを使って実行されているようです。XWaylandを使うプログラムかどうかは、xlsclientsコマンドなどで確認できます。

$ sudo pacman -S xorg-xlsclients
$ xlsclients
lufia-pc  gsd-xsettings
lufia-pc  ibus-x11
lufia-pc  gnome-shell
lufia-pc  steam

Protonを有効にする

Protonはインストールしただけでは有効になっていません。この状態では、Windows用のゲームを起動するボタンが無効になっており起動できないので、Steamのメニューから、Steam→Settings→Steam Play→Advancedと画面を遷移すると

  • Enable Steam Play for all other titles

という項目があるので、 Proton 7.0 を使うように設定してください。バージョン番号のところは、新しいものがあればそちらを使う方がいいかもしれません。

ここまで終われば、Windows用のゲームをLinuxでも遊べるようになっています。

ゲームパッドの設定

まず、Windowsゲームでの主要な入力方式はDirectInputとXInputの2種類あります。DirectInputよりXInputの方が新しいので、コントローラでXInputが使えるならこちらを使う方が好ましいでしょう。Linuxでは、コントローラデバイスからの入力はドライバを通してゲームパッドエミュレーションに渡され、ゲームからはエミュレーションによってXboxコントローラとして認識されるようです。

ものによっては何もしなくても動作するのかもしれませんが、所有しているアーケードコントローラ(リアルアーケードPro.V サイレントHAYABUSA、以下RAP5)は、Steamには認識されていてボタンのコンフィグなどでも反応するけれど、ゲームを起動するとレバーもボタンも動かない状況でした。RAP5はDirectInputとXInput両方の実装があって、筐体横のスイッチで切り替えます。PCモードに切り替えるとXInputの実装が使われます。

次にSteamをコマンドラインから起動すると、

Couldn't initialize virtual gamepad: Couldn't open /dev/uinput for writing

というエラーが出力されていたので、udev で書き込みを許可しておく必要があることが分かります。

inputグループにwrite権限を追加

AURには game-devices-udev パッケージが存在しています*1が、こんなに多くは必要ないので自分で必要なだけ追加しましょう。グループは何でもいいのですが、トラックパッド指紋認証など入力デバイス関係の権限は input グループに与えているので、ここでも input グループに与えることにしました。

$ sudo homectl update --member-of=input <username>

# 他のグループにも所属している場合はカンマ区切りで与える
$ sudo homectl update --member-of=wheel,input <username>

次に udev のルールを書くのですが、まずはデバイスのIDを調べましょう。journalctlでログを出したままコントローラを差し込むと以下のようなログが出力されます。

$ journalctl -f
 8月 11 17:11:04 lufia-pc kernel: usb 3-6.3.1: new full-speed USB device number 32 using xhci_hcd
 8月 11 17:11:04 lufia-pc kernel: usb 3-6.3.1: New USB device found, idVendor=0f0d, idProduct=00b1, bcdDevice= 1.14
 8月 11 17:11:04 lufia-pc kernel: usb 3-6.3.1: New USB device strings: Mfr=1, Product=2, SerialNumber=3
 8月 11 17:11:04 lufia-pc kernel: usb 3-6.3.1: Product: Real Arcade Pro.V HS
 8月 11 17:11:04 lufia-pc kernel: usb 3-6.3.1: Manufacturer: HORI CO.,LTD.
 8月 11 17:11:04 lufia-pc kernel: usb 3-6.3.1: SerialNumber: 55DE86DB
 8月 11 17:11:04 lufia-pc kernel: input: Generic X-Box pad as /devices/pci0000:00/0000:00:14.0/usb3/3-6/3-6.3/3-6.3.1/3-6.3.1:1.0/input/input40

ここで出力された idVendoridProduct を控えておき、udev のルールを書いていきます。このルールはSteam Controller known issues and platform-specific notesを参考に、必要なところだけ抜粋しました。

# /etc/udev/rules.d/99-rap5.rules

# This rule is needed for basic functionality of the controller in Steam and keyboard/mouse emulation.
SUBSYSTEM=="usb", ATTRS{idVendor}=="28de", MODE="0666"

# This rule is necessary for gamepad emulation.
KERNEL=="uinput", MODE="0660", GROUP="input", OPTIONS+="static_node=uinput"

# Valve HID devices over USB hidraw
KERNEL=="hidraw*", ATTRS{idVendor}=="28de", MODE="0666"

# HORI RAP5
KERNEL=="hidraw*", ATTRS{idVendor}=="0f0d", ATTRS{idProduct}=="00b1", MODE="0660"

保存した後は sudo udevadm control --reload で反映されると思ったのですが、動かなかったのでOSごと再起動しました。これで上記のエラーが消えて、RAP5がゲーム中でも使えるようになりました。

一般的なゲームパッドの情報は以下も参考にしてください。

eGPUを買う

VAIO Zは、IntelのCPUに統合されたGPU(Integrated GPU、以下iGPU)であるIris Xeグラフィックスを持っていますが、ゲームによっては非力さが否めません。例えばiGPUでGGSTをプレイすると、ヒットエフェクトが激しい場面では30FPS程度まで落ちてしまいます。

近年では、USB Type-CケーブルでThunderboltとして外付けのGPUを接続することで、重い描画処理をオフロードすることができるらしいので、外部GPU(External GPU、以下eGPU)の筐体とAMDのグラフィックボードを購入しました。今回購入したものは以下の2つです。

VAIO ZはThunderbolt 4に対応しているのですが、現在まだThunderbolt 4対応のケースは無いようです。Thunderbolt 4搭載端末にeGPUを接続してベンチマークを取ってみたによると、帯域としてはThunderbolt 3も4も同じらしいので、大きな支障はないだろうと判断してThunderbolt 3接続のケースを買いました。また、NVIDIAのグラフィックボードは情報量は多いけれどもLinuxで使うときのトラブルも多そうだったので、AMDのボードで頑張ったら買える価格のものを選びました。

HDMI/DP/USB Type-CでeGPUに接続した場合の音声

eGPUから外部ディスプレイに接続する場合の音声はどういった経路で送られるのか不明でした。USB Type-Cでディスプレイに接続していたときは外部ディスプレイのスピーカーを使っていたけれど、eGPUから外部ディスプレイにHDMI/DisplayPort/USB Type-Cのいずれかで接続したとき

  • 音声はどの経路で送られることになるのか
  • スピーカーを別途用意する必要はあるのか

が気になりました。これの答えは、HDCP対応のグラフィックボードであれば、OSからはグラフィックデバイスサウンドバイスの2つが追加されたように認識されるので、サウンドの設定で切り替えるとeGPUからディスプレイへ音声が送られることになります。

以下の出力で、0000:3d:00.1 という識別番号でオーディオコントローラが見えていますね。

$ lspci -k | sed -n '/Radeon/,+7p'
0000:3d:00.0 VGA compatible controller: Advanced Micro Devices, Inc. [AMD/ATI] Navi 24 [Radeon RX 6400/6500 XT/6500M] (rev c1)
    Subsystem: ASRock Incorporation Device 5227
    Kernel driver in use: amdgpu
    Kernel modules: amdgpu
0000:3d:00.1 Audio device: Advanced Micro Devices, Inc. [AMD/ATI] Navi 21/23 HDMI/DP Audio Controller
    Subsystem: Advanced Micro Devices, Inc. [AMD/ATI] Navi 21/23 HDMI/DP Audio Controller
    Kernel driver in use: snd_hda_intel
    Kernel modules: snd_hda_intel

eGPUとPCの接続方法

現在、PRIMEと呼ばれる技術を使って、一部の描画処理だけをiGPUから別のGPUへ切り替えることができるようです。これはNVIDIAではOptimusと呼ばれているものに相当します。

Linuxのハイブリッドグラフィックス実装はVGA Switcherooサブシステムで行われているようです。

PRIMEが有効な環境の場合、特に指定しなければプライマリGPU(ほとんどの場合はiGPU)で描画を行います。ゲームや3Dのレンダリングなど描画の負荷が高いアプリケーションをeGPU側で描画させるには、環境変数DRI_PRIME=1を設定してプロセスを起動するだけです。

# iGPUで実行する場合
$ glxgears

# eGPUで実行する場合
$ DRI_PRIME=1 glxgears

GPUが意図通りに切り替わっているかどうかは、以下のコマンドなどで確認ができます。

$ sudo pacman -S mesa-utils
$ glxinfo -B | grep 'OpenGL render'
OpenGL renderer string: Mesa Intel(R) Xe Graphics (TGL GT2)

$ DRI_PRIME=1 glxinfo -B | grep 'OpenGL render'
OpenGL renderer string: AMD Radeon RX 6500 XT (navi24, LLVM 14.0.6, DRM 3.48, 6.0.8-arch1-1)

この他にも、GNOMEを使っている場合はgnome-control-center-print-rendererを使っても同じ内容を確認できます。

$ /usr/lib/gnome-control-center-print-renderer
Mesa Intel(R) Xe Graphics (TGL GT2)

$ /usr/lib/gnome-control-center-print-renderer
OpenGL renderer string: AMD Radeon RX 6500 XT (navi24, LLVM 14.0.6, DRM 3.48, 6.0.8-arch1-1)

またPRIMEでは、

  • eGPUにオフロードした描画結果をノートPCに備え付けのディスプレイに出力する
  • eGPUにオフロードした描画結果をeGPUに接続している外部ディスプレイへ出力する

といったことが可能です。従って、ノートPCとeGPUを接続する場合、以下のどちらかを選択することになるのではないでしょうか。

  • PCとeGPUを接続する、eGPUから外部ディスプレイへ接続する
  • PCとeGPUを接続する、またPCから外部ディスプレイへも接続する(eGPUは外部ディスプレイには接続しない)

私は後者の接続方法を選択しています。理由は下のほうにも書いていますが、2022年12月時点では前者の接続方法ではパフォーマンスの問題があると分かったからです。

SteamのゲームをDRI_PRIME=1で実行する

SteamのゲームをeGPUで描画させるには、ゲームのタイトルを右クリックしたメニューのプロパティから「起動オプション」を探して、以下の内容に変更します。

DRI_PRIME=1 %command%

プライマリディスプレイとは

GNOMEの場合、「設定」→「ディスプレイ」と遷移するとプライマリとなるディスプレイを変更できます。プライマリにしたディスプレイにはGNOME Shellのトップバーなどが配置されます。

プライマリGPUとは

プライマリディスプレイとは別で、プライマリGPUという存在もあります。プライマリGPUGNOMEの設定から変更することはできませんが、確認はできます。「設定」→「このシステムについて」を開いたときにグラフィックとして記載されているGPUがプライマリGPUです。

現在のプライマリGPUを調べる方法は、「設定」以外にもいくつかの方法があります。有名なところだと gnome-control-center-print-renderer コマンドまたは glxinfo コマンドがあります。

$ /usr/lib/gnome-control-center-print-renderer
Mesa Intel(R) Xe Graphics (TGL GT2)

$ sudo pacman -S mesa-utils
$ glxinfo -B | grep 'OpenGL render'
OpenGL renderer string: Mesa Intel(R) Xe Graphics (TGL GT2)

ところで、プライマリGPUはどういった存在なのでしょうか。GNOME+Wayland環境の場合、GNOMEのWaylandコンポジタであるMutterはプライマリGPUでウィンドウなどを描画します。

セカンダリGPUからディスプレイへ出力したときの性能

少し上で、

2022年12月時点では「PCとeGPUを接続する、eGPUから外部ディスプレイへ接続する」の接続方法ではパフォーマンスの問題がある

と書きました。

一部の描画をPRIMEによってeGPUに切り替えたとき、切り替えた描画それ自体はeGPUで行われます。だけどもディスプレイに描画するためには、他のウィンドウと重ねて1枚の画像として出力する必要があります。そのためeGPUが描画した結果をプライマリGPU側へ戻して、他のウィンドウと合成する必要がありますが、eGPUからディスプレイに接続している構成では、プライマリGPUで合成した結果をさらにeGPU側へ送り、ディスプレイへ送信するコストが発生します。

手元で試した限りでは、iGPUからディスプレイへ接続する場合は60FPSを維持するけれど、eGPUからディスプレイへ接続した場合では55FPS前後まで落ちてしまっていたので、その方法は諦めました。

ただ、DMA-BUF Feedback という仕様が実装されつつあり、これが使えるようになれば上記どちらの構成でもパフォーマンスの問題は無くなるかもしれません。GNOMEは42で対応されて、MESAは22.3で対応されましたが、FlatpakでインストールしたSteamはまだMESA 22.1.7であったので使えないようです。

GNOME(Wayland)でプライマリGPUを切り替える

プライマリGPUはデフォルトだとiGPU側が選ばれますが、設定によって変更することは可能です。もちろんeGPU側をプライマリGPUにすると合成もeGPU側で行えるため、eGPUで描画してeGPUに接続したディスプレイへ出力したときのパフォーマンス問題は解消します。

udev で適切なタグを設定すると、GNOMEのプライマリGPUを変更できます。例えば /etc/udev/rules.d/61-mutter-primary-gpu.rules

ENV{DEVNAME}=="/dev/dri/card1", TAG+="mutter-device-preferred-primary"

とした状態でログイン時にeGPUが認識されていると、プライマリGPUとして/dev/dri/card1*2が採用されます。card1の名前は相対的なものなので、必要ならlspci -kなどで識別番号を調べて、その識別番号を/dev/dri/by-path/pci-xxxから探してください。

$ lspci -k | grep VGA
0000:00:02.0 VGA compatible controller: Intel Corporation TigerLake-LP GT2 [Iris Xe Graphics] (rev 03)
0000:3d:00.0 VGA compatible controller: Advanced Micro Devices, Inc. [AMD/ATI] Navi 24 [Radeon RX 6400/6500 XT/6500M] (rev c1)

$ ls -l /dev/dri/by-path/pci-0000:3d:00.0-card 
lrwxrwxrwx 1 root root 8 12月  6 22:30 /dev/dri/by-path/pci-0000:3d:00.0-card -> ../card1

タグが適切に設定できているかどうかは、以下のコマンドで確認できます。

$ udevadm info --query=all /dev/dri/card1 | grep TAGS
E: TAGS=:seat:mutter-device-disable-client-modifiers:master-of-seat:uaccess:mutter-device-preferred-primary:
E: CURRENT_TAGS=:seat:mutter-device-disable-client-modifiers:master-of-seat:mutter-device-preferred-primary:uaccess:

これで再起動の後にゲームを実行するとeGPUがプライマリGPUとして使われて、DRI_PRIME=1しなくてもeGPU側で描画できるようになりますが、プライマリGPUを変更した場合は以下のデメリットが存在します。

  • 常にeGPUが使われるので電力消費が大きい
  • eGPUを外すことができない(外すとWaylandセッションが切れるのでログイン画面に戻ってしまう)

消費電力はまあ少し我慢すればいいのですが、ケーブルを抜くたびにログイン画面に戻ってしまうのは不便なため、プライマリGPUを変更することは諦めました。

Sway(wlroots)でプライマリGPUを切り替える

試していませんが、Swayなどwlrootsを使っているコンポジタの場合は、WLR_DRM_DEVICES環境変数で変更できるようです。

WLR_DRM_DEVICES=/dev/dri/card1:/dev/dri/card0

X11でプライマリGPUを切り替える

こちらも試していませんが、xrandrの設定でPrimaryGpuオプションを設定すると変更できるようです。

トラブル事例

eGPUを抜くと画面が固まって操作できなくなる

eGPUを接続した後でケーブルを抜くと、GNOMEの画面が固まったまま操作ができなくなる事象がありました。この場合、sysfs経由で事前にeGPUを取り除いておくと、ケーブルを抜いても固まらなくなります。

まずlspciGPUの識別番号を調べておきましょう。以下の場合は 0000:3d:00.0 が識別番号となります。

$ lspci -k | grep -A 3 VGA
0000:3d:00.0 VGA compatible controller: Advanced Micro Devices, Inc. [AMD/ATI] Navi 24 [Radeon RX 6400/6500 XT/6500M] (rev c1)
    Subsystem: ASRock Incorporation Device 5227
    Kernel driver in use: amdgpu
    Kernel modules: amdgpu

この番号が特定できたらremoveファイルに1を書き込みます。

# 要root
echo 1 >/sys/bus/pci/devices/0000:3d:00.0/remove

これでケーブルを抜いても固まることがなくなります。ついでに、上記コマンドで取り外したあと再接続したい場合は scan ファイルに1を書けば実現できます。

# 要root
echo 1 >/sys/bus/pci/rescan

eGPUがスリープ状態になる

eGPU側にディスプレイを接続せずDRI_PRIME=1で一部の描画だけを行っている場合、eGPUがアイドル状態になるとスリープに入ってしまって、それ以降デバイスが起きるまで使えなくなる事象が発生しました。このスリープはカーネルパラメータにamdgpu.runpm=0を与えると抑止できます。

--- /boot/loader/entries/arch.conf.orig   2022-02-12 22:05:41.680559575 +0900
+++ /boot/loader/entries/arch.conf 2022-11-28 20:15:16.856666803 +0900
@@ -2,4 +2,4 @@
linux /vmlinuz-linux
initrd /intel-ucode.img
initrd /initramfs-linux.img
-options luks.name=ef718fc5-8eb5-44e8-904b-34c3705d3390=cryptroot root=/dev/mapper/cryptroot rw
+options luks.name=ef718fc5-8eb5-44e8-904b-34c3705d3390=cryptroot root=/dev/mapper/cryptroot rw amdgpu.runpm=0

amdgpu.runpm以外のカーネルオプションは以下にまとまっています。

USB Type-Cケーブルを流れるプロトコルは色々ある

USB Type-Cケーブルはケーブル自体の規格であって、中を流れるプロトコルは1つではありません。

PCからUSB Type-Cケーブルを直接ディスプレイへ接続すると、その中を流れる通信はDisplayPort ALT Modeプロトコルです。おそらくディスプレイの仕様に DisplayPort ALT Mode といった記述があると思います。だけどもPCとeGPUをUSB Type-Cケーブルで接続すると、この中を流れる通信はThunderbolt 3または4プロトコルです。こちらもeGPUの仕様にThunderbolt 3などの記述があるのではないかと思われます。

当然ですが、USB機器を接続した場合はUSBのプロトコルが流れます。ケーブルを接続したときにネゴシエートしているのだと思いますが...難しいですね。

HDMIケーブルで接続するとリフレッシュレートが30Hzに落ちる

HDMIのケーブルには複数の規格があり、HDMI 1.2のケーブルは最大1920x1200(60Hz)です。このケーブルを4Kディスプレイへ接続すると、解像度またはリフレッシュレートのどちらかが犠牲となります。HDMI 1.3のケーブルは2008年ごろから市場に出ているようですが、それより古いケーブルを使うときはバージョンに注意しましょう。

FlatpakがNVIDIAのドライバを認識しない

Flatpakが認識しているドライバは--gl-driversオプションで調べられます。AMDIntelGPUを使う場合は、特に気にする必要はありません。

$ flatpak --gl-drivers
default
host

NVIDIAのドライバをインストールした場合はnvidia-375-26のようなドライバが追加されて、環境変数FLATPAK_GL_DRIVERSでドライバを切り替えられるそうですが、NVIDIAGPUは持っていないので手元では試していません。

*1:これはfabiscafe/game-devices-udevをコピーするものらしい

*2:/dev/dri/by-path/pci-xxxのIDなどから適切なデバイスを選んでください

Goフォントの紹介と使い方

Goのソースコードをきれいに描画する目的で作られた「Goフォント」があるのをご存じでしょうか。周囲に聞いたところ、あまり知られていなかったので、紹介の意味も込めてインストール方法を書きます。

Goフォント

Goフォントは、2016年に以下の記事で公開されたもので、単体で配布されたものではなく、golang.org/x/exp/shinyパッケージの一部として配布されています。

go.dev

コミットログを見る限りでは、2016年にv2.004、2017年にv2.008がリリースされた後しばらく更新されていませんでしたが、2022年6月17日にv2.010がgolang-nutsで告知されました。

groups.google.com

フォントの画像は上のブログ記事にもありますし、Goのコードを書きながらAcmeエディタの基本を覚えるチュートリアルの途中からはGoフォントでGoのコードを書いている画像を貼っているので、興味があれば参考にしてください。

ところで、Goフォントは記号などを合成しません。なので -> にならないし、<= になったりしません。個人的には記号でリガチャするのは好きではないのでGoフォントを気に入っていますが、この辺りは好みもあるかなと思います。

インストールする

上にも書いたように、Goフォントはexp/shinyパッケージの一部として配布されているので、少し深い階層からコピーする必要があります。

$ git clone https://go.googlesource.com/image

Linuxの場合はfonts/TTFディレクトリにコピーしましょう。

$ cp image/font/gofont/ttfs/*.ttf ~/.local/share/fonts/TTF/
$ fc-cache -fv

# またはシステム全体に反映する場合
$ sudo cp image/font/gofont/ttfs/*.ttf /usr/share/fonts/TTF/

macOSの場合はこちら。コピー先が違うだけです。

$ cp image/font/gofont/ttfs/*.ttf ~/Library/Fonts/

# またはシステム全体に反映する場合
$ cp image/font/gofont/ttfs/*.ttf /Library/Fonts

エディタに設定する

これでフォントがシステムに認識されて、使えるようになっているはずです。具体的な設定方法は、使用するテキストエディタごとに異なるので省略します。

GoフォントはGoRegularなどいくつかのフォントを含みますが、Goのソースコードgofmtなどで整えたときに等幅フォントのほうが読みやすいので、GoMonoが使いやすいかなと思います。

Goのコードを書きながらAcmeエディタの基本を覚えるチュートリアル

AcmeエディタはPlan 9のためにRob Pikeによってデザインされたエディタで、現在はUnixにも移植されています。慣れると非常に快適なんですが、既存のエディタとは操作感が全然違うので、初見では何をすればいいのか分からないとよく聞きます。なので、この記事ではGoのコードを実際に書きながら、Acmeをどうやって使っていくのかを紹介していきます。ただし、Acmeは色々と便利な使い方があり、ひとつの記事で書き切るのは大変なので、複数の記事に分けて公開していこうと思っています。今のところ以下の内容を予定していますが、順番や内容は変更するかもしれません。

  1. Acmeの基本的な使い方(この記事)
  2. コードリーディングするときの使い方
  3. winで生活する方法、拡張機能の紹介など

ところでYouTubeでは、Russ CoxがAcmeを使っている様子を動画にしているので、見てみると学びがあるかもしれません。

www.youtube.com

次もYouTubeの動画で、RussがAcmeでプレゼンしている様子です。Acmeの拡張*1でコマンドをフックすることにより実現しています。

www.youtube.com

一時期、9fansで話題になっていましたが、この動画でタイトル文字のところに使っているフォントの書体が違うのはフォントをハックしているからで、Acmeには一部の文字だけを装飾する機能はありません。

基本的な使い方

まずはacmeを実行してみましょう。Acmeの実行にはplan9portが必要ですが、インストール自体はPlan 9 from User Space(plan9port)を使うに書いたのでここでは省略します。

Acmeはターミナルで

% acme

とするだけで起動します。ここでエラーになった場合はPLAN9環境変数が設定されていないことが原因かもしれないので、変数の値を確認してみてください。

Acmeの起動自体はこれだけなのですが、素の状態ではフォントが汚いとか、必要な9Pサービスが実行されていなくて使いづらい面があるので、macOSの人は以下のアプリケーション*2から使ってもらうと便利かもしれません。

github.com

Acmeを実行すると、カレントディレクトリにあるファイルが右のウィンドウで表示されていて、右は空白の画面が表示されます。

黄色いウィンドウごとに青い背景のヘッダが付いています。また、列ごとにも同様のヘッダがあり、いちばん外側にも同じものがあります。Acmeではコマンドを実行してファイルの保存などを行いますが、一部のコマンドは実行できる場所が決まっています。たとえばファイルを保存するPutは、ファイルを編集するとウィンドウごとのヘッダに出現するし、列を削除するDelcolは列ごとに用意されているヘッダにはじめから用意されています。この部分はタグライン(tag line)と呼ぶらしいけれど、分かりやすさのためこの記事ではヘッダと表記します。

最初から多くのコマンドがヘッダに並んでいますが、以下のチュートリアルで必要になったら説明するので、今はまだ気にしなくてもかまいません。

Goのコードを書く

このチュートリアルではGoのコードを書いていくので、ディレクトリを作りましょう。右のウィンドウ一番下に

mkdir src/acmetut

と入力して、mkdir src/acmetut 全体を選択した状態でマウスの中ボタン(ホイールマウスの場合はホイールクリック)を押すと、テキストで選択したものをコマンドとして実行します。中ボタンは一般的に「実行(Exec)」を意味します。トラックパッドしかない場合は、キーボードと組み合わせることで代用可能です。

  • 中クリック: Altキーを押しながらクリック
  • 右クリック: Commandキーを押しながらクリック

ただ、トラックパッドだけではボタンの同時押しができないなど非常に使いづらいので、マウスを買ったほうがいいと思います。

次に、上記で作ったディレクトリを開きます。mkdir src/acmetutと書いたテキストのうちsrc/acmetutのテキストをどこでもいいので右クリックすると、Acmeは新しいウィンドウでそれを開きます。右ボタンは一般的に「検索(Look)」を意味します。ひとつ上の例ではmkdir src/acmetutを事前に選択してから実行しましたが、スペースや記号を含まない文字列なら自動的に境界までを対象とするので、ここでは事前にsrc/acmetutを選択しておく必要はありません。

開いた直後の画面はこのようになるはず。

これ以降、ホームディレクトリは使わないので閉じましょう。ホームディレクトリが表示されたウィンドウのヘッダにDelと書かれたテキストがあるので、これにポインタをあわせて中クリックで閉じます。

(ヒント)コマンドを選択するのが面倒

上の説明でも少し言及していますが、スペースや特殊な記号を含まないテキストの場合は、選択せずにクリックするとAcmeが前後の区切りまでをコマンドの対象として扱います。src/acmetut/Delなどは単にテキスト上のどこかを中クリックするだけで実行できます。

または、テキストを入力してEscキーを押すと、直前の入力内容だけを選択した状態にするので、スペースや記号などを含む場合はEscキーで選択してから実行すると便利です。1行すべてがコマンドの場合は、行頭または行末でダブルクリックすると1行まるごと選択状態になるので、そのまま実行する方法もあります。

(ヒント)中ボタンでコマンドを実行するシェルを変更する

AcmeはもともとPlan 9のエディタなので、コマンドを実行するときのシェルはrcシェルが使われます。rcはとても書きやすいシェルなので慣れてしまえば困らないと思いますが、POSIXシェルの記法とは全く異なるので最初は混乱するかもしれません。

ここで使われるシェルを変更したい場合、acmeshell環境変数にシェルをセットしておくと、それが使われます。

% export acmeedit=bash
% acme

余談ですが、Plan 9環境変数は基本的に小文字なので、上のコマンドは間違いではありません。homepathなども全部小文字です。

コードを書いてテストする

ではまず簡単なコマンドを書いてみましょう。ホームディレクトリは閉じたので、src/acmetut/ディレクトリを表現したウィンドウだけ残っているはずです。src/acmetut/のウィンドウに

go mod init example.com/acmetut

と入力して、全てを選択した状態で中ボタンを押すと、新しいウィンドウでコマンドの実行結果が表示されます。このときウィンドウのタイトルは/path/to/dir+Errorsのように+Errorsが付いていますが、コマンドが正常に終了した場合でも+Errorsなので気にする必要はありません。src/acmetut+ErrorsのウィンドウはもういらないのでDelで閉じておきましょう。

ところで、go mod initが正常に終了したのでgo.modファイルが作られているはずですが、src/acmetut/ディレクトリはまだ何も表示されていません。Acmeは標準では自動リロードの機能を持たないので、src/acmetut/ディレクトリのヘッダにあるGetコマンドでリロードします(コマンドがなければ自分で入力します)。そうすると、現在のディレクトリにあるファイルリストでウィンドウが更新されてgo.modだけ表示されるはずです。ファイルの内容を見る場合は、ファイル名をポイントして右クリックです。以下のようになっているはず。

module example.com/acmetut

go 1.18

では次に、main.goを作成して、以下の内容で保存してみましょう。

package main

import "fmt"

func main() {
    fmt.Println("hello world")
}

もう分かると思いますが、ファイルの作成は

touch main.go

と書いて、すべて選択してから中クリックです*3main.goを開いて編集するとヘッダにPutというコマンドが増えます。main.goのコードを書き終わったら、Putを実行してファイルに保存しましょう。

(ヒント)コピペしたい

macOS版では、意外とcmd+ccmd+vに対応していた記憶がありますが、Acme本来の方法はマウスを使ったものになります。左ボタンを押しながら範囲を選択し、左ボタンを押したまま同時に中ボタンを押すと選択していたテキストをカットしてSnarfバッファ*4に格納します。同様に、左ボタンを押したまま選択して同時に右ボタンを押すと、Snarfバッファに入っているテキストをペーストします。

ではコピーはどうするのかというと、左ボタンを押したまま、同時に中→右ボタンと順番に押せばコピーです。途中で一瞬だけテキストが消えますが、このときの動作はCutUndoのようになります。

(ヒント)Acmeの設定

Acmeにはユーザーによる設定ファイルはありません*5。フォントや一部の挙動はコマンドラインオプションで指定します。

$ acme -a -f /mnt/font/GoRegular/14a/font -F /mnt/font/GoMono/14a/font

上の例で挙げたオプションは、それぞれ以下の意味を持ちます。

起動直後のAcmeプロポーショナルフォントで文字を描画しています。ウィンドウのヘッダにFontと入力して実行すると、プロポーショナルフォント等幅フォントを切り替えます。上のコマンドラインでは/mnt/fontというUnixでは見慣れないディレクトリが使われていますが、Acmeはフォントとして/mnt/fontで始まるファイルが渡されるとOSが管理するフォントを探しに行くので、このディレクトリを事前に作っておく必要はありません。

fontsrvコマンドで、具体的にどのようなフォントが利用可能か確認できます。

# フォントの名前を確認する
% fontsrv -p . | grep Go
Go-Bold/
Go-BoldItalic/
Go-Italic/
GoMedium/
GoMedium-Italic/
GoMono/
GoMono-Bold/
GoMono-BoldItalic/
GoMono-Italic/
...

# サイズを確認する(aがあるものはアンチエイリアス)
% fontsrv -p GoMono
4/
4a/
5/
5a/
6/
6a/
...

ウィンドウの移動

せっかくなのでここからのスクリーンショットはGoフォントにしました

縦に並んでいると狭いので、main.goのウィンドウを左に移動させましょう。ファイル名の左にある四角をドラッグするとウィンドウを移動できます。そうすると、右の下が空くので、

go run main.go

と書いて中クリックで実行してみましょう。正しく進めていれば以下の画面になるはず。

テストを書く

次にテストを書いてみます。main_test.goファイルを作成して、以下のコードを書いてください。

package main

import (
    "testing"
)

func TestAdd(t *testing.T) {
    n := Add(1, 3)
    if n != 4 {
        t.Errorf("Add(1, 3) = %d; want 4", n)
    }
}

今度はmain_test.goを開いているウィンドウのヘッダに

go test

と入力して実行します。Add関数はまだ実装していないので、当然ですが以下の画面になるはず。

このとき、エラーメッセージにファイル名と行番号が書かれたテキストがあるので、マウスでmain_test.go:8:7を右クリックしてみましょう。そうするとエラーが発生したソースコードの位置にジャンプして行が選択状態になるので、エラーになった行の前後をみて、原因を調べられますね。移動先のファイルが閉じている場合は、新しいウィンドウで開かれます。

今回のエラーはAddが見つからないことが原因なので、main.goAddを実装しましょう。Acmeの動作をみるため、ここでは意図的に、常に4を返すようにします。

func Add(i, j int) int {
    return 4
}

実装した後にPutで保存したら、main_test.goのヘッダに書いていたgo testがまだ残っていると思いますので、もういちどクリックするとテストが通るはずです。

テーブルテスト

ところで、上記のAddは常に4を返すバグがあります。テストを以下のように変更して

func TestAdd(t *testing.T) {
    tests := []struct{
        i, j int
        n int
    }{
        {1, 3, 4},
        {-1, -3, -4},
        {0, 0, 0},
    }
    for _, tt := range tests {
        n := Add(tt.i, tt.j)
        if n != tt.n {
            t.Errorf("Add(%d, %d) = %d; want %d", tt.i, tt.j, n, tt.n)
        }
    }
}

これを保存してからgo testすると以下のような出力になるはずです。

これまでと同じように、main_test.go:19を右クリックしてエラーの箇所(この場合はErrorfの行)にジャンプします。Goでは基本的に<filename>:<line>[:<offset>]のルールで行を出力するので、そのままAcmeでジャンプできるようになっています。この程度の行数だとあまり便利さを感じませんが、もっとテストケースがあるファイルの場合は嬉しいはず。

テストが失敗する理由は見れば分かりますね。常に4を返すところが問題なので、return i + jに変更してテストをすれば通ります。

(ヒント)スクロールの方法

Acmeはキーボードの上下キー、またはスクロールバーの上でマウスの左右ボタンをクリックするとページ単位のスクロールを行います。また、スクロールバーの上でマウスの中ボタンをクリックすると、クリックした位置まで移動しますし、中ボタンを押しながらマウスを上下に移動させると、マウスの移動にあわせて画面がスクロールします。個人的には、ファイルを頭から読むときはキーボードの上下で移動して、それ以外はマウスの中ボタンを使うことが多いですね。

キーボードなどでカーソルを上下に移動する方法は(たぶん)ありません。最初は常にキーボードから手を離さないようにしたかったですが、慣れてしまえば全然困りませんし、広い範囲を選択するときはマウスの方が早いと思います。

gofmt

ところでAcme自体は汎用のテキストエディタなので、保存時にgofmtで整形するなどは行いません。なので少し面倒ですが手動でgofmtしておきましょう。gofmtする方法は複数あります。最も簡単なのは、どこか適当な場所に

go fmt

と書いて実行した後に、差分のあったファイルをGetで再読み込みする方法です。それでもいいのですが、より応用がきく方法として、テキスト全体を選択した状態でヘッダ部分に

| gofmt

と書いて実行すると(先頭の|が重要です)、選択した部分にgofmtを適用します。この方法なら、一部の範囲をソートしたければ選択してから| sortとすればいいし、ほかにも色々と便利に使えます。

ファイル全体を選択するのが面倒な場合は、

Edit , | gofmt

として実行するだけでもいいです。EditAcme独自のコマンドで、Edit 1,$とすると1行目から最終行までを選択状態にしますが、1$は省略できるので,だけでファイル全体を選択する動作になります。

(ヒント)別のターミナルからファイルを渡したい

plumberが動作していればBコマンドで開けます。

最初のほうで紹介したacmeeditリポジトリのパッケージから起動すると、plumberが動作していなければ自動で実行するようになっているのでお手軽です。Bコマンドはファイル名だけではなく行番号なども一緒に渡せるので、以下のような開き方ができます。

% B main.go

% B main.go:20

% B 'main.go:/^func main/'

エディタの終了

では最後にエディタの終了方法です。ここまで進めたならもう分かると思いますが、Acmeのいちばん外側にあるヘッダからExitを実行すれば終了します。ただし、変更されたあと保存していないファイルがが残っている場合はExitが中断するので、Putで保存するかDeleteで保存せずに閉じてからExitしましょう。

おすすめする使いかた

Acmeでは拡張を書かなくても面白い使い方ができますが、特におすすめするのは、独自のスクリプト<filename>:<line>[:<offset>]形式のテキストを出力することです。簡単なものだと

grep -n '<pattern>' file /dev/null (git grepでも同じ)

とすると<pattern>にマッチした行ごとに<filename>:<line>形式の行番号も出力するので、修正する場所をあらかじめ絞り込むときに使うと便利です。grepの結果が出力された+Errors ウィンドウもただのテキストなので、不要な行は削除しておくと、より見通しがよくなります。Plan 9にはgという、カレントディレクトリのソースコードgrepする便利なラッパーコマンドがありますし、Rob Pike氏が紹介していたfcfといったコマンドも、やっていることは同じですね。

blog.lufia.org

ほかにも、grepした結果をブックマークのように残しておくと便利な場合があります。例えばfunc Addgrepすると

/home/lufia/src/acmetut/+Errors

というタイトルになるのですが、このウィンドウを開いたままfunc maingrepすると、結果はfunc Addのウィンドウに追記されます。これはこれで便利なときもあるものの、量が多くなると見通しが悪くなるので、区切りのいいところで+の前に任意の文字列を入れておきましょう。たとえばヘッダを編集して

/home/lufia/src/acmetut/Add+Errors

としておくと、次回以降にgrepしたときは新しく/home/lufia/src/acmetut/+Errorsウィンドウが作られるので、Addのウィンドウをそのまま残すことができます。これは比較的規模の大きいコードを読み進めるときに便利ではないかなと思います。

Acme上でターミナルを開く

Acme上でそのままbashなどを実行しても、すぐに終了してしまって使えません。winという特別なコマンドが用意されていて、winを実行すると新しいウィンドウでシェルのプロンプトが表示されて、そのままシェルとして利用できます。

シェルを実行しているウィンドウもただのテキストなので、コマンドの出力結果などを自由に編集できますが、Unixのターミナルと異なりヒストリや色などには対応していません。ヒストリについては " (quote1) と "" (quote2) が用意されていて、winの履歴からプロンプトのような行をgrepするコマンドがあるので、これを使うことになるでしょう。

色や装飾については、コマンドごとに無効化していく必要があってやや面倒です。次回以降のどこかで紹介するかもしれません。

Acmeのターミナルでgit commitしたい

Russ Cox氏によりeditinacmeコマンドが公開されているので、これをEDITOR環境変数またはGIT_EDITOR環境変数にセットします。

% go install 9fans.net/go/acme/editinacme@latest
% export GIT_EDITOR=editinacme

次の予定

基本的な操作のチュートリアルはこれで終わりです。次回はAcmeでコードリーディングすると便利だという話を書こうと思っていますが、先にGitを使えたほうが便利かもしれないので順番は前後するかもしれません。

過去にもAcmeの使い方を書いた記事があるので、こちらはチュートリアルではありませんが、興味があれば読んでみてください。

blog.lufia.org

*1:実態はAcmeが提供するファイルサーバを読み書きするプログラム

*2:実態はacmeを起動するまでに準備するだけのスクリプト

*3:他の方法もあるけどtouchがいちばん簡単だと思う

*4:クリップボードのようなものです

*5:起動時に読み込みするacme.dumpファイルはありますが、これはウィンドウの状態を保存するためのもので設定ではない

Arch LinuxでTPM2.0デバイスを使って暗号化したボリュームのパスフレーズ入力を省略する

以前の記事で、ルートファイルシステムを暗号化する手順を紹介しました。

blog.lufia.org

ルートとなるボリュームを暗号化すると、再起動のたびにパスフレーズの入力を促されますが、何度も入力するのは面倒です。そこで、TPMバイスに鍵を保存してパスフレーズの入力を省略しました。パスフレーズの入力が必須な状態と比べると少しだけセキュアではなくなりますが、個人的には許容できる範囲かなという印象です。

若干丁寧に説明を書いたので長い記事になってしまいましたが、証明書や鍵の管理を(直接は)していないので、やること自体はそこまで難しくないと思います*1

セキュアブートを有効にする

後で説明していますが、TPMバイスの保護機構では「セキュアブートの設定が変更されたときに保存している鍵を読み取れなく」できます。そのため、この手順でセキュアブートを有効にしていますが、不要なら飛ばしても影響はないと思います。

Arch Linuxでは、セキュアブートを実現する方法は以下の3通りあります。

  • PreLoader+HashTool
  • shim+MokManager
  • 自身の鍵で署名する

丁寧にやるなら、署名するためのマシンを分けて自身の鍵で署名するといいのだろうけど、そこまでするのは私物マシンの管理としては過剰に思いますし、同じマシンで署名するならそれほど意味があるとは思えないので、いちばん簡単なPreLoaderを使う方法を選びました。PreLoaderの方法では、悪意のあるブートローダカーネルに変更したあとでハッシュを追加すれば動いてしまうので、物理的にアクセスできる状況からは保護できませんが、少なくともリモートから書き換えられたとか、誤って不正なコードを実行した、などの状況は防ぐことができるので十分かなと思います。

セキュアブート自体の説明は以下の記事が分かりやすいですが、MOKにはあまり言及されていません。

PreLoader+HashToolの動作

まずはPreLoaderやHashToolが何を行うのか説明した方が理解しやすいと思うので書きます。PreLoaderはloader.efiを実行するだけのプログラムです。PreLoaderにはMicrosoftの署名が施されているので、セキュアブートが有効な状態でも問題なく実行できます。HashToolはPreLoaderから実行されるプログラムで、選択したファイルのハッシュをMOK(Machine Owner Key)へ追加するために使います。

従って、PreLoaderでセキュアブートを有効にすると、最終的にUEFIファームウェアは以下の順番でカーネルまで到達します。

  1. 電源を入れる
  2. UEFIファームウェアがPreLoaderを実行する
  3. PreLoaderはsystemd-bootのブートローダ(名前はloader.efiで固定)を実行する
  4. カーネルを実行する

このとき、UEFIファームウェア

などを確認します。検証に失敗した場合は実行エラーとなって、PreLoaderはHashToolへ制御を移します。HashToolはMOKハッシュ値を登録することができるので、ブートローダカーネルのハッシュをMOKへ登録して再度実行すると、今度は検証に成功するのでブートに成功します。

必要なプログラムのインストール

署名された状態のPreLoaderとHashToolが必要なのでAURからインストールします。また、ブートエントリに登録するためefibootmgrも必要です。

$ sudo pacman -S efibootmgr

$ git clone https://aur.archlinux.org/preloader-signed.git
$ cd preloader-signed
$ makepkg -si

これでPreLoaderとHashToolは/usr/share/preloader-signed以下にインストールされますが、このままではブート時に参照されないので、これを/boot/EFI以下へ手動でのコピーが必要です。また、PreLoaderloader.efiという名前のファイルを実行するので、systemd-bootのブートローダloader.efiとして参照できるようにします。

$ ls /usr/share/preloader-signed
HashTool.efi  PreLoader.efi

$ sudo cp /usr/share/preloader-signed/*.efi /boot/EFI/systemd/
$ sudo cp /boot/EFI/systemd/systemd-bootx64.efi /boot/EFI/systemd/loader.efi

上のコマンドではsystemd-bootx64.efiをコピーしていますが、これはPreLoader経由ではなくファームウェアが直接systemd-bootを実行するブートエントリも残すためです。セキュアブートに失敗してもすぐ戻せるように、残しておく方が安全です。

用意ができたらPreLoaderを実行するためのブートエントリを追加します。--partオプションはパーティション番号を指定するものです。

$ sudo efibootmgr --disk /dev/nvme0n1 --part 1 --create --label PreLoader --loader /EFI/systemd/PreLoader.efi

ここまでで、ブートエントリは以下のような状態になっているはず。

$ efibootmgr -v
BootCurrent: 0001
Timeout: 0 seconds
BootOrder: 0002,0001,0012,0000
Boot0000* Windows Boot Manager  HD(1,GPT,1880c8ba-98e7-4471-ba61-fcc1d165c58d,0x800,0x32000)/File(\EFI\Microsoft\Boot\bootmgfw.efi)WINDOWS.......
Boot0001* Linux Boot Manager    HD(1,GPT,1880c8ba-98e7-4471-ba61-fcc1d165c58d,0x800,0x32000)/File(\EFI\systemd\systemd-bootx64.efi)
Boot0002* PreLoader HD(1,GPT,1880c8ba-98e7-4471-ba61-fcc1d165c58d,0x800,0x32000)/File(\EFI\systemd\PreLoader.efi)

セキュアブートを有効にする

PCを再起動します。このときUEFIのメニューでセキュアブートを有効にして、上記の手順で追加したPreLoaderブートエントリで起動しましょう。そうすると、この時点ではまだloader.efivmlinuz-linuxのハッシュが検証できないので、ブートの途中で検証エラーが発生して*2、HashToolが起動します。HashToolの画面では矢印キーを使って/bootパーティションのファイルを選択できるので、loader.efivmlinuz-linuxの2つを探して、ハッシュをMOKへ登録します。終わったらExitでHashToolを抜けて大丈夫です。

これで、セキュアブートを有効にした状態でブートできるのではないかと思います。セキュアブートが有効になっているかどうかは、以下のコマンドで確認できます。

$ sudo dmesg | grep 'Secure boot'
[    0.010526] Secure boot enabled
[    6.092896] Bluetooth: hci0: Secure boot is enabled

$ bootctl status
System:
     Firmware: UEFI 2.70 (Phoenix Technologies Ltd. 4660.22136)
  Secure Boot: enabled (user)
 TPM2 Support: yes
 Boot into FW: supported

なんらかの理由で起動に失敗した場合は、おそらくUEFIのメニューにブートオーダーの設定もあると思うので、ここから以前のブートエントリを優先に戻して起動すればよいです。

カーネルやsystemd-bootをアップデートした場合

systemdパッケージを更新しても、それだけではブートローダ/boot以下にコピーされません。都度bootctlcpを実行してもいいですが面倒なので、/etc/pacman.d/hooks/systemd-boot.hookなどに書いておくことをおすすめします。

[Trigger]
Type = Package
Operation = Upgrade
Target = systemd

[Action]
Description = Upgrading systemd-boot...
When = PostTransaction
Exec = /bin/sh -c 'bootctl update && cp "$(bootctl -p)"/EFI/systemd/{systemd-bootx64,loader}.efi'

また、vmlinuz-linuxloader.efiを変更した場合、ハッシュ値が変わるのでMOKでの検証が失敗します。このときは上記と同様にHashToolが起動するので、新しいファイルのハッシュを登録し直せば起動できるようになります。

気になりごと

Arch Linuxでは、カーネルは頻繁に更新されるので、HashToolで追加したハッシュの上限はあるのか、あるなら古いハッシュを削除する方法はあるのか、が気になっています。HashTool.cを読むと、MOKの実態はUEFIのMokList変数らしいので、これを扱う方法があればよいのですが、この値はブートサービス固有の変数で通常起動した後は見れません。少くとも/sys/firmware/efi/efivars以下では参照できませんでした。自由に書き換えられると困るのでそうですね...

それから、mokutilコマンドでdbをみたとき、dbに含まれているMicrosoftの証明書は期限が2026年でした。Windows環境ならファームウェアアップデートなどで対応するのかもしれませんが、Linuxではどうすればいいんだろう。

busybox initをsystemd initに変更する

Arch Linuxmkinitcpioは、デフォルトではbusyboxフックを使いますが、systemd-cryptenrollを使うためにはsystemdフックが必要なので切り替えます。難しく聞こえますが、やることは/etc/mkinitcpio.confHOOKS変数を変更するのと、この変更によってカーネルオプションの書式が少し変わるので対応するだけです。

まずはmkinitcpio.confの差分です。

--- mkinitcpio.conf.orig 2022-01-29 16:55:28.098086104 +0900
+++ mkinitcpio.conf   2022-01-31 01:24:39.829166470 +0900
@@ -49,7 +49,7 @@
 #
 ##   NOTE: If you have /usr on a separate partition, you MUST include the
 #    usr, fsck and shutdown hooks.
-HOOKS=(base udev autodetect modconf keyboard keymap block encrypt filesystems fsck)
+HOOKS=(base systemd autodetect modconf keyboard block sd-encrypt filesystems fsck)
 
 # COMPRESSION
 # Use this to compress the initramfs image. By default, zstd compression
@@ -64,4 +64,4 @@
 
 # COMPRESSION_OPTIONS
 # Additional options for the compressor
-#COMPRESSION_OPTIONS=()
+COMPRESSION_OPTIONS=(-9)

autodetectフックは省略可能ですが、このフックはinitramfs-linux.imgから不要なモジュールを削減してくれるものです。イメージは小さいほうが読み込み速度などで有利なので、基本的には含めるほうがいいでしょう。ただし、必要なモジュールなのに誤って削減される場合があるらしく、削減前のイメージをinitramfs-linux-fallback.imgとして生成します。

また、systemdフックはbusyboxフックに比べてファイルサイズが大きくなる傾向があるのでCOMPRESSION_OPTIONSも変更していますが、/bootパーティションに余裕があるなら設定しなくても問題ありません。

設定を更新したら、新しいinitramfs-linux.imgを生成します。

$ sudo mkinitcpio -p linux

systemdフックへの変更に伴い、カーネルオプションの書式も変わります。

--- arch.conf.orig   2022-02-12 22:05:35.920657630 +0900
+++ arch.conf 2022-02-12 22:05:41.680559575 +0900
@@ -2,4 +2,4 @@
 linux /vmlinuz-linux
 initrd /intel-ucode.img
 initrd /initramfs-linux.img
-options cryptdevice=UUID=ef718fc5-8eb5-44e8-904b-34c3705d3390:cryptroot root=/dev/mapper/cryptroot rw
+options luks.name=ef718fc5-8eb5-44e8-904b-34c3705d3390=cryptroot root=/dev/mapper/cryptroot rw

上記のArchWikiを読む限り、rootオプションは無くても動きそうにみえますが、無くしてしまうとブート時に、

A start job is running for /dev/gpt-auto-root

タイムアウトしてしまってブートできませんでした。

TPM 2.0にLUKS2の鍵を入れる

TPMは、雑に表現すると「機密情報を安全に保存するためのストレージ」です。tssグループに参加していれば一般ユーザーでも利用できるので、例えばSSH秘密鍵を安全に保存するためにも利用できます。一般的なストレージとの大きな違いは、ハードウェア構成やブート順などに変化があれば、保存されている機密情報を無効化できるところかなと思います。

無効*3にする条件にはPCR(Platform Configuration Register)の番号を与えます。PCRは0から開始する連番になっていて、ハードウェアによって上限は異なります*4が、0から7くらいまでの用途は事前に決められています。たとえばPCR0には「ファームウェアのコードに変更があれば変わる」ハッシュ値が保存されていて、PCR4はブートローダに変化があれば変わるハッシュ値です。この値は、

PCR[n] = sha256(PCR[n] + data)

のように計算されるので、一度でも変更されると、たとえ設定を元に戻しても以前の値には戻りません。well-knownな各PCRの値は以下のマニュアルにある表がわかりやすいかなと思います。

それぞれのPCRは独立して計算されますが、ブート順を変更すると一緒にパーティションテーブルの状態が変わるなどはよくあるので、その場合はどちらか片方だけ指定しておけば十分です。

PCRの変化例

具体的にPCRの変化を実験してみました。

  1. セキュアブートを無効に変更
  2. Archインストーラからブート

この場合、手元のハードウェアではPCR4、PCR5、PCR8が変化しました。

--- tpm2_pcrread 2022-01-30 03:10:30.473355620 +0900
+++ tpm2_pcrread  2022-01-30 03:09:52.159120902 +0900
@@ -7,2 +7,2 @@
-    4 : 0x510D823ADDB47C0F3AE033F820F5B139A5140D180BB71508B83686DC1873BC08
-    5 : 0xCEE0B028F90CDB9446AB335E2E7DB173908E1668AFDBA7E6C089E3911DE8059F
+    4 : 0x2BE06D1448029581DC41BFBC72F3E5F086F1104C2B16CA5F3F64A8F11FDD199F
+    5 : 0xB9344EFE635B503DB6474937C18839770460B3A4365916D07D13B00FD3D63AD6
@@ -11 +11 @@
-    8 : 0xE0C4DFC3BBDFBD8B79583B9FC5EC83912A2C86977D5FCC110C51C29D6EC846CF
+    8 : 0x3723C256E87505FC1174CCA1F71BC8145D8703D46AE88C88537DF9AF72E2AB0B

次に

  1. セキュアブートを有効に戻す
  2. カーネルを更新

したときの様子です。PCRの計算には現在のハッシュ値も含まれているので、元の値には戻りません。

--- tpm2_pcrread 2022-01-30 03:09:52.159120902 +0900
+++ tpm2_pcrread  2022-01-30 03:32:01.209482235 +0900
@@ -7 +7 @@
-    4 : 0x2BE06D1448029581DC41BFBC72F3E5F086F1104C2B16CA5F3F64A8F11FDD199F
+    4 : 0xAB040291BAFDE1BD13A36FA243E9CECAED92266C29EC1FBAACC99A6AACE51E3E
@@ -10,2 +10,2 @@
-    7 : 0xF6EC92324515892DBA62227C794696324EA38639C60EF94F138B59C0270B0C8A
-    8 : 0x3723C256E87505FC1174CCA1F71BC8145D8703D46AE88C88537DF9AF72E2AB0B
+    7 : 0xE622A16146FB45989BFEAC7FE32B961A933269E78F111B7DB61CE1B37F54BA40
+    8 : 0x9D64BE0CC068CED5CD0ACA32C101B4270B75B61DA74293245E1394C39B62FCA9

TPM2にLUKS2の鍵を入れる

ようやく本題です。LUKS2でボリュームを暗号化している場合、ブート時にパスフレーズの入力が必要です。ところでLUKS2はパスフレーズとは別の鍵をキースロットに複数登録することができ、それを使った複合が可能です。この鍵をTPM2に入れておき、ブート時に参照することでパスフレーズの入力を省略します。

まずはTPMが利用可能かどうかを調べます。調査などに便利なのでtpm2-toolsパッケージもインストールしておきます。

$ journalctl -k --grep=tpm

$ sudo pacman -S tpm2-tools
$ sudo tpm2_getrandom 20
$ sudo tpm2_getcap pcrs
$ sudo tpm2_pcrread sha256

TPM2を一般ユーザーでも利用したい場合はtssグループに参加させると使えます。

$ sudo homectl update --member-of=wheel,input,tss lufia

(いちどログアウトしてから)

$ tpm2_getrandom 20

systemd-cryptenroll

TPM2とLUKSキースロットの管理はsystemd-cryptenrollで行います。同様のことを行うプログラムにclevisもありますが、ここではsystemd-cryptenrollを選びました。この記事では言及しませんが、systemd-cryptenrollはTPM2に限らず、FIDO2を使った複合などにも対応しています。

systemd-cryptenrollの使い方ですが、ここまで進めているならそれほど難しくはなく、決まった順番にコマンドを実行するだけです。まずはTPM2デバイスの名前を調べます。

$ systemd-cryptenroll --tpm2-device=list
PATH        DEVICE     DRIVER
/dev/tpmrm0 IFX0785:00 tpm_tis

ここでデバイスの名前は/dev/tpmrm0と分かりました。上記の出力例では、TPMバイスは1つしかないのでautoとすることで省略可能です。あとはsystemd-cryptenrollコマンドに暗号化したディスクを与えましょう。

$ sudo systemd-cryptenroll /dev/nvme0n1p2 --tpm2-device=auto --tpm2-pcrs=4+7

ブート時にTPM2へアクセスするため、/etc/mkinitcpio.confMODULE変数にTPM2ドライバを追加します。

--- mkinitcpio.conf.orig 2022-01-29 16:55:28.098086104 +0900
+++ mkinitcpio.conf   2022-01-31 01:24:39.829166470 +0900
@@ -4,7 +4,7 @@
 # run.  Advanced users may wish to specify all system modules
 # in this array.  For instance:
 #     MODULES=(piix ide_disk reiserfs)
-MODULES=()
+MODULES=(tpm_tis)
 
 # BINARIES
 # This setting includes any additional binaries a given user may

あとはinitramfs-linux.imgをビルドして終わりです。

$ sudo mkinitcpio -p linux

これで次回以降のパスフレーズ入力が省略できます。

PCRハッシュ値に変化があった場合

上記ではPCR4とPCR7どちらかに変化があったとき鍵を無効にする設定でコマンドを実行しました。そのため、例えばカーネルが更新されたときはセキュアブートによってMOKも更新されることになり、PCRの値も変化して鍵が無効になります。この場合はTPM2から鍵を取り出せなくなるため、パスフレーズの入力が必要です。

パスフレーズでブートした後、以下のコマンドによって再びパスフレーズを省略できます。LUKS2のキースロットは有限ですし、TPM2から古い鍵を取り出す手段は失われているので、--wipe-slot=tpm2オプションを忘れないようにしましょう。

$ sudo systemd-cryptenroll /dev/nvme0n1p2 --wipe-slot=tpm2 --tpm2-device=auto --tpm2-pcrs=4+7

参考リンク

*1:少なくとも他の方法に比べたら...

*2:赤くエラーが表示されるので少し驚くかも

*3:sealと表現される

*4:tpm2_pcrreadコマンドで確認できる

業務端末としてLinuxデスクトップを使うために設定したこと

2021年の11月に、業務端末としてDELL XPS 13を購入して、Linuxデスクトップに移行しました。いまでは快適に使えるようになりましたが、Linuxデスクトップに慣れていないこともあって思ったように動かず困ったところがあったので、導入にあたって悩んだところをまとめました。

ディスクの暗号化

業務利用の要件にディスクの暗号化があるので、bootパーティションを除いて暗号化しました。手順は過去記事に追記しました。

blog.lufia.org

GNOME

KDE Plasmaの方がスタイルは好みですし、実際に業務端末でも2ヶ月ほど使っていましたが、Wayland環境ではタッチパッドの左右スワイプが動かないとか、XWaylandで動作するアプリケーションを4Kディスプレイで表示するとぼやけた表示になるなど厳しいなと思いました*1。個人で使うものなら、少し効率が悪い程度なら問題にしませんが、業務で利用する場合は些細なことに気をとられたくないので、GNOMEを使うことにしました。

$ sudo pacman -S noto-fonts-cjk
$ sudo pacman -S gnome-shell gnome-control-center gnome-terminal gdm nautilus
$ sudo systemctl enable gdm.service

基本的にはデフォルトで使う派ですが、どうしても外せない設定を入れます。これらの意味は過去記事に書きました。

$ gsettings set org.gnome.desktop.input-sources xkb-options "['lv3:ralt_switch', 'ctrl:nocaps']"
$ gsettings set org.gnome.desktop.interface gtk-key-theme Emacs
$ gsettings set org.gnome.desktop.interface gtk-enable-primary-paste false
$ gsettings set org.gnome.desktop.peripherals.touchpad tap-and-drag-lock true

必要なGNOMEアプリケーションを追加します。ここではsushi*2evince*3を追加します。これらはFlathubにもあるので、flatpakで入れてもいいかも。

# pacmanでインストールする場合
$ sudo pacman -S sushi evince eog

# flatpakでインストールする場合
$ flatpak install org.gnome.NautilusPreviewer
$ flatpak install org.gnome.Evince
$ flatpak install org.gnome.eog

Google Meetの画面共有などに必要なのでxdg-desktop-portalも入れます。

$ sudo pacman -S xdg-desktop-portal-gnome

GNOMEの壁紙

/usr/share/backgroundsまたは~/.local/share/backgroundsにファイルを置いておくと設定アプリから変更できるようになります。

GNOMEテーマの変更

GNOMEのデフォルトテーマは、あまり好みではないので変更します。GNOMEにおいて、テーマと名前のつくものは色々あります。

アイコンテーマを変更すると全体的にアイコンが変わります。GTKテーマを変更すると、アプリケーションの外観が変わります。GNOMEシェルテーマは少し分かりづらいですが、トップバーやアクティビティ画面などが該当します。

テーマはGnome Lookなどを探せば本当に色々ありますが、多すぎて逆に好みのものを見つけるのが困難ですね。今のところ、目に入ったなかではFlat-Remixが良いかなと思ったのでこれを設定します。

アイコンテーマの変更

AURにflat-remixがあるので使います。

$ git clone https://aur.archlinux.org/flat-remix.git
$ cd flat-remix
$ makepkg -si
$ gsettings set org.gnome.desktop.interface icon-theme Flat-Remix-Blue-Light

GTKテーマ

AURにflat-remix-gtkがあるので使います。

$ git clone https://aur.archlinux.org/flat-remix-gtk.git
$ cd flat-remix-gtk
$ makepkg -si
$ gsettings set org.gnome.desktop.interface gtk-theme Flat-Remix-GTK-Blue-Light

GNOMEシェルテーマ

シェルテーマを変更するにはUser Themes拡張が必要なので先に用意しておきます。GNOME Extensionsに色々な拡張があるのでそこから追加してもいいのですが、gnome-shell-extensionsパッケージにも含まれているのでそちらを使いました。

# pacmanでインストールする場合
$ sudo pacman -S gnome-shell-extensions

# flatpakでインストールする場合
$ flatpak install org.gnome.Extensions

テーマ自体は、AURにflat-remix-gnomeがあるので使います。

$ git clone https://aur.archlinux.org/flat-remix-gnome.git
$ cd flat-remix-gnome
$ makepkg -si
$ gsettings set org.gnome.shell.extensions.user-theme name Flat-Remix-Blue-Light

補足

上の手順ではAURからテーマをインストールしましたが、ユーザー単位でテーマを管理したい場合は~/.local/share以下にファイルを置くとgsettingsでテーマとして参照できるようになります。

$ mkdir -p ~/.local/share/{themes,icons}
$ mv themes/Flat-Remix-Blue-Light ~/.local/share/themes
$ mv themes/Flat-Remix-GTK-Blue-Light ~/.local/share/themes
$ mv icons/Flat-Remix-Blue-{Dark,Light} ~/.local/share/icons # LightからDarkへsymlinkしているので両方必要

指紋認証

XPS 13は指紋認証が使えるので設定します。ただし、2021年12月時点のfprintdパッケージは1.94.1でしたが、fprintdの1.92.0以上を使うと、

Impossible to enroll: GDBus.Error:net.reactivated.Fprint.Error.NoSuchDevice: No devices available

というエラーで指紋の登録ができません。journalctl -u fprintd.serviceすると、

Failed to clear storage before first enrollment: Device has no storage.

と記録があります。Dell XPS 13(9310) - ArchWikiによると、fprintd-1.92.0-1より新しいバージョンには問題があるらしいので、以下から1.92.0より古いパッケージを落としてくる必要がありました。

$ sudo pacman -U fprintd-1.90.9-1-x86_64.pkg.tar.zst

問題が解決するまではfprintをアップデートされると困るので、/etc/pacman.confで固定します。

--- /etc/pacman.conf.orig    2022-01-08 18:13:06.000000000 +0900
+++ /etc/pacman.conf  2022-01-16 23:03:32.302176992 +0900
@@ -22,7 +22,7 @@
 Architecture = auto
 
 # Pacman won't upgrade packages listed in IgnorePkg and members of IgnoreGroup
-#IgnorePkg   =
+IgnorePkg    = fprintd
 #IgnoreGroup =
 
 #NoUpgrade   =

また、ドライバも必要です。XPS 13では以下の2つが必要です。

  • libfprint-tod-git
  • libfprint-2-tod1-xps9300-bin

どちらもAURからインストールします。

$ git clone https://aur.archlinux.org/libfprint-tod-git.git
$ cd libfprint-tod-git
$ makepkg -si

$ git clone https://aur.archlinux.org/libfprint-2-tod1-xps9300-bin.git
$ cd libfprint-2-tod1-xps9300-bin
$ makepkg -si

指紋で認証する

まず、指紋を登録するユーザーはinputグループに属している必要があるので、所属していない場合はグループに追加します。

$ sudo homectl update --member-of=wheel,input lufia

準備ができたら指紋を登録します。fprintd-enrollを実行するとユーザーのパスワードを聞かれるので、入力して指紋を登録します。ここでsudo fprintd-enrollのようにsudoを使ってしまうと、rootユーザーの指紋として扱われてしまいます。

$ fprintd-enroll

ここで登録した指紋は/var/lib/fprintに保存されます。登録した指紋を削除する場合はfprintd-deleteを使います。

$ fprintd-delete root

あとは必要に応じてPAMの設定を変更すれば終わりです。

  • /etc/pam.d/sudo
  • /etc/pam.d/sddm
  • /etc/pam.d/kde

KDE Plasma Desktopを使っている場合、ログイン時に参照するのは/etc/pam.d/sddmで、画面ロックの解除で参照するのは/etc/pam.d/kdeです。GDMでは特に変更しなくても指紋が登録されていれば有効になります。

上記のArchWikiでは、lsusbコマンドでデバイスを確認していますが、このコマンドはusbutilsパッケージに含まれています。

サウンド

ALSAとPulseAudioは何が違うのかについて、ざっくりとALSAとPulseAudioの関係がとても分かりやすいです。ところで最近はPulseAudioを置き代えるためにPipeWireというものが実装されていて、PipeWire, the media service transforming the Linux multimedia landscapeによると、ALSAやPulseAudio互換モードを持ち、個別のアクセス制御やパフォーマンスなど優位らしいのでPipeWireを使います。

$ sudo pacman -S alsa-firmware sof-firmeware
$ sudo pacman -S pipewire-pulse pipewire-alsa pipewire-jack

実行すると、pulseaudio, pulseaudio-alsa, pulseaudio-bluetoothなどが削除されますが、pipewireが代わりになるので今まで通り動作します。

PulseAudioもPipeWireもどちらもサウンドサーバとしてデバイスとアプリケーションの中間で動作しますが、PipeWireはセッションを開始するためにセッションマネージャーを必要とします。現在、セッションマネージャーはpipewire-media-sessionwireplumberのどちらかを使います。pipewire-media-sessionのドキュメントに

Note that we recommend the use of WirePlumber instead.

とあるのでwireplumberをインストールします。

$ sudo pacman -S wireplumber

ただし、2021年12月時点では、KDE PlasmaのWaylandセッションでWirePlumberを使うと、音がまったく鳴らないし動画も再生されない(一瞬だけ鳴るけどそのまま詰まって動かなくなっているような動きをする)*4問題がありました。この場合はpipewire-media-sessionを使いましょう。

$ pacman -S pipewire-media-session

Flatpak

Flatpakはディストリビューションに依存しないパッケージ管理システムです。主にアプリケーションの配布に使われていて、Flatpakにより管理されたアプリケーションはサンドボックスで動作するなどの特徴があります。

$ sudo pacman -S flatpak
$ flatpak --if-not-exists remote-add flathub https://flathub.org/repo/flathub.flatpakrepo

Flatpakには各種ブラウザ、Visual Studio Code、Slack、Discord、Zoomなど人気のあるアプリケーションはだいたい登録されています。

pacmanflatpakパッケージをインストールすると/etc/profile.d/flatpak.shが作られて、ここに記述されたXDG_DATA_DIRSが次のログイン以降に反映されます。GNOMEKDE PlasmaなどのデスクトップはXDG_DATA_DIRSにあるアプリケーションやテーマを認識するので、Flatpakでインストールしたアプリケーションであっても他のものと同様に起動できるようになっています。または、Flatpakのアプリケーションをコマンドラインから起動する場合は以下のようになります。

$ flatpak run com.example.app

不要になったアプリケーションはflatpak uninstallで削除できますが、アプリケーションが依存していたランタイムはそのまま残ります。どこからも参照されないランタイムも削除する場合は以下のように実行します。

$ flatpak uninstall com.example.app
$ flatpak uninstall --unused

Visual Studio Code

Goのコードを書くときはAcmeを使っていますが、TypeScriptなどはVisual Studio Codeを使っているのでFlatpakでインストールします。ここではflathubを明示していますが、IDが一意に定まるなら省略しても動作します。

$ flatpak install flathub com.visualstudio.code

Visual Studio Live Shareを使う場合はgnome-keyringが必要です。これはFlatpakには無いのでpacmanで入れましょう。

$ sudo pacman -S gnome-keyring

FlatpakアプリケーションをWaylandセッションで動作させる

Waylandセッションで起動したい場合は--socket=waylandオプションが必要です。都度flatpak runに与えるか、flatpak overrideで永続化して使いましょう。flatpak overrideした設定は、~/.local/share/flatpak/overridesまたは/var/lib/flatpak/overridesに書かれます。

$ flatpak run --socket=wayland com.example.app

$ flatpak --user override --socket=wayland
$ flatpak run com.example.app

ElectronアプリケーションをWaylandセッションで動作させる(問題あり)

Electronアプリの場合は、上記に加えてこれらのオプションも必要です。

$ flatpak run --socket=wayland com.example.app --enable-features=UseOzonePlatform --ozone-platform=wayland

これらのオプションは、~/.config/electron-flags.confに書くと参照するらしいのですが手元で試した限りでは反映されなかったので、~/.local/share/applications以下に.desktopファイルを作って、--enable-featuresを含めたコマンドを設定しておくといいでしょう。

また、Electronアプリは、Chromiumの問題によりWaylandセッションでは日本語入力ができません。Waylandで動くタイル型ウィンドウマネージャ・swayを使うによると2021年8月頃から発生していて、2022年1月でもまだ継続しているので、ブラウザやエディタではとても困りますね...

ブラウザ

2022年1月時点では、ChromiumはFlathubに登録されていますが、Google Chromeはありません。ただしFlathubのβチャンネルにChromeがあるので、これを使います。また、Microsoft Edgeもβチャンネルにあるので一緒にインストールしました*5

$ flatpak --user remote-add --if-not-exists flathub-beta https://flathub.org/beta-repo/flathub-beta.flatpakrepo
$ flatpak install com.google.Chrome
$ flatpak install com.microsoft.Edge

flatpak remote-add/var/lib/flatpak以下への書き込み権限を必要としますが、--userオプションを加えるとユーザー個別に設定させることができます。

Flatpakアプリケーションが一定時間で強制終了する

最初にアプリケーションを起動してしばらくすると、

background activity:

という通知が表示されることがあります。ここでAllowを選ぶことができますが、選ばずに閉じるなどをすると、アプリケーションが1分*6で落ちるようになります。アプリケーションがバックグラウンドに遷移したときに動作させたままにするかどうかの確認なんですが、厄介なことに、いちど閉じると再通知されません。

誤って通知を閉じてしまった場合、flatpak permission-resetでリセットできます。また、現在の設定はflatpak permissionsで確認でき、パーミッションを個別にセットする場合はflatpak permission-setです。

# 確認する
$ flatpak permissions

# リセットする
$ flatpak permission-reset com.example.app

# 個別にセットする
$ flatpak permission-set background com.example.app yes

ところで、このパーミッションは、インストールする際に表示されるパーミッションとは異なるものらしいです。アプリケーションが要求するパーミッションはインストール直前に表示されます。または、以下のコマンドで確認できます。

$ flatpak info --show-permissions com.example.app

Flatpakアプリケーションのデバッグ

--commandオプションで、実行するコマンドを切り替えられます。

$ flatpak run --command=sh com.example.app

アプリケーションのコンテナでシェルが動作するので、あとはデバッグ対象のアプリケーションを実行するといいです。

Flatpakとpacmanの使い分け

pacmanflatpakのどちらにも同じアプリケーションが利用可能になっている場合、どちらを使うのか迷いますが、個人で使うものはflatpakで、システム全体で持っている方が都合よいものはpacmanを使うといいのかなと思っています。

*1:Flatpakのところに書いているけどElectronアプリをWaylandで動かすのは厳しい

*2:macOSでのQuick Look

*3:macOSでのプレビュー

*4:https://twitter.com/plan9user/status/1472962934741823492

*5:Firefoxは普段使うブラウザなのでpacmanでインストールしていた

*6:時間は設定によるがデフォルトでは1分

I/O errorでユーザーにログインできなくなった

普段使っているユーザーでLinuxデスクトップにログインできなくなった。正しいパスワードを入力すると画面が1秒程度切り変わるけど、すぐディスプレイマネージャのログイン画面に戻ってしまう状態だった。

エラーの内容を調べる

rootではログインできたので、Ctl+Alt+F4などでディスプレイマネージャからコンソールに切り替えて、ログインできなくなった原因を調べる。問題のユーザーはsystemd-homedでLUKSイメージを使った環境だったので、activateしてみるとI/Oエラーが出力された。

# homectl activate lufia
[  152.810994] loop0: detected capacity change from 0 to 764010613
[  152.812280] blk_update_request: I/O error, dev loop0, sector 764010608 op 0x0:(READ) flags 0x80700 phys_seg 1 prio class 0
[  152.812446] blk_update_request: I/O error, dev loop0, sector 764010608 op 0x0:(READ) flags 0x0 phys_seg 1 prio class 0
[  152.812554] Buffer I/O error on dev loop0, logical block 95501326, async page read
[  152.813969] blk_update_request: I/O error, dev loop0, sector 764010608 op 0x0:(READ) flags 0x0 phys_seg 1 prio class 0
[  152.814210] Buffer I/O error on dev loop0, logical block 95501326, async page read
[  152.815182] blk_update_request: I/O error, dev loop0, sector 764010608 op 0x0:(READ) flags 0x0 phys_seg 1 prio class 0
[  152.815344] Buffer I/O error on dev loop0, logical block 95501326, async page read
[  153.564587] device-mapper: table: 254:0: len=763977845 not aligned to h/w logical block size 4096 of loop0
[  153.564591] device-mapper: core: Cannot calculate initial queue limits
[  153.564716] device-mapper: ioctl: unable to set up device queue for new table.
[  153.568854] blk_update_request: I/O error, dev loop0, sector 764010608 op 0x0:(READ) flags 0x0 phys_seg 1 prio class 0
[  153.569169] Buffer I/O error on dev loop0, logical block 95501326, async page read

ここで

blk_update_request: I/O error, dev loop0, sector xxx

という嫌なエラーがみえる。また、homectl inspecthomectl listなどで現在の状態を調べると、dirtyという状態になっていることがわかる。systemd-homedはdeactivateせず終了したとき、dirty状態になるらしい*1

# homectl list
NAME  UID   GID   STATE REALNAME HOME        SHELL
lufia 60331 60331 dirty lufia    /home/lufia /bin/bash

1 home areas listed.

このフラグは、/home/[user].homeにあるLUKSイメージにhome-dirty=1として属性が付けられている。属性を消せばinactive状態に戻る。

# attr -l /home/lufia.home
Attribute "home-dirty" has a 1 byte value for /home/lufia.home

# attr -r /home/lufia.home home-dirty

systemd-homedのLUKSストレージ構造

systemd-homedはディレクトリやCIFSサーバなどにストレージを切り替えられるようになっていて、LUKS+Btrfsなファイルシステムイメージがデフォルトとして使われる。この場合、以下の要素が使われる。

LUKSストレージでユーザーが作られると、ユーザーのホームディレクトリは/home/[user].homeという名前のイメージファイルで管理されるようになる。イメージファイルはそのままではマウントできないので、device-mapperでイメージをloopデバイスにする。このデバイスは、LUKSで暗号化したうえでBtrfsファイルシステムとしてフォーマットされる。こうして作られたファイルシステムはログインするとsystemd-homedによって/home/[user]へマウントされるので、ユーザーのデータは最終的に[user].homeのイメージファイルへ書かれることになる。

データを取り出す

ローカルには紛失すると困るものはないはずだが、作業中のソースコードなどは救出したいので必要なデータを取り出した。systemd-homed - ArchWikiにレスキューコマンドがあったのでそのまま使う。

# losetup -fP lufia.home
# cryptsetup open /dev/loop0p1 rescue
# mount /dev/mapper/rescue /mnt/

これで/mnt/[user]からファイルを参照できるようになるので、あとはtarなどで必要なファイルをコピーする。それが終わったら新しくユーザーを作って、逃がしておいたファイルを戻せばよい。ここでは、新しいユーザーはLUKSイメージを使わないストレージにした。

# userctl create --storage=directory --uid=60331 --member-of=wheel lufia

暗号化はディスク全体でやればいいし、Btrfsディスクイメージの利点もあまり感じていなかった。

コマンドとsystemd-homedの違い(未解決)

レスキュー用のコマンドではマウントできるのに、systemd-homedではactivateできないところが不思議だったのでjournalctlでログを読む。

# journalctl -u systemd-homed --since=today
Jan 03 10:49:54 plage systemd-homed[359]: lufia: changing state inactive → activating
Jan 03 10:49:54 plage systemd-homework[506]: Provided password unlocks user record.
Jan 03 10:49:54 plage systemd-homework[506]: Successfully locked image file '/home/lufia.home'.
Jan 03 10:49:54 plage systemd-homework[506]: Backing file is fully allocated already.
Jan 03 10:49:54 plage systemd-homework[506]: Setting up loopback device /dev/loop0 completed.
Jan 03 10:49:55 plage systemd-homework[506]: device-mapper: reload ioctl on home-lufia (254:0) failed: Invalid argument
Jan 03 10:49:55 plage systemd-homework[506]: Failed to unlock LUKS superblock: No such device
Jan 03 10:49:55 plage systemd-homed[359]: block device /sys/devices/virtual/block/loop0 has been removed.
Jan 03 10:49:55 plage systemd-homed[359]: Activation failed: No such device
Jan 03 10:49:55 plage systemd-homed[359]: lufia: changing state activating → inactive

ここで、

block device /sys/devices/virtual/block/loop0 has been removed.
Activation failed: No such device

とあるのは、systemd-homedの処理途中でエラーが発生した場合にloopデバイスが残らないように削除しているだけなので問題ではない。なのでおそらく

device-mapper: reload ioctl on home-lufia (254:0) failed: Invalid argument

が、レスキュー用のコマンドとの差異だろう。次の行に出力されたログから、

のどこかでioctlがエラーになっているのだと思うが、簡単には追えそうになかったので諦めた。

参考情報

調べているときに参考になった記事。