Plan 9とGo言語のブログ

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

systemd-homed環境でユーザーにログインできなくなる原因と対処

ここ数年はLinuxデスクトップで生活しているが、たまにユーザー環境へログインできなくなることがあった。

症状

これまで何の問題もなく動いていたが、再起動した後に突然ログインできなくなる。gdm を使っているけれども、パスワード入力のあと何のエラーもなくパスワード再入力画面に戻る。このとき、パスワードを間違えると「認証に失敗しました」のようなエラーになるのでパスワードは合っていると思われる。

root ではログインできるので、ログインした後に systemd-journald のログをみると以下のような行が記録される。

$ journalctl --since=today
systemd-homed[532]: lufia: changing state inactive → activating-for-acquire
systemd-homework[2917]: Provided password unlocks user record.
systemd-homework[2917]: Home directory /home/lufia exists, is not mounted but populated, refusing.
systemd-homed[532]: Activation failed: Device or resource busy

systemd-homed では一般的に /home/$USER は空のディレクトリで、ログインしたとき各種ストレージ方式ごとに必要なファイルをホームディレクトリとしてマウントする。例えば /home/$USER.home (LUKSストレージの場合)や /home/$USER.homedir (ディレクトリ方式の場合)を /home/$USER にマウントすることになる。

なのでログインする時点では /home/$USER は空であるはずが、エラーメッセージには /home/lufia が空ではないのでマウントに失敗したとあり、実際に /home/lufia 以下には空のディレクトリが作られていた。この謎のファイルは消しても一定時間で復活する状態だった。

fsck -f でディスクの検査をしても何もエラーは検出されない。

原因

以下の条件が揃えば同様の症状になる。

  • systemd-homedでユーザーを管理している
  • Dockerが動作している
  • /home/$USER 以下のファイルをボリュームマウントしたdockerコンテナが再起動時に残っている

Dockerはボリュームマウントしたとき、ディレクトリがなければ作成する。Dockerコンテナ実行時は /home/$USER にホームディレクトリがマウントされているので問題なく参照できるが、Linuxを再起動したときはまだユーザーがログインしていないので空のディレクトリになっている。そこでDockerは残っているコンテナを復活させようとして、/home/$USER 以下にファイルを作ってしまう。そうすると、実際にログインしたときは /home/$USER 以下に(Dockerが作成した)ファイルが存在するため、マウントできずエラーになってしまう。

対処方法

この状況になった場合は、root などでログインしたあと以下の手順を実行する。

  • Dockerに残っているボリュームマウントしているコンテナを止める

または

  • docker.service を止める
  • /home/$USER に残っているファイルを消す
  • $USER でログインして docker.service を開始する

感想

Dockerも systemd-homed も、それぞれは意図した動作をしているだけなので、困りますね。

Goの型パラメータを使って型付きバリデータを作っている

型パラメータ(generics)とerrors.Joinを使ってバリデータを作っています。

github.com

経緯

Goで値のバリデーションを行う場合、有名なライブラリには以下の2つがあります。

go-playground/validator はよくある構造体のフィールドに validate:"required" のようにタグを付けるスタイルのバリデータです。awesome-goで紹介されているvalidatorのほとんどはこのスタイルで人気もありますが、個人的には validate:"required" 程度ならともかく validate:"oneof='red green' 'blue'" あたりからは、手軽さよりも複雑さの方が強くて少々厳しいなと感じます。

一方、 go-ozzo/ozz-validation の方はコードで記述するスタイルのバリデータです。公式のサンプルを借用すると、

a := Address{
    Street: "123",
    City:   "Unknown",
    State:  "Virginia",
    Zip:    "12345",
}
err := validation.ValidateStruct(&a,
    validation.Field(&a.Street, validation.Required, validation.Length(5, 50)),
    validation.Field(&a.City, validation.Required, validation.Length(5, 50)),
    validation.Field(&a.State, validation.Required, validation.Match(regexp.MustCompile("^[A-Z]{2}$"))),
    validation.Field(&a.Zip, validation.Required, validation.Match(regexp.MustCompile("^[0-9]{5}$"))),
)
fmt.Println(err)

とても印象は良かったのですが、残念ながら2020年ごろからメンテされなくなっており、IssueやPull requestの状況をみる限りは枯れて安定している様子もないので、今このライブラリに依存するのも怖いなと思います。

go-ozzo/ozzo-validation から派生したリポジトリjellydator/validationというリポジトリがあり、これは今もメンテされているようですが、Go 1.18で入った型パラメータ(generics)とか、1.20で入った errors.Join を使ってみたい気持ちがありました。

そういう理由で、上で挙げた go-validator を自作しています。

go-validatorの使い方

例えば以下のコードでは、createUserRequestValidatorValidate(context.Context, *CreateUserRequest) メソッドを持つバリデータです。

import (
    "context"

    "github.com/lufia/go-validator"
)

var createUserRequestValidator = validator.Struct(func(s validator.StructRule, r *CreateUserRequest) {
    validator.AddField(s, &r.Name, "name", validator.Length[string](5, 20))
    validator.AddField(s, &r.Provider, "provider", validator.In(Google, Apple, GitHub))
    validator.AddField(s, &r.Theme, "theme", validator.In("light", "dark"))
})
err := createUserRequestValidator.Validate(context.Background(), &CreateUserRequest{})
fmt.Println(err)

実装するときに気をつけたことをいくつか挙げます。

型のあるバリデータ

バリデータはanyではなく個別の型を持たせています。これは必ず達成したいと思っていました。Goの型パラメータは関数引数などから推論されるので、上記では validator.Struct に渡している関数や validator.In に渡している値の型などからバリデータの型を決定しています。

同様に、構造体フィールドも型推論によって決定したかったので、validator.AddField にはフィールドのポインタを渡すようにしています。Validateの内部では r の先頭ポインタとのオフセットを使って reflect でフィールドを探しています。

複数のエラーを扱う

複数の項目で違反があった場合は errors.Join で複数のエラーを返すようになっています。個別のエラーをコード側から扱いたい場合は errors.Asinterface { Unwrap() []error } などで掘る必要があって少し面倒ですが、Go 1.20時点で複数のエラーを扱う場合の標準的な方法なのでそれに合わせています。

国際化対応

go-validator では golang.org/x/text/message でエラーメッセージの飜訳をしています。

本当は、ライブラリ側では素朴にエラーを返して、使う側で飜訳してもらう方が好ましいとは思うのですが、バリデータでは

  • 複数のエラーを返す場合がある
  • 構造体やスライスをバリデーションする場合は階層構造になることがある

などがあり、使う側でエラーを飜訳してもらうのも難しそうだったのでライブラリ側で対応しました。言語を切り替えたい場合は context.Context に言語タグを設定しておくと切り替わりますし、ライブラリ側の飜訳では不十分なら独自のカタログを用意する方法もあります。

難しかったところ

メソッドで型パラメータを宣言したい

Go 1.20時点では、メソッドで新しく型パラメータを宣言できません。

// これはできない
func (p *userType) Method[T any](v T)

何度か欲しくなったけれどできないので、別のgenericな型を用意して対応しました。

type Type[T any] struct{...}

// これはできる
func (p *Type[T]) Method(v T)

具体的な型引数を省略できない場合がある

複数の値のうちどれかを選択する validator.In バリデータや、値の範囲が一定以上になっていることを検査する validator.Min バリデータなどは、引数から型推論できるため型引数を省略できますが、値が必須であることの検査をする validator.Required バリデータなどは推論するための型がないので型引数を省略できません。

今の時点では仕方がないので、これは諦めました。今後リリースされるGoのバージョンでは、代入先の型から推論したり、デフォルトの型引数が定義できたりなど拡張されていくようなので期待しています。

lenの引数にできる型の制約を書けない

簡単にできそうだけど意外と困難です*1

len() できる型は

  • string
  • []T
  • map[K]V
  • chan T

などありますが、string には型パラメータが不要だけどスライスには1つ必要、マップはキーと値で2つ必要です。型パラメータを3つ持たせて必要なところだけ使うようにすればできなくはないですが、その実現方法はどうなんだ?とは思います。

基底型がstringまたはStringメソッドを実装している型の制約が書けない

String() string を実装していれば文字列のように扱えると便利じゃないかなと思って、

type stringable interface {
    ~string | String() string
}

としてみたところエラーになりました。

type stringable interface {
    ~string
    String() string
}

これはエラーにはならないけれど、ANDとして扱われるようでうまく実現できませんでした。

参考

Goの型パラメータについてとても詳しくまとまっている情報なのでぜひ読んでください。

*1:できなくはないけど歪なものになる、が正しい

GitHubの複数リポジトリで共通したワークフローを(ある程度)まとめて管理する

TL;DR

  • Reusable workflowにタグを付けて、参照する側のリポジトリはDependabotなどで更新するといいと思う
  • リポジトリごとにDependabotのプルリクエストをマージする手間は必要になる
  • GitHub Actionsのscheduleトリガーでcron式を書けるが、60日以上更新がないリポジトリでは無効になるので使いづらい

背景

個人で複数のリポジトリを管理しているとき、それぞれだいたい同じようなワークフローを管理しているのではないかと思います。例えば私はGoを使って開発することが多いのですが、その場合、リポジトリには以下のようなワークフローを作ります。

jobs:
  test:
    strategy:
      matrix:
        os: ['ubuntu-22.04', 'windows-2022', 'macos-12']
        go: ['1.20.x', '1.19.x']
    runs-on: ${{ matrix.os }}
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-go@v4
      with:
        go-version: ${{ matrix.go }}
    - run: go test -race -covermode=atomic -coverprofile=prof.out
      shell: bash
    - uses: shogo82148/actions-goveralls@v1
      with:
        github-token: ${{ secrets.GITHUB_TOKEN }}
        path-to-profile: prof.out
        parallel: true
        flag-name: ${{ matrix.os }}_go${{ matrix.go }}
  finish:
    needs: test
    runs-on: ubuntu-latest
    steps:
    - uses: shogo82148/actions-goveralls@v1
      with:
        github-token: ${{ secrets.GITHUB_TOKEN }}
        parallel-finished: true

このワークフローは、以下のイベントごとに更新していくことになります。

  • OSに更新があったとき(1〜2年に1回)
  • Goのバージョンに更新があったとき(半年ごと)
  • 各アクションのアップデート(随時)

1つ2つならいいけれど、いくつもあるリポジトリ全てを更新するのは大変ですね。

Reusable workflowにする

GitHubでは、Reusable workflowとしてアクションの一部を別のリポジトリで管理できます。上記のワークフローをReusable workflowに書き換えると、以下のようになります。

name: Test

on:
  workflow_call:
    inputs:
      os-versions:
        description: 'Stringfied JSON object listing GitHub-hosted runnners'
        default: '["ubuntu-22.04", "windows-2022", "macos-12"]'
        required: false
        type: string
      go-versions:
        description: 'Stringfied JSON object listing target Go versions'
        default: '["1.20.x", "1.19.x"]'
        required: false
        type: string

jobs:
  test:
    strategy:
      matrix:
        os: ${{ fromJson(inputs.os-versions) }}
        go: ${{ fromJson(inputs.go-versions) }}
    runs-on: ${{ matrix.os }}
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-go@v4
      with:
        go-version: ${{ matrix.go }}
    - run: go test -race -covermode=atomic -coverprofile=prof.out ./...
      shell: bash
    - uses: shogo82148/actions-goveralls@v1
      with:
        github-token: ${{ github.token }}
        path-to-profile: prof.out
        parallel: true
        flag-name: ${{ matrix.os }}_go${{ matrix.go }}
  finish:
    needs: test
    runs-on: ubuntu-latest
    steps:
    - uses: shogo82148/actions-goveralls@v1
      with:
        github-token: ${{ github.token }}
        parallel-finished: true

inputsでOSやGoのバージョンをJSON文字列で受け取れるように実装していますが、なぜかというと現在の仕様ではリストをワークフローに渡す手段がないからですね。この方法はArray input type support in reusable workflowsのコメントで紹介されていました。

参照する側はこのように。

jobs:
  test:
    uses: lufia/workflows/.github/workflows/go-test.yml@main
    with:
      os-versions: '["ubuntu-22.04"]'  # ワークフローごとに環境を変更したい場合は文字列を渡す

これでOSやGoのバージョンを上げたいときなどはReusable workflowだけを更新すればよくなります。

Reusable workflowを更新したときワークフローを実行したい

ところで、Reusable workflowでOSやGoのバージョンを更新したとき、参照している側のリポジトリでも更新後の環境でテストなどを実行したくなります。ですが今のままではReusable workflowに更新があったことを伝える手段がありません。

GitHub Actionsではscheduleトリガーでcron式を書けるので、これで定期的に実行するよう設定できますが、GitHub側の制約によりscheduleは「60日以上更新のないリポジトリ」では自動的に無効化されます。手動で有効に戻すことはできますがちょっと面倒ですし、必要のない時でもワークフローが動作することになるので、あまりよくないですね。

on:
  schedule:
  - cron: '0 14 10 * *'

本当はトリガーで更新を受け取れるといいのですが、そのようなトリガーイベントはありません。代わりに、Reusable workflowにタグを付けておいて、参照する側のリポジトリはDependabot*1でReusable workflowのバージョンを上げていく方法がいいんじゃないかなと思います。

上記でmainとしていた部分をタグに変更しておくと、Dependabotはプルリクエストを作成します。あとはCIなどを確認したうえでマージすればいいでしょう。

jobs:
  test:
    uses: lufia/workflows/.github/workflows/go-test.yml@v0.1.0

ただし、このタグはReusable workflowを管理するリポジトリ(上記で言えばlufia/workflows)全体のタグです。1つのリポジトリで複数のReusable workflowを扱っている場合は、それぞれのワークフローごとにタグを打ちたくなりますが、個別にタグを打つ方法は分かりませんでした。何か分かったら追記します。

おまけ: go.modのgoディレクティブはどうするか

Go Modulesでは、モジュールが必要とするGoのバージョンをgo.modファイルのgoディレクティブで管理しています。Dependabotはgoディレクティブの更新をしないので、Reusable workflowを更新するプルリクエストをマージしても、このバージョンはずっと変化せず古いバージョンのままです。

// go.mod
module github.com/lufia/backoff

go 1.16

数年前のバージョンが残っていると、外から見たときに「今のバージョンで正しく動くのかな」とは思いますが、とはいえバージョンの不一致で壊れた場合はどこかのCIで気付くでしょうし、Go公式のProposalで検討されているいくつかの仕様*2ではmainモジュールのバージョンではなくモジュールごとに挙動が変わるような仕様で考えられているので、goディレクティブに古いバージョンが残っていても大きな問題にはならないんじゃないかなと思っています。

*1:Renovateでも同様にできるかもしれませんが調べてません

*2:例えばredefining for loop variable semanticsなど

Goでホスト名とポート番号の操作をするときはnetパッケージの関数が使える

標準のnetパッケージには、ホスト名とポート番号を操作する関数がいくつか用意されていますが意外と知られていないようなので、便利だけどあまり知られていない関数を3つ紹介します。

TL;DR

ホスト名とポート番号を:で区切られたアドレスに変換する

Goでコードを書くとき、localhost:8080のようにホスト名とポート番号を:で区切ったひとつの文字列として表現することがあります。この表記はAddressと呼ばれており、例えば以下のような関数で使われています。

package http

func ListenAndServe(addr string, handler Handler) error
package net

func Dial(network, address string) (Conn, error)

type Addr interface {
    String() string  // string form of address (for example, "192.0.2.1:25", "[2001:db8::1]:80")
}

ホスト名とポート番号をアドレスへ変換するとき、fmt.Sprintfを使ったコードをよく見かけますが、ホストとしてIPv6アドレスが与えられた場合に意図しない動作を引き起すことがあります。IPv6アドレスは:で区切って表記するので、単純に結合してしまうとIPv6アドレスとポート番号の区別がつかなくなり不正なアドレスとなります。

var (
    host = "::1"
    port = 80
)
addr := fmt.Sprintf("%s:%d", host, port)
c, err := net.Dial("tcp", addr) // err = "dial tcp: address ::1:80: too many colons in address"

正しくは、IPv6アドレスにポート番号を連結する場合は[::1]:80のようにIPv6アドレスの部分を[]で囲む必要があります。この書式はRFC 4038

Therefore, the IP address parsers that take the port number separated with a colon should distinguish IPv6 addresses somehow. One way is to enclose the address in brackets, as is done with Uniform Resource Locators (URLs) [RFC2732]; for example, http://[2001:db8::1]:80.

と定められています。Goのnetパッケージには、この操作を行うnet.JoinHostPort関数が用意されているので、なるべくこちらを使いましょう。ただし、このときportstringなので型の変換が必要です。

addr := net.JoinHostPort(host, strconv.Itoa(port))
c, err := net.Dial("tcp", addr)

ホスト名とポート番号を分割する

上記とは逆に、:で接続された文字列をホスト名とポート番号に分割したい場合は、こちらもnet.SplitHostPortが用意されているのでそのまま使えます。

host, port, err := net.SplitHostPort("[::1]:80")
if err != nil {
    log.Fatalln(err)
}
fmt.Println(host, port) // ::1 80

ポート番号を文字列から数値にする

これはstrconv.Atoistrconv.ParseUintでもそこまで困らないかなと思いますが、net.LookupPortが便利に使えます。strconv.Atoiとの違いは、

  • 負数の場合、または65535より大きい値の場合はエラーになる
  • redisなどサービス名も対応している

といった特徴があります。

_, err := net.LookupPort("tcp", "-1") // err = "address -1: invalid port"
_, err := net.LookupPort("tcp", "65536") // err = "address 65536: invalid port"

port, err := net.LookupPort("tcp", "redis")
if err != nil {
    log.Fatalln(err)
}
fmt.Println(port) // 6379

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が使いやすいかなと思います。