gcr-ssh-agent で SSH 鍵のパスフレーズを GNOME Keyring に保存

gcr-ssh-agent を ssh-agent として使うと SSH 鍵のパスフレーズを Keyring に保存し、都度の入力を省略できます。鍵ごとのパスフレーズを入力する必要がなくなり、パスフレーズを設定した SSH 鍵を日常的に使いやすくなります。

この記事では WSL の Fedora Linux 44 で動作を確認しています。また、GUI のパスフレーズ入力画面を表示するために WSLg が有効になっている必要があります。

gcr-ssh-agent の有効化

gcr パッケージをインストールし、gcr-ssh-agent.socket ユーザーユニットを有効化します。

sudo dnf install gcr
systemctl --user enable --now gcr-ssh-agent.socket
systemctl --user status gcr-ssh-agent.socket

~/.bash_profile などに以下を追記し、SSH クライアントが gcr-ssh-agent のソケットを参照するよう設定します。

export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/gcr/ssh"

設定を反映するには、いったんログアウトしてからログインし直します。

以下のコマンドなどで、エージェントへアクセスすると自動的に gcr-ssh-agent が起動します。

ssh-add -l

プロセスツリーを見ると gcr-ssh-agent の子プロセスとして本来の ssh-agent が起動しています。gcr-ssh-agent は SSH クライアントと ssh-agent の間に入り、必要な処理を加えてリクエストを中継します。

pstree -a "$(pgrep -xo gcr-ssh-agent)"
# gcr-ssh-agent --base-dir /run/user/1000/gcr
#   ├─ssh-agent -D -a /run/user/1000/gcr/.ssh
#   └─3*[{gcr-ssh-agent}]

パスフレーズの保存

パスフレーズ付きの秘密鍵と、それに対応する .pub ファイルを ~/.ssh に配置します。

~/.ssh/oreore.ed25519
~/.ssh/oreore.ed25519.pub

この鍵を使って ssh コマンドで接続します。

ssh -i ~/.ssh/oreore.ed25519 git@github.com

秘密鍵のパスフレーズが必要になると GUI の入力画面が表示されます。Automatically unlock this key whenever I’m logged in をチェックしてパスフレーズを入力すると、パスフレーズが Keyring に保存されます。次回からは保存されたパスフレーズが使われるため、入力画面は表示されません。

なお、ssh-add で秘密鍵を直接読み込んだ場合は GUI ではなく端末上にパスフレーズの入力画面が表示されます。この入力には gcr-ssh-agent が介入しないため、パスフレーズは Keyring に保存されません。

ssh-add ~/.ssh/oreore.ed25519               # Keyring に保存されない
ssh -i ~/.ssh/oreore.ed25519 git@github.com # Keyring に保存できる

gcr-ssh-agent の仕組み

通常の ssh-agent は起動した時点では秘密鍵を保持していません。ssh-add や ssh が秘密鍵を読み込む際にパスフレーズを問い合わせ、復号した秘密鍵を ssh-agent に送ります(ssh から送るには AddKeysToAgent を有効にする必要があります)。

秘密鍵が追加された後、SSH クライアントと ssh-agent はおおむね次の流れで動作します。

  • SSH クライアントが ssh-agent に利用可能な鍵を問い合わせる
  • ssh-agent が公開鍵の一覧を返す
  • SSH クライアントが利用する公開鍵を指定して署名を要求する
  • ssh-agent が対応する秘密鍵で署名する

パスフレーズを問い合わせるのは ssh-add や ssh であり、エージェントではありません。この流れではパスフレーズがエージェントに渡されないため、エージェントはパスフレーズの入力や保存に介入できません。

一方、gcr-ssh-agent は起動時に ~/.ssh にある *.pub ファイルを読み込みます。この時点で ssh-add -l すると読み込まれた鍵が一覧表示されますが、秘密鍵はまだ復号されておらず、内側の ssh-agent に読み込まれていません。

次に、SSH クライアントからの利用可能な鍵の問い合わせに対しては読み込み済の公開鍵を提示します。その後、署名を要求された鍵が本来の ssh-agent に読み込まれていない場合は、対応する秘密鍵を ssh-add で読み込みます。このとき、Keyring に保存されたパスフレーズを参照し、保存されていなければ GUI で問い合わせます。

よって、この仕組みでパスフレーズを保存するには、次の条件を満たす必要があります。

  • 秘密鍵を ~/.ssh に配置する
  • 秘密鍵のファイル名に .pub を付けた名前で公開鍵を配置する
  • ssh-add ではなく ssh コマンドから鍵を使う

たとえば秘密鍵が ~/.ssh/oreore.ed25519 なら、公開鍵は ~/.ssh/oreore.ed25519.pub に配置します。公開鍵がない場合は、以下のコマンドで秘密鍵から生成できます。

ssh-keygen -y -f ~/.ssh/oreore.ed25519 > ~/.ssh/oreore.ed25519.pub

~/.ssh 以外にある秘密鍵を -i オプションや IdentityFile で指定しても SSH 接続には利用できます。ただし、gcr-ssh-agent の探索対象にはならないため、パスフレーズは Keyring で管理されません。

さいごに

通常の ssh-agent では、プロセスが再起動すると秘密鍵を読み込み直す必要があります。gcr-ssh-agent を使うとパスフレーズを Keyring から取得できるため、長くランダムなパスフレーズを設定しても日常的な入力を省略できます。

なお、パスフレーズの保存と参照には、libsecret を介して Secret Service API が使われます。そのため、保存先は GNOME Keyring に限定されず、Secret Service API に対応した資格情報ストアを利用できます。

WSL で Secret Service API を使う

GitHub CLI(gh)や GitLab CLI(glab)は OS の資格情報ストアが利用できないとトークンを設定ファイルに平文で保存します。これらのトークンは長期有効なものであることが多いので、平文での保存は避けたいです。

Windows には Windows 資格情報マネージャー、macOS には Keychain という OS 標準の資格情報ストアがあります。Linux には GNOME Keyring や KWallet など複数の実装があります。Secret Service API は D-Bus 経由でアプリケーションからこれらの実装を統一的に利用するためのインターフェースです。

通常の Linux デスクトップ環境であればその環境が提供する資格情報ストアを使えば良いでしょう。 WSL には標準のデスクトップ環境は無いため、WSL で利用できる実装をいくつか試しました。

この記事は systemd を有効にした Fedora 44 の WSL 2 環境で検証しています。

動作確認の方法

各実装の動作確認には libsecret に含まれる secret-tool を使います。

sudo dnf install libsecret

secret-tool は Secret Service API を使って資格情報ストアを操作する CLI ツールです。自作の CLI ツールでも sensitive な値を平文で保持せずに資格情報ストアに入れたいときに便利に使えます。

secret-tool store --label=test foo bar
# Password: 保存する値を入力
secret-tool lookup foo bar
# 保存した値が表示される

--label=test は表示上のラベルです。人が見てわかりやすいラベルを指定します。foo bar は属性とその値で、これが Secret の検索用のキーになります。

属性と値は複数セット指定できます。secret-tool lookup は指定されたすべての属性と値がマッチするものが返ります。複数マッチした場合は最後に保存されたものが返ります。

secret-tool store --label=test service abc user ore
# Password: oreore
secret-tool store --label=test service abc user are
# Password: areare
secret-tool lookup service abc
# areare

secret-tool search --all ならマッチしたものすべてが返ります。

secret-tool search --all service abc
# たくさん

GNOME Keyring

GNOME Keyring は GNOME デスクトップ環境で使われている資格情報ストアです。GNOME デスクトップ環境そのものが無くても単体でも利用可能です。

インストールするだけで D-Bus のサービスまで有効になります。

sudo dnf install gnome-keyring

WSLg が有効な状態で secret-tool store を実行すると gnome-keyring-daemon が D-Bus 経由で自動的に起動し、初回は Keyring を初期化するダイアログが表示されます。ここで設定するパスワードが Keyring をアンロックするためのマスターパスワードです。

WSL を再起動するなどして gnome-keyring-daemon のプロセスが新しくなると、Keyring を参照したときにマスターパスワードの入力が求められます。マスターパスワードを入力してアンロックすると、同じ gnome-keyring-daemon プロセスが動いている間は再入力を求められません。

一般的な GNOME デスクトップ環境では、PAM を通じてログイン時に Keyring が自動的にアンロックされます。ログアウトしてセッションが終了すると gnome-keyring-daemon も終了し、Keyring は再びロックされます。つまりデスクトップにログインしている間は Keyring がアンロックされたままになります。

WSL ではデスクトップ環境は無いので、基本的には起動後最初に Keyring にアクセスしたときにマスターパスワードのプロンプトが表示されます。

GNOME Keyring を WSLg なしで使う

なんらかの事情で WSLg を無効にしている場合、gnome-keyring-daemon の標準入力からマスターパスワードを渡して Keyring の初期化やアンロックができます。

printf '%s' 'master-password' | gnome-keyring-daemon --unlock --replace
secret-tool store --label=test foo bar
secret-tool lookup foo bar

ただし、初期化後の2回目以降のアクセスでマスターパスワードが間違っていても gnome-keyring-daemon --unlock は分かりやすいエラーを表示しません。その後の secret-tool が失敗するだけなので、使い勝手はあまりよくありません。GNOME Keyring を使うなら WSLg を有効にした方がよいでしょう。

KeePassXC

KeePassXC の Secret Service Integration を使う方法もあります。次のようにインストールして起動します。

sudo dnf install keepassxc
keepassxc &

KeePassXC でデータベースを作成し、設定画面から Secret Service Integration を有効にします。以降は secret-tool でシークレットにアクセスすると、KeePassXC のマスターパスワードやアクセス許可を求めるダイアログが表示されます。

KeePassXC 自体が GUI アプリケーションなので WSLg は必須です。WSL 上の CLI ツールの資格情報を保存するだけの用途であれば KeePassXC のデータベースとアプリケーションを常時管理するのは少し大げさですが、普段使いのパスワードマネージャーも KeePassXC にまとめてしまうなら有力な選択肢になるでしょうか。

oo7-daemon

oo7 は、Rust で実装された Secret Service API のライブラリとデーモンです。Fedora 45 では GNOME Keyring や KWallet に代わる標準の Secrets Service Provider として採用される予定 です。

検証時点では安定版パッケージがなく、ソースからビルドする必要があったため試していません。Fedora 45 以降では WSL でも最初に検討する候補になりそうです。

pass-secret-service

pass-secret-service は pass をバックエンドにする Secret Service API の実装です。シークレット本体は ~/.password-store/secret-service/ に GPG で暗号化されて保存されます(暗号化はシークレットの値のみ。属性は暗号化されない)。普段から pass を使っている場合は、既存の GPG 鍵とパスワードストアをそのまま利用できます。

GitHub の Releases でバイナリが公開されているので mise なら github バックエンドで簡単にインストールできます。

mise use -g github:grimsteel/pass-secret-service

systemd ユーザーユニットと D-Bus サービスファイルはリポジトリからダウンロードして所定の位置に配置します。 ただし、ExecStart が /usr/bin/pass-secret-service となっているため、mise 経由に書き換える必要があります。

mkdir -p ~/.config/systemd/user ~/.local/share/dbus-1/services
curl -fsSL \
  https://raw.githubusercontent.com/grimsteel/pass-secret-service/refs/heads/main/systemd/pass-secret-service.service \
  | sed "s|/usr/bin/pass-secret-service|%h/.local/bin/mise exec -- pass-secret-service|" \
  > ~/.config/systemd/user/pass-secret-service.service
curl -fsSL \
  https://raw.githubusercontent.com/grimsteel/pass-secret-service/refs/heads/main/systemd/org.freedesktop.secrets.service \
  > ~/.local/share/dbus-1/services/org.freedesktop.secrets.service
systemctl --user daemon-reload

D-Bus サービスファイルの SystemdService から systemd ユーザーユニットが起動されるため、enable や start は不要です。secret-tool などから初めてアクセスしたときに自動的に起動します。

gpg-agent が秘密鍵のパスフレーズをキャッシュしていない場合、pass-secret-service から実行された gpg は gpg-agent 経由で pinentry を表示しようとします。しかし、systemd ユーザーサービスには操作中の TTY が無いためパスフレーズを入力できず、失敗します。

GNOME Keyring のように GUI で表示すればいいかと思い pinentry-qt を試しましたがダメでした。

sudo dnf install pinentry-qt

vim ~/.gnupg/gpg-agent.conf
# pinentry-program /usr/bin/pinentry-qt を追記
gpgconf --kill gpg-agent

secret-tool lookup foo bar
# secret-tool: gpg: public key decryption failed: Inappropriate ioctl for device
# gpg: decryption failed: Inappropriate ioctl for device

pass-secret-service は systemd サービスとして動作するため WAYLAND_DISPLAY などの環境変数を持っていません。そのため pinentry-qt へ変更しただけではダイアログの表示先がありません。

ログインシェルの開始時に WSLg の画面を示す環境変数を systemd ユーザーマネージャーへ渡すことで解決できます。~/.bash_profile に以下を追加します。

dbus-update-activation-environment --systemd WAYLAND_DISPLAY

設定後、既に起動している pass-secret-service を停止します。

systemctl --user stop pass-secret-service

これで環境変数 WAYLAND_DISPLAY が pass-secret-service に渡されるようになるため、secret-tool で資格情報ストアへアクセスしたときに GPG 秘密鍵のパスフレーズを入力するダイアログが表示されます。

GNOME Keyring ではなぜ問題が起きないのか

GNOME Keyring も D-Bus から systemd サービスが起動されるため同じ問題が起きそうですが、dbus-update-activation-environment を実行しなくてもダイアログを表示できました。

GNOME Keyring では以下の経路でダイアログを表示しています。

アプリケーション → gnome-keyring-daemon → gcr-prompter

gcr-prompter が利用する Wayland クライアントライブラリ は、WAYLAND_DISPLAY が未定義の場合に wayland-0 へ接続するようハードコードされています。そのため環境変数が未定義でも大丈夫です。

Secret Service API に GUI 環境を受け渡す特別な仕組みでもあるのかと思ったのですがそういうわけではなく、Wayland のデスクトップ環境を前提とした単純な仕組みでした。

wsl-secret-service

wsl-secret-service は、WSL 側に Secret Service API を提供し、シークレット本体を Windows 資格情報マネージャーへ保存する実装です。Linux 側のデーモンと Windows 側のヘルパーが WSL interop 経由で通信します。WSLg を必要とせず、Windows の資格情報ストアを利用できる点が WSL 向けです。

GitHub Releases から Linux 側と Windows 側のバイナリを取得してインストールします(mise だと Windows 側のヘルパーが入りません)。

gh release download --repo akihiro/wsl-secret-service --pattern wsl-secret-service-linux-amd64
gh release download --repo akihiro/wsl-secret-service --pattern wincred-helper-windows-amd64.exe
install -D -m755 wsl-secret-service-linux-amd64 ~/.local/bin/wsl-secret-service
install -D -m755 wincred-helper-windows-amd64.exe ~/.local/share/wsl-secret-service/wincred-helper.exe
rm -f wsl-secret-service-linux-amd64 wincred-helper-windows-amd64.exe

サービスファイルは、バイナリと同じリリースのタグから取得して配置します。今の最新版は v0.0.6 です。

mkdir -p ~/.config/systemd/user ~/.local/share/dbus-1/services

curl -fsSL https://raw.githubusercontent.com/akihiro/wsl-secret-service/v0.0.6/wsl-secret-service.service \
  -o ~/.config/systemd/user/wsl-secret-service.service
curl -fsSL https://raw.githubusercontent.com/akihiro/wsl-secret-service/v0.0.6/org.freedesktop.secrets.service \
  -o ~/.local/share/dbus-1/services/org.freedesktop.secrets.service

systemctl --user daemon-reload

secret-tool でシークレットを保存して取得します。

secret-tool store --label=test foo bar
secret-tool lookup foo bar
# secret-tool: secret does not contain a textual password

secret-tool lookup で保存したシークレットが何らかの理由でテキストとして扱われず、エラーになりました。Content-Type が維持されていない可能性がありますが詳しい原因は分かっていません。 値そのものは Secret Service API から取得できており、少なくとも gh や glab からは利用できました。

なお、Windows 資格情報マネージャーにはシークレットが UUID を含む名前で保存されていました。

wsl-secret-service.png

ラベルや属性などのメタデータは WSL 側の ~/.config/wsl-secret-service/metadata.json に保存されます。このファイルを削除すると Windows 資格情報マネージャーにシークレット本体が残っていても Secret Service API から参照できなくなります。

gh と glab で使う

いずれかの Secret Service API の準備ができたら gh auth login を実行します。gh は Secret Service API が利用可能なら自動的にそちらにトークンを保存します。

gh auth login
gh auth status

glab は、少し前までは --use-keyring で明示する必要があったのですが今はデフォルトで Keyring が使用されます。

glab auth login
glab auth status

移行後は hosts.yml や config.yml を確認し、トークンが残っていないことも確認します。

さいごに

シークレットを平文の設定ファイルに保存しないという今回の目的は達成できました。ただ、GNOME Keyring はアンロックした後、pass-secret-service は gpg-agent がパスフレーズをキャッシュしている間、Windows 資格情報マネージャーは Windows にログインしている間、同じユーザー権限で動くプロセスから資格情報の読み出しが自由に可能です。そのため、サプライチェーン攻撃などで悪意あるコードが実行された場合のリスクがなくなるわけではありません。それでも、シークレットを平文で保存するよりはだいぶましでしょう。また、資格情報ストアへの対応状況もツールごとに異なります。gh のように自動的に利用するものもあれば、かつての glab のようにオプションの指定が必要なもの、そもそも対応していないものもあります。このあたりが統一されていないのは悩ましいところです。

今回試した中では GNOME Keyring はインストールするだけで D-Bus や systemd のサービス設定ファイルも配置されるため簡単です。Linux デスクトップで広く使われており、パスワード入力ダイアログも自然に扱えます。

次点は pass-secret-service でしょうか。GNOME Keyring と比べると野良感はありますが、普段から aws-vault のバックエンドに pass を使っているなら GPG で一元管理できて良さそうです。反対に aws-vault のバックエンドを Secret Service API に変更し、GNOME Keyring にまとめてしまう方法もありますが。

wsl-secret-service はさらに野良感が高いですが、Windows 資格情報マネージャーを利用できる点は便利そうです。WSL 側のメタデータもセットで管理する必要があるのは注意点でしょうか。

KeePassXC は今回試した中ではやや大げさな気もしました。前述の通りパスワードマネージャーとしても使うならありかもしれません。

WSL で ssh-agent を便利に使うための Tips

SSH の秘密鍵をパスフレーズなしの平文で保存するのはセキュリティの面で不安があります。 一方、秘密鍵にパスフレーズを設定すると、SSH で接続するたびに入力する必要があり面倒です。

ssh-agent を使用するとパスフレーズで保護された秘密鍵をメモリ上に保持し、繰り返し入力する手間を省けます。

この記事では、WSL で ssh-agent を使いやすくするための設定や、関連する Tips を紹介します。 なお、WSL 上の Fedora を前提としているため、他のディストリビューションではパッケージ名や systemd のユニットファイルが異なる場合があります。

systemd で ssh-agent を起動する

現在の WSL は systemd に対応しているため、ssh-agent は systemd のユーザーサービスとして起動するのが簡単です。

openssh-clients パッケージには ssh-agent のユーザーサービス用のユニットファイルが含まれています。ssh-agent.socket を有効化すると、最初にソケットへアクセスしたときに ssh-agent が自動的に起動します。

systemctl --user enable --now ssh-agent.socket

シェルから ssh-agent のソケットを参照できるように ~/.bash_profile へ以下を追記します。

export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/ssh-agent.socket"

環境によっては XDG_RUNTIME_DIR が定義されていないことがあります。その場合は次のようにします。

export SSH_AUTH_SOCK="/run/user/$(id -u)/ssh-agent.socket"

新しいシェルを起動し、以下のコマンドで ssh-agent の状態を確認します。

ssh-add -l

引数なしで ssh-add を実行すると ~/.ssh/id_rsa や ~/.ssh/id_ed25519 などのデフォルトの鍵が ssh-agent に追加されます。秘密鍵がパスフレーズで保護されている場合はここでパスフレーズを入力します。それ以降は ssh-agent が動作している間、パスフレーズの入力を省略できます。

ssh-add

Keychain で ssh-agent を起動する

systemd が利用できない環境では Keychain で ssh-agent を管理できます。Keychain は ssh-agent を最初に 1 つだけ起動し、複数のセッションで共有するためのツールです(macOS のキーチェーンとは関係ありません)。

# ~/.bash_profile
eval "$(keychain --eval --quiet --quick)"

上記を ~/.bash_profile などに追記すると、ssh-agent がまだ実行されていない場合は新しく起動します。すでに実行中の場合は既存の ssh-agent を使用するように環境変数が設定されます。

IdentityFile と AddKeysToAgent

接続先ごとに異なる秘密鍵を使う場合、SSH の実行時に秘密鍵を指定するのは手間がかかります。~/.ssh/config の IdentityFile と AddKeysToAgent を設定すると、秘密鍵の選択と ssh-agent への追加を自動化できます。

IdentityFile には接続先で使用する秘密鍵を指定します。AddKeysToAgent は秘密鍵を使用したときに自動的に ssh-agent へ追加する設定です。AddKeysToAgent には、ssh-agent に鍵を保持する時間も指定できます。

AddKeysToAgent は Host * でグローバルに有効にしてもよいでしょう。明示的に ssh-add を実行する必要がなくなります。

以下の例ではデフォルトの保持時間を 8 時間とし、運用系サーバで使用する鍵だけ 1 時間に設定しています。

Host honban-no-server
    IdentityFile ~/.ssh/honban-no-server.rsa
    AddKeysToAgent 1h

Host *
    AddKeysToAgent 8h

OpenSSH は各設定項目について最初に見つかった値を採用します。接続先ごとに保持時間を変更する場合は接続先固有の設定を先に書き、Host * によるグローバル設定をファイルの最後に書く必要があります。

gpg-agent を採用しなかった理由

gpg-agent は OpenSSH のエージェントプロトコルに対応しているため、ssh-agent の代わりに使用できます。既存の SSH 鍵を gpg-agent にインポートする方法と、gpg のサブキーを SSH 鍵として使用する方法があります。ただ、どちらもわたしには合わなかったので利用していません(Not for me)。

既存の SSH 鍵をインポートする場合、gpg 鍵とは別のパスフレーズで鍵を保護する必要があります。また、このパスフレーズは SSH 鍵ごとに設定する必要があります。gpg-agent で管理するなら gpg 鍵のパスフレーズ一本で完結させられるかと思ったのですが、そうはできませんでした。SSH 鍵ごとにパスフレーズを持つのなら ssh-agent で十分な気もします。

また、この方法では鍵を ssh-add で追加できますが、同じコマンドでは削除できません。インポートされた SSH 鍵を直接削除する必要があるようです。保存された鍵は gpg 鍵と同じディレクトリにランダムな ID で作成されるため、誤って gpg 鍵を削除すると目も当てられません。

gpg のサブキーを SSH 鍵として使用する場合は、gpg 鍵のパスフレーズで管理できます。ただし、既存の SSH 鍵をサブキーへ変換する方法はないため、新しい鍵として作成し、接続先へ公開鍵を登録し直す必要があります。すでに複数の SSH 鍵を使用しており、一部はプロジェクト内で共有しているため、この方法への移行は困難です。

新しい個人用の鍵には gpg のサブキーを使い、既存の鍵には gpg-agent へ追加した SSH 鍵を使う構成も可能です。しかし、鍵の管理を gpg に一本化できないのであれば構成を複雑にしてまで移行するメリットはないと判断し、見送りました。

Name タグを指定して Session Manager 経由で SSH 接続する

AWS SSM Session Manager 経由で SSH 接続する場合、通常は接続先に EC2 インスタンス ID を指定しますが、ProxyCommand 内で Name タグからインスタンス ID を検索すると、覚えやすい名前で接続できます。

この設定では、同じ AWS アカウントおよびリージョン内で稼働中の EC2 インスタンスの Name タグが一意であることを前提としています。また、Session Manager を利用するための設定と、ローカル環境への Session Manager plugin のインストールは完了しているものとします。

~/.ssh/config に以下を設定します。ProxyCommand は 1 行で記述する必要があります。

Host ec2/*
    ProxyCommand sh -c 'exec aws ssm start-session --target "$(aws ec2 describe-instances --filters Name=tag:Name,Values="${1#ec2/}" Name=instance-state-name,Values=running --query Reservations[].Instances[].InstanceId --output text)" --document-name AWS-StartSSHSession --parameters portNumber="$2"' -- '%h' '%p'

aws-vault で認証情報を取得し、Name タグの値を指定して接続します。

aws-vault exec {profile} -- ssh {user}@ec2/{tag:Name}

エージェントフォワーディングに注意

SSH のエージェントフォワーディングを有効にすると、ローカルの ssh-agent を SSH 接続先のリモートホストでも利用できます。

# -A オプションでエージェントフォワーディングを有効にする
ssh -A remote-host

SSH 接続先のリモートホストで Git の clone や fetch を実行したいときなど、秘密鍵を接続先へ配置せずに認証できるため便利です。

ただし、接続先のリモートホストで動作するプロセスからも、転送された ssh-agent を利用できる可能性があります。同じユーザーもしくは root からは利用可能と思ってよいでしょう。秘密鍵そのものを読み出されるわけではありませんが、接続中にエージェントへ署名を要求され、意図しないサーバへの認証に悪用されるおそれがあります。特に、管理者権限を持つユーザーを信頼できないサーバでは注意が必要です。

# エージェントフォワーディングを有効にして接続すると
ssh -A remote-host
# リモートホスト側でも SSH_AUTH_SOCK が設定される
echo $SSH_AUTH_SOCK
/tmp/ssh-XXXXfa4Eez/agent.2541293

# root が同じソケットを SSH_AUTH_SOCK に設定すれば利用可能
SSH_AUTH_SOCK=/tmp/ssh-XXXXfa4Eez/agent.2541293 ssh other-host

~/.ssh/config の ForwardAgent yes でも有効にできますが、すべての接続先で有効にするようなことはせず、必要な接続先だけ有効にするか、もしくは基本的に無効で必要なときだけ -A オプションで有効にするとよいでしょう。また、踏み台サーバを経由したいだけであればエージェントフォワーディングで多段するのではなく、ProxyCommand や ProxyJump の方がよいでしょう。

gcr-ssh-agent

最近知ったのですが、GNOME デスクトップ環境で使用される gcr パッケージの gcr-ssh-agent も、SSH エージェントとして利用できます。 以前は GNOME Keyring に組み込まれていた SSH エージェントが、別のパッケージとして切り出されたものです。

sudo dnf install gcr
systemctl --user enable --now gcr-ssh-agent.socket

シェルから gcr-ssh-agent のソケットを参照できるように、~/.bash_profile へ以下を追記します。

ソケットのパスは本来 $XDG_RUNTIME_DIR/gcr/ssh ですが、ここでも id -u で UID を取得してパスを組み立てます。

export SSH_AUTH_SOCK="/run/user/$(id -u)/gcr/ssh"

gcr-ssh-agent を Secret Service API に対応した Keyring と組み合わせると、SSH 鍵のパスフレーズを Keyring に保存できます。WSL の再起動後は、Keyring をアンロックするだけで SSH 鍵を使用できます。ただし、GNOME Keyring などをあらかじめ利用可能にしておく必要があります。Keyring の設定はこの記事の範囲外となるため、ここでは割愛します。

さいごに

改めて記事を見返すと、WSL 特有の内容はないかもしれません。現在の WSL では、ssh-agent を systemd のユーザーサービスとして起動すると複数のセッションで簡単に共有できます。

IdentityFile と AddKeysToAgent を組み合わせれば、接続先に応じた秘密鍵の選択と ssh-agent への追加も自動化できます。

systemd が利用できない環境でも、Keychain を使えば同等の構成にできます。以前、WSL が systemd に対応する前は Keychain を使用していました。今は systemd のユーザーサービスを利用するほうが簡単です。

これで「毎回の入力が面倒だから」と秘密鍵を平文のままにしておく理由もなくなりました。秘密鍵はパスフレーズで保護しましょう。

AWS CodeBuild で GitLab Runner の Docker executor を実行する

AWS CodeBuild は 2025年2月26日のアップデート から、GitLab Self Managed の CI/CD ジョブを実行するマネージドランナーとして利用できます。

ただし、標準の連携で実行される GitLab Runner は Shell executor です。そのため、Docker executor を前提とした既存の CI/CD ジョブをそのまま移行できません。

そこで、GitLab の Webhook を起点に CodeBuild を開始し、CodeBuild 上で GitLab Runner の Docker executor を実行する構成を自前で構築しました。

全体の構成

この記事で使用する最小構成の Terraform コードは下記に置いています。

ジョブが実行されるまでの経路は次のとおりです。

flowchart LR
    gitlab[GitLab] -->|Job Hook| functionUrl[Lambda Function URL]
    functionUrl --> lambda[AWS Lambda]
    lambda --> codebuild[AWS CodeBuild]
    codebuild --> runner[GitLab Runner]
    runner --> executor[Docker executor]

処理の流れは次のようになります。

  1. GitLab CI/CD のジョブが pending になる
  2. GitLab の Job Hook が Lambda Function URL を呼び出す
  3. Lambda が GitLab API からジョブのタグを取得する
  4. 対象の Runner で実行するジョブなら、Lambda が CodeBuild を開始する
  5. CodeBuild が GitLab Runner を起動する
  6. GitLab Runner が pending のジョブを取得し、Docker executor で実行する
  7. ジョブが終了したら CodeBuild も終了する

常駐する GitLab Runner は無く、ジョブが pending になったときだけ CodeBuild が起動します。

サンプルコードでは Terraform から GitLab API を操作するため、GitLab の API スコープを持つアクセストークンを環境変数に設定する必要があります。

export GITLAB_TOKEN=abc123...

glab auth login でアクセストークンを Keyring に保存している場合、secret-tool などでそのトークンを取得してもいいでしょう。glab:gitlab.example.com の部分は、対象の GitLab のホスト名に置き換えます。

export GITLAB_TOKEN="$(secret-tool lookup service glab:gitlab.example.com)"

やや紛らわしいですが、ここで設定する GITLAB_TOKEN は後述する Lambda が GitLab REST API を呼び出すためのアクセストークンとは別物です。Lambda が使用するアクセストークンは Terraform で作成してパラメータストアに保存しています。

また、この例では GitLab Runner は Project Runner として登録していますが、GitLab の管理者権限があるなら Instance Runner としての登録も可能です。

GitLab の Job Hook を作成する

gitlab_project_hook でプロジェクトに Job Hook を作成します。

resource "gitlab_project_hook" "main" {
  name                    = "aws:${data.aws_region.main.region}:${data.aws_caller_identity.main.account_id}:${var.name}"
  project                 = data.gitlab_project.main.id
  url                     = aws_lambda_function_url.main.function_url
  enable_ssl_verification = true
  push_events             = false
  job_events              = true
  token                   = random_password.token.result
}

Webhook の送信先には Lambda Function URL を指定します。Function URL 自体の認証方式は NONE とし、GitLab が送信する X-Gitlab-Token ヘッダーを Lambda で検証します。X-Gitlab-Token には上記で設定した token の値が渡されます。これは十分に長いランダム値であれば任意の値で大丈夫です。

Lambda から CodeBuild を開始する

Job Hook はジョブの状態が変わるたびに送信されます。Lambda では build_status が pending のイベントだけを処理します。

if (body.build_status !== "pending") {
    return false;
}

次に X-Gitlab-Token ヘッダーの値を検証します。これは GitLab Webhook で設定したトークンの値です。例ではランダム生成した値を GitLab Webhook のトークンと、SSM パラメータストアに保存しています。Lambda で X-Gitlab-Token ヘッダーの値とパラメータストアから取り出した値を比較します。

if (event.headers["x-gitlab-token"] !== SECRET_TOKEN) {
    return false;
}

次に、Webhook のペイロードからプロジェクトやビルドの ID を取得し、GitLab の REST API でそのジョブが必要としているタグを取得します。これは .gitlab-ci.yml の tags の値です。

const url = `${GITLAB_URL}/api/v4/projects/${body.project_id}/jobs/${body.build_id}`;
const res = await fetch(url, { headers: { "PRIVATE-TOKEN": GITLAB_TOKEN }});
const data = await res.json();
const tags = data.tag_list;

取得したタグが 1 つ以上あり、すべて Runner 自身のタグに含まれているときだけ StartBuildCommand で CodeBuild を開始します。

if (!(Array.isArray(tags) && tags.length > 0 && tags.every(v => RUNNER_TAGS.includes(v)))) {
    return false;
}

await codebuild.send(new StartBuildCommand({ projectName: CODEBUILD_PROJECT }));

この判定がないと、別の Runner で実行するジョブや、CodeBuild で実行できないジョブでも CodeBuild が起動してしまいます。

GitLab の REST API の呼び出しには Terraform で作成してパラメータストアに保存した Project Access Token を使用します。サンプルではトークンの有効期限を 365 日、ローテーションを有効期限の 180 日前に設定しています。トークンを更新するため、定期的に terraform apply を実行する必要があります。

Project Access Token はこのためだけに使用しています。Webhook のペイロードにタグの値が含まれていれば省略できるのですが、残念ながら含まれていません(GitLab issue #467657)。

GitLab 19.1 以降では Signing token を利用できる

このサンプルでは固定のシークレットを X-Gitlab-Token ヘッダーで送信し、Lambda で同じ値かどうかを比較しています。この方法は実装が単純ですが、リクエストごとに同じ値が送信されるため、ペイロードの改ざんやリクエストの再送までは検出できません。

Signing token は GitLab 19.0 で Feature flag 付きで導入され、GitLab 19.1 で GA になりました。Signing token を設定すると HMAC-SHA256 署名が webhook-signature ヘッダーで付与されるため、より安全な方法での検証が可能です。

GitLab Runner を事前に登録する

GitLab Runner は gitlab_user_runner でプロジェクト Runner として登録します。

resource "gitlab_user_runner" "main" {
  project_id      = data.gitlab_project.main.id
  runner_type     = "project_type"
  locked          = true
  untagged        = false
  tag_list        = var.runner_tags
  maximum_timeout = 600
}

Runner の認証トークンは SSM パラメータストアに保存します。

resource "aws_ssm_parameter" "runner_token" {
  name  = "/${var.name}/runner-token"
  type  = "SecureString"
  value = gitlab_user_runner.main.token
}

CodeBuild の環境変数でこれを使い gitlab-runner を実行します。

なお、AWS が提供する CodeBuild の GitLab Runner 連携では、ビルドごとに Runner を登録し、終了時に削除しているようでした。今回の構成では実装を単純にするため、あらかじめ登録した Runner を使い回しています。今のところそれで特に問題は起こっていません。強いて言えば登録されている Runner のステータスがジョブの実行中以外は Offline と表示されますが、動作上の問題はなさそうです。

CodeBuild で Docker executor を実行する

CodeBuild のビルド環境では Docker Engine を利用するため、privileged_mode を有効にする必要があります。

environment {
  type            = "LINUX_CONTAINER"
  image           = "aws/codebuild/standard:8.0"
  compute_type    = "BUILD_GENERAL1_SMALL"
  privileged_mode = true
}

Buildspec では GitLab Runner のコンテナイメージを使って run-single コマンドを実行します。

version: 0.2
env:
  shell: bash
phases:
  build:
    commands:
      - docker run --rm --volume /var/run/docker.sock:/var/run/docker.sock
          public.ecr.aws/gitlab/gitlab-runner:alpine
          run-single
          --url "$GITLAB_URL"
          --token "$RUNNER_TOKEN"
          --max-builds 1
          --wait-timeout 60
          --executor docker
          --docker-privileged
          --docker-helper-image public.ecr.aws/gitlab/gitlab-runner-helper:alpine-latest-x86_64-latest
          --docker-image public.ecr.aws/docker/library/alpine:latest
          --docker-allowed-pull-policies always
          --docker-allowed-pull-policies never
          --docker-allowed-pull-policies if-not-present
          --cache-type s3
          --cache-s3-bucket-name "$CACHE_BUCKET"
          --cache-s3-bucket-location "$AWS_REGION"

Docker Engine を操作できるように CodeBuild 環境の /var/run/docker.sock をマウントします。

--max-builds 1 を指定しているため、Runner はジョブを 1 件実行すると終了します。--wait-timeout 60 は Runner を開始してからジョブを取得するまでの時間です。Runner の起動時点でジョブはすでに pending なので通常はすぐに取得できます。あまり大きな値にする必要はないでしょう。

サンプルでは .gitlab-ci.yml の cache で指定したファイルをビルド間で共有するため、S3 バケットを Runner の分散キャッシュに設定しています。不要なら省略できます。

.gitlab-ci.yml から利用する

Runner の tag_list と同じタグをジョブに指定します。例えば Runner のタグを codebuild-docker とした場合は次のようになります。

test:
  image: node:24
  services:
    - name: redis:7
      alias: redis
  tags:
    - codebuild-docker
  script:
    - npm ci
    - npm test

ジョブが pending になると CodeBuild が開始され、Docker executor が node:24 と redis:7 のコンテナを起動してジョブを実行します。

さいごに

常駐する Runner を用意しなくても CodeBuild で GitLab Runner の Docker executor をオンデマンドで実行し、既存の .gitlab-ci.yml にある image や services などのフル機能を活かしたまま GitLab CI を AWS に移行できるようになりました。

ただ、CodeBuild は同等のスペックの EC2 と比べて時間当たりの料金が高いです。Runner の稼働率によっては EC2 上で GitLab Runner を常駐させた方が安価になる可能性があります。EC2 インスタンスなら夜間や土日にスケジュールで指定してコスト削減も可能です。どちらが良いかは実際の運用にあわせて決定する必要があるでしょう。

ここ数年で追加された CloudWatch Logs の新機能まとめ

ここ数年は CloudWatch Logs でログを見る機会が少なく、基本的に Datadog や Splunk に送られたログを見ていたため、新機能をキャッチアップできていませんでした。そこで、ここ数年で CloudWatch Logs に追加された主な機能をまとめました。

記載内容は 2026 年 7 月時点のものです。

2023/11 低頻度アクセスログクラス

https://aws.amazon.com/about-aws/whats-new/2023/11/amazon-cloudwatch-logs-infrequent-access-log-class/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/CloudWatch_Logs_Log_Classes.html

CloudWatch Logs で低頻度アクセスログクラスが利用可能になりました。S3 などでもおなじみの Infrequent Access です。IA と略されます。 機能的に制限がある代わりに、取り込みのコストが小さく抑えられています。「低頻度アクセス」という名称ですが、保存されているログのストレージコストに変わりはなく、安くなるのは取り込み費用のみです。

ドキュメントを見る限り、通常の Logs Insights クエリでは大きな違いはなさそうです。ただし、フィールドインデックスとファセットを作成できず、Live Tail も利用できない点は大きな制約です。また、メトリクスフィルターやサブスクリプションフィルターなども利用できません。

多くのケースでは、低頻度アクセスログクラスで十分に思えます。そのため、デフォルトを低頻度アクセスにするオプションがあってもよさそうです。

なお、ロググループの作成後にログクラスは変更できないため、作成時に指定する必要があります。AWS サービスがロググループを自動作成する場合は Standard になるため、IA にするには、あらかじめ同名のロググループを作成しておく必要があります。

2023/12 パターン分析・比較

https://aws.amazon.com/jp/blogs/news/amazon-cloudwatch-logs-now-offers-automated-pattern-analytics-and-anomaly-detection/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/LogsAnomalyDetection-Insights.html

Logs Insights で検索したログについて、例えば @message 内の可変値と推定される部分を抽象化したパターンを分析できます。パターンの統計を表示したり、前日・前週・前月などと比較したりできます。 ログ分析画面では、クエリの右下にある「パターンを分析する」から実行できます。実行後に「時間の経過に伴うパターンの比較」を選択すると、過去の期間と比較できます。クエリで直接指定することもでき、その方が簡単です。

# パターン分析
fields @timestamp, @message
| pattern @message

# 前週と比較
fields @timestamp, @message
| pattern @message
| diff previousWeek

さらに、パターン分析の結果に対して anomaly コマンドでログの異常検出が可能です。

# 異常検出
fields @timestamp, @message
| pattern @message
| anomaly

これは機械学習により、未知のエラー、既知パターンの可変値の急変、既知パターンの出現頻度の急変などを基に判定しているそうです。

2023/12 ログ異常検出

https://aws.amazon.com/jp/blogs/news/amazon-cloudwatch-logs-now-offers-automated-pattern-analytics-and-anomaly-detection/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/LogsAnomalyDetection.html

前述の anomaly コマンドとは別に、あらかじめ異常検出をロググループに設定しておくことで、取り込まれたログを自動的にスキャンして異常を検出し、その結果を保存できます。

検出方法は前述の anomaly と同じです。検出対象は、未知のエラー、既知パターンの可変値の急変、出現頻度の急変などです。一部のプロジェクトで設定されているような、ERROR レベルのログをすべて通知する用途では取りこぼす可能性があるため、その代替としては適していません。

後述する Log Based Alarm を利用すると、ERROR レベルのログを通知できます。あるプロジェクトで両方を有効にしたところ、ERROR レベルを条件に通知する用途では Log Based Alarm で十分でした。一方、ログが構造化されておらず、エラーに該当するログを条件で抽出しにくい場合は、ログ異常検出も有用です。

2024/11 フィールドインデックスとファセット

https://aws.amazon.com/jp/about-aws/whats-new/2024/11/amazon-cloudwatch-logs-field-indexes-log-group-selection-log-insights/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/CloudWatchLogs-Field-Indexing.html https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/CloudWatchLogs-Facets.html

ログのフィールドにインデックスを付けて、ログを効率的に絞り込めるようになりました。

標準ログクラスのすべてのロググループではデフォルトでいくつかのフィールドインデックスが作成されます。また、特定のデータソースの特定のタイプのログには追加でいくつかのフィールドインデックスが追加されるものもあります。 デフォルト以外のフィールドインデックスはインデックスポリシーを作成することで追加できます。ポリシーの作成後に取り込まれたログだけがインデックス化されます。

インデックスポリシーはアカウントレベルとロググループレベルで設定できます。

アカウントレベルのポリシーは、アカウント内のすべてのロググループを対象にできるほか、ロググループ名のプレフィックスを指定して対象を絞ることもできます。 また、特定のデータソースとタイプも指定できます。例えば、amazon_rds.aurora_postgresql のように指定します。

ロググループレベルのポリシーは、単一のロググループに適用されます。アカウントレベルのポリシーと競合した場合はロググループレベルが優先され、両方の設定の和集合にはなりません。

filter でインデックス化されたフィールドを等価条件(field = value または field IN [...])に使用すると、インデックスが自動的に使用されます。

fields @timestamp, @message
| filter level in ["NOTICE"]
| limit 20

filterIndex を使用すると、指定したフィールドのインデックスがあるロググループだけを検索対象にできます。filter では、インデックスがないロググループが検索対象に含まれている場合、そのロググループでは指定した時間範囲内のすべてのログイベントが走査されます。一方、filterIndex では、インデックスがないロググループは走査されません。

fields @timestamp, @message
| filterIndex level in ["NOTICE"]
| limit 20

検索対象のロググループで、条件に使用するフィールドがインデックス化されていることが分かっている場合は、基本的に filterIndex を使う方がよいでしょう。

また、インデックスポリシーの作成時に、フィールドをファセットとして指定できます。ファセットに指定されたフィールドは自動的に集計され、ログ分析ではフィルター候補としてドロップダウンリストから選択できます。 基本的にカーディナリティの低いフィールドがファセットに適しています(level や status など)。逆に、traceId や userId のようなカーディナリティの高いフィールドは、インデックス化の効果が大きいものの、ファセットには適しません。

2024/11 トランスフォーマー

https://aws.amazon.com/jp/about-aws/whats-new/2024/11/amazon-cloudwatch-logs-transform-enrich/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/CloudWatch-Logs-Transformation.html

ログの取り込み時に、CSV、JSON、Grok Parser などで内容をパースし、フィールドを抽出・変換できるようになりました。 Datadog で言うところの Pipeline に相当します。最初から JSON 形式でログが出力されていれば不要ですが、そうでない場合は CloudWatch 側でログを変換できます。

トランスフォーマーはロググループレベルだけではなく、アカウントレベルでも設定できます。アカウントレベルは全ロググループに適用するだけではなく、ロググループ名のプレフィックス指定でも適用できます。例えばプレフィックス /aws/lambda/ のロググループすべてに適用、といったことが可能です。

ログのパースに失敗した場合は TransformationErrors メトリクスに記録されるほか、ログイベントに @transformationError が付与されるため、Logs Insights で filter ispresent(@transformationError) を使用して検索できます。 試しに Aurora MySQL の audit ログを CSV としてパースさせてみたところ、シングルクォートを含む SQL で CsvProcessor.ParsingException というエラーになりました。audit ログのクォート文字がシングルクォートなので、quoteCharacter に ' を指定していたのですが、\' のようなクォート文字のエスケープに対応していないためのようです。'' なら大丈夫そうなので、通常の CSV なら問題なさそうです。Aurora MySQL の audit ログをパースする場合は、Grok Parser を利用する必要がありそうです。

2024/12 OpenSearch PPL、および OpenSearch SQL

https://aws.amazon.com/jp/blogs/aws/new-amazon-cloudwatch-and-amazon-opensearch-service-launch-an-integrated-analytics-experience/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/CWL_AnalyzeLogData_Languages.html

いわゆる Logs Insights のクエリ言語(CWLI: CloudWatch Logs Insights QL)以外に、OpenSearch PPL と OpenSearch SQL というクエリ言語が利用可能になりました。

PPL は CWLI に似たパイプライン形式の言語で、SQL はいわゆる SQL です。これらは OpenSearch 統合などとは無関係に利用できますが、新しいログ分析の UI ではクエリ言語を切り替える項目を確認できませんでした。 公式ドキュメントには引き続き PPL と SQL が掲載されており、クエリ履歴には「クエリ言語」という項目があります。AWS CLI の aws logs start-query にも --query-language オプションが残っているため、機能自体は廃止されていません。マネジメントコンソールからの実行方法は不明です。

CWLI 以外は全然使っていなかったので特に困りはしないのですが、謎です。

2025/11 スケジュールされたクエリ

https://aws.amazon.com/jp/about-aws/whats-new/2025/11/amazon-cloudwatch-scheduled-queries/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/ScheduledQueries.html

CloudWatch Logs Insights のクエリを定期実行し、その結果を S3 や EventBridge に渡す機能です。 同じことをやろうとすると EventBridge Scheduler → Lambda であれやこれやする必要がありましたが、それを CloudWatch Logs で完結させることができます。

ログベースのアラートにも利用できますが、後述する Log Based Alarm が追加されたため、アラートだけが目的であれば、そちらを利用する方が適しています。 用途としては、次のようなものが考えられます。

  • S3 に保存したクエリ結果を月次レポートとして整形する
  • 監査ログなどの集計結果だけを S3 に保存し、CloudWatch Logs の保持期間を短くしてストレージコストを抑える

なお、Terraform の AWS Provider にはリソースがないようです。それほど最近の機能でもありませんが、Issue も見つかりません。 AWS Cloud Control Provider には awscc_logs_scheduled_query リソースがあるため、こちらを利用するとよいでしょう。

2025/12 データソース

https://aws.amazon.com/jp/about-aws/whats-new/2025/12/amazon-cloudwatch-unified-management-analytics/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/data-source-discovery-management.html

CloudWatch Logs にデータソースという管理単位が追加され、AWS サービス、サードパーティ製品、カスタムアプリケーションログなどをデータソースとして分類・管理できるようになりました。 いくつかの AWS サービスや、サードパーティ製品から取り込まれたログにはデフォルトで @data_source_name、@data_source_type、@data_format のシステムフィールドが付与されます。 また、アプリケーションログではロググループに次のタグを付与しておくと、それぞれ @data_source_name と @data_source_type となって取り込まれます。

cw:datasource:name
cw:datasource:type

これらはデフォルトでファセットになるので、ログ分析でドロップダウンリストで指定可能になります。

なお、データソース名、タイプ、ともに最大 64 文字で、小文字英字、数字、アンダースコアのみを含めることができ、英字または数字で始める必要があり、二重アンダースコア (__) を含めることはできません。例えばハイフンなどは含めることができません。また、データソース名を aws または amazon で始めることはできません。確認した限りでは、制約に違反したタグはエラーになるわけでもなく、静かに無視されてしまうので注意です。

2025/12 S3 Tables 統合

https://aws.amazon.com/jp/about-aws/whats-new/2025/12/amazon-cloudwatch-unified-management-analytics/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/s3-tables-integration.html

CloudWatch Logs に取り込まれたログを継続的に S3 Tables へ配信・テーブル化し、Athena などによる大規模な SQL 分析を可能にする機能です。 特に細かな設定をしなくても、統合を有効にすればロググループが S3 Tables へ配信されます。このとき、S3 Tables へは aws-cloudwatch というバケットが作られ、データソース名とタイプ名ごとにテーブルが作成されます。前述の通りアプリケーションログなどは cw:datasource:name や cw:datasource:type などのタグでデータソース名とタイプ名を付与しておく必要があります。

公式ドキュメントによると追加のコストは不要なため、統合を有効にしておいてもよさそうです。もちろん Athena などでテーブルを検索する場合は、各分析サービスの利用料金が別途発生します。

この統合によって作成された S3 テーブルには、既存の CloudWatch の取り込みとストレージ料金以外に、追加のストレージやテーブルのメンテナンス料金はかかりません。

ただ、個人的には、Athena は CloudWatch Logs にログを送れない ALB などのログを検索するために使うことが多く、CloudWatch Logs にあるログを、この統合を利用してまで Athena で検索することは無さそうです。

2026/03 HTTP エンドポイント

https://aws.amazon.com/jp/about-aws/whats-new/2026/03/cloudwatch-http-log-collector/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/CWL_HTTP_Endpoints.html

AWS SDK の PutLogEvents でログを送るのとは別に、シンプルな HTTP エンドポイントでもログを送れるようになっています。エンドポイントにはいくつかありますが

  • OTLP エンドポイント
  • HLC エンドポイント
  • ND-JSON エンドポイント
  • 構造化 JSON エンドポイント

自前のツールやアプリから送るなら ND-JSON エンドポイントがシンプルで良いように思います(JSON Lines の名の方が主流な気もする)。

ただし、EC2 からログを送る場合は、ファイルに書き出したうえで CloudWatch Agent を利用する方が簡単です。 すでに何らかのツールを導入していて、そのツールがこれらの方法によるログ送信に対応しているなら、そのまま利用できます。 OTLP は OpenTelemetry Collector など、HLC は Splunk 関係でしょうか。 わざわざ自前で HTTP エンドポイントにログを送信することは無さそうな気がします。

2026/06 Log Analytics

https://aws.amazon.com/jp/about-aws/whats-new/2026/06/amazon-cloudwatch-log-analytics/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/LogAnalytics.html

Logs Insights、Live Tail、Contributor Insights などが統合され、Log Analytics(ログ分析)にまとめられました。 UI が大きく変わったため、各機能の場所が分かりにくくなっています。オプトアウトすれば以前の Logs Insights も利用できるはずですが、確認した環境では、オプトアウト後の Logs Insights でログを検索できませんでした。原因は不明です。

慣れの問題だと思うので、基本的にオプトインしてログ分析の方を使うと良いでしょう。

2026/06 syslog 取り込み

https://aws.amazon.com/about-aws/whats-new/2026/06/amazon-cloudwatch-syslog-ingestion/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/CWL_Syslog.html

syslog 形式のログを直接取り込めるようになりました。 設定はやや複雑ですが、syslog 固有のメタデータがログのメッセージとは別のフィールドとして送られる点がメリットです。一方、EC2 上の Linux であれば、CloudWatch Agent を利用してログファイルから送る方が簡単です。エージェントを導入できず、syslog でしかログを出力できないアプライアンスなどに適しています。

2026/06 リソースタグでログイベントをエンリッチ化

https://aws.amazon.com/about-aws/whats-new/2026/06/amazon-cloudwatch-logs-resource-tags/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/resource-tags-for-telemetry.html https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/CloudWatchLogs-Facets.html

CloudWatch の設定で「テレメトリのリソースタグ」を有効にしておくと、ログの排出元のリソースに付いていたタグを @aws.tag.Env のように参照できるようになります。

この方法で追加されたフィールドは、デフォルトのファセットとして自動的に追加されます。デフォルトのファセットなので、フィールドインデックスのクォータには含まれません。追加のコストもないため、有効にしない理由はなさそうです。

なお、ある案件で試しに有効にしたところ、AWS リソースから直接出力されるログだけでなく、ECS サイドカーで実行している Fluent Bit から送られるログにも付与されていました。ECS サービスに付与されたタグでエンリッチ化されている可能性があります。

また、確認した環境では、ロググループに入った直後のログでは @aws.tag.Env などの属性を参照できず、少し時間が経ってから参照できるようになりました。公式ドキュメントでは取り込み時にタグを追加すると説明されているため、検索画面などへの反映が遅延していた可能性がありますが、詳細は不明です。

2026/07 Log Based Alarm

https://aws.amazon.com/about-aws/whats-new/2026/07/amazon-cloudwatch-log-alarms/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/Alarm-On-Logs.html

これまでログベースのアラームは、メトリクスフィルタでログからカスタムメトリクスを作ったうえで、それに CloudWatch Alarm を設定する必要がありました。 Log Based Alarm を使えば Logs Insights のクエリを直接 CloudWatch Alarm に設定できます。

今のところ Terraform の AWS Provider は未対応です。

AWS Cloud Control Provider では作成できるため、当面はこちらを利用します。

また、追加の情報としてマッチしたログ行をアラームの通知に挿入できます。メトリクスフィルタを使う方法だとログ行は通知に入れられないので、通知を受けた後にログを見に行く必要がありましたが、Log Based Alarm であれば通知にログ行自体が含まれているので、その内容を直ちに判断できます。

これまでは同じことを実現するために、Firehose、S3 Event、Lambda を組み合わせる必要がありました。Log Based Alarm だけで実現できるようになったため、構成が大幅に簡単になりました。

ただし、このアラームは Chatbot がまだ対応していないようです。そのままでは Chatbot から Slack へ通知できないため、メールを利用するか、Lambda などで通知処理を実装する必要があります。また、ログ行を通知に含めるのもメール通知だけでしかサポートされておらず、Lambda アクションのペイロードにはログ行は含まれません。

2026/07 Intelligent-Tiering

https://aws.amazon.com/jp/about-aws/whats-new/2026/07/amazon-cloudwatch-intelligent-tiering/ https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/cwl_intelligent_tier.html

CloudWatch Logs の設定に Intelligent-Tiering という機能が追加されました。これはリージョン単位の設定で、有効にするとアクセス頻度に応じてログが自動的に低コストなストレージ層に移行されるようになります。

ストレージ層は次の3段階あります。

  • Standard tier
  • Infrequent Access tier
  • Archive Instant Access tier

最初、Standard tier で取り込まれたログは、30 日間アクセスがなければ Infrequent Access tier に移行し、90 日間アクセスがなければ Archive Instant Access tier に移行します。アクセスがあればその時間範囲のログは自動的に Standard tier に戻るため、ストレージ層による機能的な制限は特にありません。有効にしない理由が見当たらない機能です。

注意すべき点として、ここで使われている Standard と Infrequent Access というのは、同名のログクラスとは別の概念です。整理すると次のようになります。似た用語が使われているため、混同しないよう気を付けましょう。

  • ログクラス
    • Standard と Infrequent Access で取り込みコストが異なる(ストレージコストは同じ)
    • Infrequent Access だと機能的な制限がある
  • Intelligent-Tiering
    • ストレージコストが異なる(取り込みコストとは無関係)
    • アクセス頻度に応じて下位の tier へ自動的に移行する
    • アクセス時に Standard tier に戻るため、機能的な制限はない

CloudWatch Logs で Standard や IA(Infrequent Access)という用語だけが示された場合、ログクラスと tier のどちらを指すのか区別しにくいため、注意が必要です。

さいごに

幾つかのプロジェクトで Datadog や Splunk を使い始めた当初は慣れない UI に戸惑いましたが、使い続けるうちに、CloudWatch Logs よりも高機能で使いやすいと感じるようになりました。 しかし、改めて CloudWatch Logs の機能を振り返ると、フィールドインデックス、ファセット、データソース、Log Based Alarm などが追加され、ログの検索や監視は以前より便利になっています。

今回は Logs の機能だけを取り上げましたが、CloudWatch には Application Signals による APM や、CloudWatch RUM によるリアルユーザーモニタリングも用意されています。これらを組み合わせることで、CloudWatch だけでも幅広いオブザーバビリティに対応できそうです。

mlr (Miller) を使いこなそう

mlr(Miller)は、CSV や JSON などの構造化データをコマンドラインで軽快に扱うためのツールです。 Unix の cut や sort、grep のような使い勝手でありながら、より強力なデータ処理能力を備えています。

様々なファイル形式への対応

mlr は様々なファイル形式を透過的に扱えます。

入力・出力形式の指定方法

入力と出力の形式は、それぞれ以下のフラグで個別に指定できます。

  • --i{format}: 入力形式の指定 (e.g. --icsv, --ijson)
  • --o{format}: 出力形式の指定 (e.g. --ocsv, --ojson)

入力と出力で同じ形式を使用する場合は、以下のフラグでまとめて指定できます。

  • --{format}: 入出力両方の形式 (e.g. --csv, --json)

また、--icsv --ojson を --c2j のように短いフラグでまとめて指定できる便利なショートカットもあります。c(CSV)、t(TSV)、j(JSON)、l(JSONLines)、y(YAML)、p(PPRINT)、m(Markdown)などの文字を組み合わせます。

例: CSV を読み込んで JSON Lines で出力する

$ cat <<'EOS' | mlr --c2l cat
name,value
foo,10
bar,20
EOS
# {"name": "foo", "value": 10}
# {"name": "bar", "value": 20}

主な対応形式

CSV / TSV (Comma / Tab-Separated Values)

カンマ(CSV)またはタブ(TSV)で区切られた形式です。RFC 4180 に準拠したダブルクォートの扱いなどに対応しています。

  • 入力フラグ: --icsv / --itsv
  • 出力フラグ: --ocsv / --otsv
  • 両方指定: --csv / --tsv

サンプル:

$ cat <<'EOS' | mlr --c2l cat
name,value,notes
foo,10,"This is a note."
bar,20,"Note with comma, and
a new line."
EOS
# {"name": "foo", "value": 10, "notes": "This is a note."}
# {"name": "bar", "value": 20, "notes": "Note with comma, and\na new line."}

csvlite / tsvlite

RFC 4180 に厳密に準拠しない、少し緩やかな CSV/TSV 形式です。例えば、クォートで囲まれていないフィールド内にクォート文字が現れることを許容します。

  • 入力フラグ: --icsvlite / --itsvlite
  • 出力フラグ: --ocsvlite / --otsvlite
  • 両方指定: --csvlite / --tsvlite

csv ではパースエラーになりますが、csvlite なら読み込めるデータの例です。

# --csv ではエラーになる
$ cat <<'EOS' | mlr --icsv --ojsonl cat
name,value
foo,I "love" miller
EOS
# mlr: mlr: CSV header/data length mismatch 2 != 1 at filename (stdin) row 2.

# --csvlite ならば読み込める
$ cat <<'EOS' | mlr --icsvlite --ojsonl cat
name,value
foo,I "love" miller
EOS
# {"name": "foo", "value": "I \"love\" miller"}

JSON

JSON の配列形式です。

  • 入力フラグ: --ijson
  • 出力フラグ: --ojson
  • 両方指定: --json

サンプル:

[
  {"name": "foo", "value": 10},
  {"name": "bar", "value": 20}
]

JSON Lines

1 行に 1 つの JSON オブジェクトが並ぶ形式です。ログファイルなどでよく使われます。

  • 入力フラグ: --ijsonl
  • 出力フラグ: --ojsonl
  • 両方指定: --jsonl

サンプル:

{"name": "foo", "value": 10}
{"name": "bar", "value": 20}

YAML

YAML 形式です。Miller 6 から正式にサポートされました。

  • 入力フラグ: --iyaml
  • 出力フラグ: --oyaml
  • 両方指定: --yaml

サンプル:

$ cat <<'EOS' | mlr --iyaml --ojsonl cat
- name: foo
  value: 10
- name: bar
  value: 20
EOS
# {"name": "foo", "value": 10}
# {"name": "bar", "value": 20}

PPRINT (Pretty-Printed)

等幅スペースで綺麗に整列された表形式です。

  • 入力フラグ: --ipprint
  • 出力フラグ: --opprint
  • 両方指定: --pprint

サンプル:

name value
foo  10
bar  20

Markdown

Markdown のテーブル形式です。

  • 入力フラグ: --imd
  • 出力フラグ: --omd
  • 両方指定: --md

サンプル:

| name | value |
| --- | --- |
| foo | 10 |
| bar | 20 |

DKVP (Delimited Key-Value Pairs)

キー=値 のペアがカンマなどのデリミタで区切られたフォーマットです。

  • 入力フラグ: --idkvp
  • 出力フラグ: --odkvp
  • 両方指定: --dkvp

サンプル:

name=foo,value=10
name=bar,value=20

NIDX (Numerically Indexed)

値だけがスペースやタブなどで区切られた形式です。キーは自動的に数値(1, 2, 3...)が割り当てられます。awk などの Unix 標準のテキスト処理ツールでよく扱われるフォーマットです。

  • 入力フラグ: --inidx
  • 出力フラグ: --onidx
  • 両方指定: --nidx

サンプル:

foo 10
bar 20

XTAB (Transposed Tabular)

キーと値が縦に並んだ形式です。横に長いレコードを縦に表示して見やすくしたい場合に便利です。

  • 入力フラグ: --ixtab
  • 出力フラグ: --oxtab
  • 両方指定: --xtab

サンプル:

name  foo
value 10

name  bar
value 20

便利な Verb

mlr の操作は Verb と呼ばれるサブコマンドを組み合わせて行います。

cat : 基本中の基本

cat は入力レコードをそのまま出力します。主にファイル形式の変換に使用します。

$ cat <<'EOS' | mlr --c2l cat
name,value
foo,10
bar,20
EOS
# {"name": "foo", "value": 10}
# {"name": "bar", "value": 20}

また、Unix 標準の cat コマンドと同様に、-n オプションで行番号を付与できます。

  • -n: n という名前のフィールドを先頭に追加し、1 から始まる行番号を付与します。
  • -N {label}: 指定した {label} という名前のフィールドを先頭に追加し、1 から始まる行番号を付与します。
$ cat <<'EOS' | mlr --c2l cat -N no
name,value
foo,10
bar,20
EOS
# {"no": 1, "name": "foo", "value": 10}
# {"no": 2, "name": "bar", "value": 20}

label : ヘッダーを付与する

label はフィールドに名前を付与します。ヘッダーのないファイルに名前を指定したい場合に便利です。

-N オプションを指定してヘッダーなし CSV として読み込むと、まず 1、 2、 3... といった連番が一時的なラベルとして割り当てられます。その上で label Verb を使用すると、それらの連番を分かりやすい名前に置き換えることができます。

# ヘッダー無し CSV はフィールド名が "1","2" のような連番になる
$ cat <<'EOS' | mlr --c2l -N cat
foo,10
bar,20
EOS
# {"1": "foo", "2": 10}
# {"1": "bar", "2": 20}

# label でフィールドに名前を付ける
$ cat <<'EOS' | mlr --c2l -N label name,value
foo,10
bar,20
EOS
# {"name": "foo", "value": 10}
# {"name": "bar", "value": 20}

join : データを結合する

join は、SQL の JOIN のように、2 つのファイルを特定のキーで結合します。

  • -f: 左側のファイル(メモリにすべて読み込まれる側)を指定します。
  • -j: 結合キーとなるフィールドを指定します。
  • --ul: ペアにならなかった左側ファイルのレコードも出力します( SQL の LEFT JOIN 相当)。
  • --ur: ペアにならなかった右側ファイルのレコードも出力します( SQL の RIGHT JOIN 相当)。
  • --lp {text}: 左側ファイルの競合フィールドに付与するプレフィックスを指定します。
  • --rp {text}: 右側ファイルの競合フィールドに付与するプレフィックスを指定します。

デフォルト(--ul や --ur の指定なし)では、両方に存在するレコードのみが出力されます(SQL の INNER JOIN 相当)。

$ cat <<'EOS' > data1.jsonl
{"id": 1, "name": "foo", "age": 20}
{"id": 2, "name": "bar", "age": 30}
{"id": 3, "name": "only_left", "age": 40}
EOS

$ cat <<'EOS' > data2.jsonl
{"id": 1, "value": 100, "age": 25}
{"id": 2, "value": 200, "age": 35}
{"id": 4, "value": 400, "age": 45}
EOS

# INNER JOIN
$ mlr --jsonl join -f data1.jsonl -j id data2.jsonl
# {"id": 1, "name": "foo", "age": 25, "value": 100}
# {"id": 2, "name": "bar", "age": 35, "value": 200}

# INNER JOIN (プレフィックス付き)
# 左右のファイルで共通する "age" フィールドのような競合を避けるため、
# --lp, --rp オプションを使って、それぞれプレフィックス(接頭辞)を付けます。
$ mlr --jsonl join -f data1.jsonl -j id --lp left_ --rp right_ data2.jsonl
# {"id": 1, "left_name": "foo", "left_age": 20, "right_value": 100, "right_age": 25}
# {"id": 2, "left_name": "bar", "left_age": 30, "right_value": 200, "right_age": 35}

# LEFT JOIN
$ mlr --jsonl join --ul -f data1.jsonl -j id data2.jsonl
# {"id": 1, "name": "foo", "age": 25, "value": 100}
# {"id": 2, "name": "bar", "age": 35, "value": 200}
# {"id": 3, "name": "only_left", "age": 40}

# RIGHT JOIN
$ mlr --jsonl join --ur -f data1.jsonl -j id data2.jsonl
# {"id": 1, "name": "foo", "age": 25, "value": 100}
# {"id": 2, "name": "bar", "age": 35, "value": 200}
# {"id": 4, "value": 400, "age": 45}

cut : フィールドを選択・除外する

cut は特定のフィールドのみを選択、または除外します。

$ echo '{"id": 1, "name": "foo"}' | mlr --jsonl cut -f name
# {"name": "foo"}

$ echo '{"id": 1, "name": "foo"}' | mlr --jsonl cut -x -f name
# {"id": 1}

filter : レコードを絞り込む

filter は SQL の WHERE 句のように、条件に一致するレコードを抽出します。

$ cat <<'EOS' | mlr --c2l filter '$value > 15'
name,value
foo,10
bar,20
EOS
# {"name": "bar", "value": 20}

put : フィールドを加工・追加する

put は新しいフィールドを計算して追加したり、既存のフィールドを加工したりできます。

$ echo '{"name": "foo", "value": 10}' | mlr --jsonl put '$new_value = $value * 10'
# {"name": "foo", "value": 10, "new_value": 100}

sort : レコードを並べ替える

sort はレコードを並べ替えます。数値順(-n)や辞書順(-f)、降順(-r)などを指定できます。

$ cat <<'EOS' | mlr --c2l sort -nr value
name,value
foo,10
bar,20
baz,5
EOS
# {"name": "bar", "value": 20}
# {"name": "foo", "value": 10}
# {"name": "baz", "value": 5}

uniq : 重複を排除する

uniq は指定したフィールドの値に基づいて、重複のない(ユニークな)レコードを抽出します。

  • -g {フィールド名} (または -f): 重複排除の基準とするフィールドを指定します(複数ある場合はカンマ区切り)。
  • -c: 重複している数をカウントして一緒に出力します。
  • -o {名前} : -c で出力されるカウント結果のフィールド名を指定します(デフォルトは "count" )。
  • -a: 特定のフィールドだけでなく、レコード全体として完全に重複しているレコードを 1 つにまとめます( -g とは併用できません)。
  • -n: ユニークな値の「種類数(件数)」のみを出力します。

例: 特定のフィールドでユニークにする

$ cat <<'EOS' | mlr --c2l uniq -g name
name,category
foo,A
bar,B
foo,A
baz,A
bar,B
EOS
# {"name": "foo"}
# {"name": "bar"}
# {"name": "baz"}

例: 重複レコードのカウントも一緒に出力する

$ cat <<'EOS' | mlr --c2l uniq -g name -c
name,category
foo,A
bar,B
foo,A
baz,A
bar,B
EOS
# {"name": "foo", "count": 2}
# {"name": "bar", "count": 2}
# {"name": "baz", "count": 1}

例: レコード全体で重複を排除する(-a オプション)

$ cat <<'EOS' | mlr --c2l uniq -a -c
name,category
foo,A
bar,B
foo,A
baz,A
bar,B
EOS
# {"count": 2, "name": "foo", "category": "A"}
# {"count": 2, "name": "bar", "category": "B"}
# {"count": 1, "name": "baz", "category": "A"}

count : レコード数をカウントする

count はレコードの件数をカウントします。

  • -g {フィールド名}: 指定したフィールドの値ごとにグループ化してカウントします。
  • -o {名前}: カウント結果のフィールド名を指定します(デフォルトは "count" )。
  • -n: グループ化したときのユニークな値の数(グループの総数)のみを出力します(-g と一緒に使用します)。

例: 全体のレコード数をカウントする

$ cat <<'EOS' | mlr --c2l count
name,category
foo,A
bar,B
foo,A
baz,A
bar,B
EOS
# {"count": 5}

例: グループごとにカウントする

$ cat <<'EOS' | mlr --c2l count -g category
name,category
foo,A
bar,B
foo,A
baz,A
bar,B
EOS
# {"category": "A", "count": 3}
# {"category": "B", "count": 2}

split : 特定のキーでファイルを分割する

split を使用すると、特定のフィールド(キー)の値ごとにデータを自動的に分割して、別々のファイルに出力できます。

# category フィールドの値ごとにファイルを分割する
# split_A.jsonl と split_B.jsonl が生成されます
$ cat <<'EOS' | mlr --jsonl split -g category --prefix split --suffix jsonl
{"category": "A", "val": 10}
{"category": "B", "val": 20}
{"category": "A", "val": 30}
EOS

$ cat split_A.jsonl
# {"category": "A", "val": 10}
# {"category": "A", "val": 30}

$ cat split_B.jsonl
# {"category": "B", "val": 20}

flatten : ネストした構造を平坦化する

flatten は、JSON などのネストされた(階層構造を持つ)フィールドを、1 つのフラットなフィールドに展開します。

  • -s {string}: 平坦化する際のキーの区切り文字を指定します。デフォルトは . です。
  • -f {fields}: 平坦化する特定のフィールド名を指定します(カンマ区切り)。指定しない場合はすべてのフィールドが対象となります。

例: ネストした JSON をフラットにする

$ echo '{"id": 1, "a": {"name": "foo", "age": 20}}' | mlr --jsonl flatten
# {"id": 1, "a.name": "foo", "a.age": 20}

unflatten : 平坦なキーをネストした構造に戻す

unflatten は flatten の逆の操作を行います。区切り文字(ドットなど)で表現されたフラットなフィールド名を、ネストされた階層構造(オブジェクト)へと復元します。

  • -s {string}: 階層化する際のキーの区切り文字を指定します。デフォルトは . です。
  • -f {fields}: 階層化する特定のフィールド名を指定します。

例: ドット区切りのキーをネストした JSON に戻す

$ echo '{"id": 1, "a.name": "foo", "a.age": 20}' | mlr --jsonl unflatten
# {"id": 1, "a": {"name": "foo", "age": 20}}

join コマンドで左右のデータのフィールド名の重複を避けるために --lp(左プレフィックス)や --rp(右プレフィックス)を使用することがありますが、そのプレフィックスに a. や b. のようなドット区切りの名前を指定しておき、結合したあとに unflatten を適用すると、結合元のデータをそれぞれのオブジェクトとして綺麗にネストした JSON に整形できます。

$ cat <<'EOS' > data1.jsonl
{"id": 1, "name": "foo", "age": 20}
EOS

$ cat <<'EOS' > data2.jsonl
{"id": 1, "score": 100, "age": 25}
EOS

# join の --lp, --rp でドット区切りの接頭辞を付け、then unflatten でオブジェクト化する
$ mlr --jsonl join -f data1.jsonl -j id --lp a. --rp b. then unflatten data2.jsonl
# {"id": 1, "a": {"name": "foo", "age": 20}, "b": {"score": 100, "age": 25}}

Verb の組み合わせ

then を使用すると、複数の Verb をつなげて実行できます。

# value が 15 より大きいレコードを抽出し、value でソートしてから、name フィールドだけを切り出す
$ cat <<'EOS' | mlr --c2l filter '$value > 15' then sort -n value then cut -f name
name,value
foo,10
bar,20
baz,5
EOS
# {"name": "bar"}

その他の便利なオプション

コメント行の扱い

--skip-comments フラグを使用すると、# で始まる行を無視できます。

$ cat <<'EOS' | mlr --c2l --skip-comments cat
# test data
name,value
foo,10
#bar,20
baz,30
EOS
# {"name": "foo", "value": 10}
# {"name": "baz", "value": 30}

--pass-comments フラグを使用すると、コメント行は解析されずにそのまま即時出力されます。

$ cat <<'EOS' | mlr --c2l --pass-comments cat
# test data
name,value
foo,10
#bar,20
baz,30
EOS
# # test data
# {"name": "foo", "value": 10}
# #bar,20
# {"name": "baz", "value": 30}

元ファイルのコメント行を維持したいときに便利ですが、そのまま出力されるだけなので、出力のファイル形式でそれがコメントとして扱われるかどうかは別問題です。

ヘッダー行の有無

  • --implicit-csv-header (--hi): 入力ファイルにヘッダーがないものとして扱います。フィールド名は 1、 2、 3... となります。
  • --headerless-csv-output (--ho): 出力にヘッダー行を含めません。
  • -N: --implicit-csv-header と --headerless-csv-output を両方指定した状態と同様になります。

実務においては最終的な出力を CSV/TSV にすることはそれほど多くなく、JSON や JSON Lines にすることが多いため、出力側のヘッダー有無は関係ないことがほとんどです。 そのため、ヘッダーなしの CSV を読み込みたい場合は、両方指定となる -N オプションを使用するのが最も簡単です。

# ヘッダーなしCSVにラベルを付け、JSON Lines形式で出力する
$ cat <<'EOS' | mlr --icsv -N --ojsonl label name,value
foo,10
bar,20
EOS
# {"name": "foo", "value": 10}
# {"name": "bar", "value": 20}

フィールド区切り文字(デリミタ)のカスタマイズ

CSV や TSV 以外の、特殊な文字で区切られたデータを扱う場合に便利です。

  • --ifs: 入力フィールドの区切り文字を指定します。
  • --ofs: 出力フィールドの区切り文字を指定します。
  • --fs: 入出力両方のフィールド区切り文字を指定します。
# セミコロン区切りのデータを読み込んで、コロン区切りのファイルとして返す
$ echo 'foo;10' | mlr --csv -N --ifs ";" --ofs ":" cat
# foo:10

列数が揃っていない不揃いな CSV を許容する

  • --ragged(または --allow-ragged-csv-input): データ行の列数がヘッダー行より少ない場合、空文字で埋めます。多い場合は数値インデックスを自動で割り当てます。このオプションがないとエラーになります。
$ cat <<'EOS' | mlr --c2l --ragged cat
name,value,extra
foo,10
bar,20,yes,unwanted
baz
EOS
# {"name": "foo", "value": 10}
# {"name": "bar", "value": 20, "extra": "yes", "4": "unwanted"}
# {"name": "baz"}

# --ragged が無いとエラーです
cat <<'EOS' | mlr --c2l cat
name,value,extra
foo,10
bar,20,yes,unwanted
baz
EOS
# mlr: mlr: CSV header/data length mismatch 3 != 2 at filename (stdin) row 2.

類似のツール

mlr のようにコマンドラインで構造化データ(特に CSV / TSV )を扱うための類似ツールをいくつか紹介します。ほとんど使ったことは無いので説明には誤りが含まれているかもしれません。

qsv

xsv の後継として開発されている、Rust 製の高速なデータ処理ツールキットです。

  • 特徴: 動作が極めて高速です。CSV だけでなく、TSV などのデリミタ付きテキストはもちろん、JSON や JSON Lines、Excel(XLSX)、Parquet、SQLite、PostgreSQL など、多彩なフォーマットの相互変換やインポート・エクスポートをサポートしています。
  • 使い所: 巨大な CSV や Parquet などのデータを高速にフィルタリング・集計したい場合や、各種データフォーマットをシームレスに相互変換したい場合。

dasel

YAML や JSON、XML、CSV、TOML などの複数フォーマットをシームレスに扱える、Go 製の構造化データ処理ツールです。

  • 特徴: jq に似たシンプルなセレクタ構文を使い、フォーマットの違いを意識せずにデータの抽出や更新が行えます。XML や TOML にも対応しているのが大きな強みです。
  • 使い所: JSON や YAML だけでなく、XML や TOML など多様なフォーマットのデータを単一のツールで手軽に操作・相互変換したい場合。

DuckDB(CLI)

インメモリで高速に動作する、分析用途に特化したデータベースです。コマンドラインツールも提供されています。

  • 特徴: CSV や Parquet、JSON といったファイルを直接、高速な SQL でクエリできます。Pandas との連携もスムーズです。
  • 使い所:巨大な CSV や Parquet ファイルに対して、より高度で複雑な SQL を使ったデータ分析や集計を行いたい場合。

まとめ

mlr は非常に多機能であり、公式リファレンス( https://miller.readthedocs.io/en/latest/reference-verbs/ )を確認するとわかるように、本稿では紹介しきれない多数の Verb が存在します。

しかし、filter や put、sort などのレコード操作は、一度 JSON で出力したうえで jq にパイプして処理すれば十分なことも多く、mlr の独自の DSL を苦労して覚える必要性はそれほど高くありません。

一方で、以下のような処理は jq では行うのが難しく、mlr が真価を発揮する領域です。

  • 複数ファイルに対する join (結合処理)

    • Unix / Linux には標準の join コマンドが存在しますが、これには「結合キーとなる列であらかじめデータをソートしておかなければならない」という厳しい制約があります。また、CSV などのデリミタやクォート、改行が含まれる複雑なデータを正しく処理するのは困難です。mlr join を使用すれば、データの事前ソートは不要で、CSV や JSON Lines のような構造化データのまま、キーが一致するレコードを極めて手軽に結合できます。左右のファイルで競合するフィールド名に接頭辞を付与する処理(--lp / --rp)もオプション一つでスマートに行えます。
  • 構造化された状態を維持したまま uniq -c (重複カウント)する処理

    • uniq による重複排除やカウントは、jq で特定のフィールドを抽出したうえで、sort | uniq -c にパイプすることでも同様の結果が得られます。しかし、uniq -c を使用すると出力がプレーンテキストになってしまい、その後の構造化データとしての処理が難しくなります。mlr uniq -c (または count)を使用すれば、カウント結果(count フィールド)を含んだ構造化データ( JSON Lines や CSV など)として直接出力できるため、パイプラインの後続処理にそのまま繋げられて非常に便利です。

jq(に限らず普段使いの他のツール)単体では記述が困難な、ちょっと面倒な前処理やフォーマット変換を補完する形で mlr を組み合わせて使用するのが、スマートな使い方と言えるでしょうか。

WSL から code コマンドでファイルをなるべく高速に開く試み

VS Code の Remote WSL 拡張があれば WSL 内のファイル・ディレクトリを Windows 側の VS Code の GUI で開くことができて大変便利です。また、VS Code インストールディレクトリの下記にあるシェルスクリプトを WSL 側で実行するとコマンドラインから VS Code でファイルを開くこともできます。

C:\Users\oreore\AppData\Local\Programs\Microsoft VS Code\bin\code

エイリアスなどで code だけで実行できるようにしておくと便利でしょう。

# ディレクトリを開く
code .

# ファイルを開く
code file.md

# ファイルを開いて、閉じられるのを待つ
code -w file.md

code -w を core.editor に設定しておけば git のコミットメッセージなども VS Code で書けるようになります。

git config --global core.editor 'code -w'

ただ、1点問題があるとすれば・・コマンドを叩いてから実際に VS Code でファイルが開かれるまで、微妙に遅いんです。一呼吸待たされます。たまになら良いのですが Git でコミットするたびのことなので、この待ち時間は少し気になります。何とかしたいです。

なぜこんなに遅いのか

下記で実行して出てくるログを眺めるに、

VSCODE_WSL_DEBUG_INFO=true code README.md

遅いのは下記の部分です。

'/c/Users/oreore/AppData/Local/Programs/Microsoft VS Code/Code.exe' 'C:/Users/oreore/AppData/Local/Programs/Microsoft VS Code/XXX/resources/app/out/cli.js' --locate-extension ms-vscode-remote.remote-wsl

これは Windows 側の Code.exe が起動されています。Code.exe の実体はおそらく Electron の巨大バイナリのため、その起動が体感上の遅さにつながっているように見えます。

ただ、やっていることはファイル /tmp/remote-wsl-loc.txt に Windows 側の Remote-WSL 拡張のパスを書き出しているだけのようです。例えば次のような内容です。

cat /tmp/remote-wsl-loc.txt
# c:\Users\oreore\.vscode\extensions\ms-vscode-remote.remote-wsl-0.104.3

Remote-WSL 拡張のパスを別の方法で得る

Remote-WSL 拡張のパスを得るためだけに巨大な exe が実行されているのが遅い原因だと分かったので、そこを適当な方法で置き換えてみます。

例えば、ディレクトリは固定で良さそうなので sort --version-sort でソートして最新版のパスを得てみたり、

ls -d /c/Users/oreore/.vscode/extensions/ms-vscode-remote.remote-wsl-* | sort --version-sort -r | head -1
# /c/Users/oreore/.vscode/extensions/ms-vscode-remote.remote-wsl-0.104.3

もしくは WSL 側で実行中の vscode-server のプロセスの環境変数から得たり。

pid="$(pgrep -n -f '.vscode/extensions/ms-vscode-remote.remote-wsl-.*/scripts/wslServer.sh')"
cat /proc/$pid/environ | grep -z '^VSCODE_WSL_EXT_LOCATION=' | cut -z -d= -f2- | tr -d '\0'
# /c/Users/oreore/.vscode/extensions/ms-vscode-remote.remote-wsl-0.104.3

Code.exe を実行して Remote-WSL 拡張のパスを得ている部分を、これらの方法に置き換えれば若干ですが早くなります。

統合ターミナルだともっと早い

ただ、それでもやっぱりまだ遅いです。strace してみたところ結局もう一度 Code.exe が実行されていました、どうやら Windows 側で Code.exe が新たに開始され、そして Windows 側で IPC 的な方法で起動済の Code.exe とやり取りしているようです。

一方、VS Code の統合ターミナル内で code を叩いてみると爆速です。統合ターミナルでは環境変数 PATH が追加され、code コマンドは次のような WSL 側の vscode-server の code コマンドが実行されるようになっていました。

which code
# ~/.vscode-server/bin/0958016b2af9f09bb4257e0df4a95e2f90590f9f/bin/remote-cli/code

ただ、これを VS Code の外のシェルで実行してもダメでした。

~/.vscode-server/bin/0958016b2af9f09bb4257e0df4a95e2f90590f9f/bin/remote-cli/code
# Command is only available in WSL or inside a Visual Studio Code terminal.

どうやら統合ターミナル内では VSCODE_IPC_HOOK_CLI という環境変数に vscode-server とやり取りするための Unix ドメインソケットのパスが入っているようで、これが必要なようです。

同じものを VS Code の外のシェルで実行するときにも設定しておけば、Code.exe が実行されることもなく爆速でファイルが開けそうです。

VSCODE_IPC_HOOK_CLI 自体は紆余曲折の末、次の方法で取るようにしてみました。

cmd="$(ls ~/.vscode-server/bin/*/node -t | head -1)"
pattern="$(pidof "$cmd" | sed -e 's/ /,|,pid=/g' -e 's/^/,pid=/' -e 's/$/,/')"
VSCODE_IPC_HOOK_CLI="$(ss -lxp | grep -F "${XDG_RUNTIME_DIR%/}/vscode-ipc" | grep -E "($pattern)" | head -1 | awk '{print $5}')"
VSCODE_BIN="$(ls ~/.vscode-server/bin/*/bin/remote-cli/code -t | head -1)"
export VSCODE_IPC_HOOK_CLI
exec "$VSCODE_BIN" "$@"

これで Code.exe が実行されなくなるので、ファイルが爆速で開くようになります。

VS Code がフォアグラウンドにならない

まだ問題がありました。この方法だと VS Code のウィンドウがフォアグラウンドにならないのです。例えばターミナルで git commit したときに、ターミナルの裏にいる VS Code で COMMIT_EDITMSG が開かれるだけなので、自分でウィンドウを切り替えないとコミットメッセージが書けません。

これはだいぶ不便です。何とかしたいです。要するに前述の方法でファイルを開いた後に VS Code のウィンドウをアクティブ化できればいいのです。それぐらいなら PowerShell で簡単にできます。

cmd="$(ls ~/.vscode-server/bin/*/node -t | head -1)"
pattern="$(pidof "$cmd" | sed -e 's/ /,|,pid=/g' -e 's/^/,pid=/' -e 's/$/,/')"
VSCODE_IPC_HOOK_CLI="$(ss -lxp | grep -F "${XDG_RUNTIME_DIR%/}/vscode-ipc" | grep -E "($pattern)" | head -1 | awk '{print $5}')"
VSCODE_BIN="$(ls ~/.vscode-server/bin/*/bin/remote-cli/code -t | head -1)"
export VSCODE_IPC_HOOK_CLI
powershell.exe -NoProfile -command '$null = (New-Object -ComObject Wscript.Shell).AppActivate("Visual Studio Code")'
exec "$VSCODE_BIN" "$@"

完璧です。ファイルを爆速で開きつつ、VS Code のウィンドウもフォアグラウンドに切り替わります。

tmux との相性が悪い

大抵の人にはここまでで十分かもしれません。とはいえ、WSL で tmux を使っている人には都合が悪い問題がまだありました。 前述の方法、普通に使っている分には問題ないのですが、次のような条件で VS Code のフォアグラウンド化が失敗することがあります。

    1. ターミナルから tmux を開始する
    1. ターミナル自身を終了する
    1. ターミナルを再実行して tmux のセッションにアタッチする

この後、同じ方法で VS Code をフォアグラウンド化しようとしても、タスクバーで明滅はするもののウィンドウ自体は切り替えられません。

ここからは完全に観測結果に基づく推測になるのですが・・A の時点で開始した tmux が、この時点ではターミナルと同じフォアグラウンドプロセスだったのが、B の時点でバックグラウンドプロセスに落ちて、C ではバックグラウンドプロセスである tmux にアタッチした状態になっているので、その時点ではシェル自体がバックグラウンドプロセス扱いになり、他のウィンドウのフォアグラウンド化ができなくなってしまうためだと思います(この辺り Windows と WSL が絡み合って複雑なので詳しいことは全くわからない)。

https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/nf-winuser-setforegroundwindow

  • 呼び出し元のプロセスがフォアグラウンド プロセスです。
  • 呼び出し元のプロセスは、フォアグラウンド プロセスによって開始されました。

これは PowerShell の Start-Process を挟むことで回避できます。

cmd="$(ls ~/.vscode-server/bin/*/node -t | head -1)"
pattern="$(pidof "$cmd" | sed -e 's/ /,|,pid=/g' -e 's/^/,pid=/' -e 's/$/,/')"
VSCODE_IPC_HOOK_CLI="$(ss -lxp | grep -F "${XDG_RUNTIME_DIR%/}/vscode-ipc" | grep -E "($pattern)" | head -1 | awk '{print $5}')"
VSCODE_BIN="$(ls ~/.vscode-server/bin/*/bin/remote-cli/code -t | head -1)"
export VSCODE_IPC_HOOK_CLI
powershell.exe -NoProfile -Command "Start-Process powershell.exe -ArgumentList '-NoProfile -Command [void](New-Object -ComObject Wscript.Shell).AppActivate(''Visual Studio Code'')' -WindowStyle Hidden"
exec "$VSCODE_BIN" "$@"

Start-Process は内部的に ShellExecuteEx が呼ばれており、これは Windows Shell を通じてプロセスが起動されるため、フォアグラウンドプロセスとして実行させることができるのだろうと思います(たぶん)。

さいごに

ここまでくれば EDITOR 環境変数も設定しちゃっていいでしょう。vim や nano を置き換える感覚で、VS Code が使えるようになります。

export EDITOR='code -w'

実際のところ素の方法だけでは Git の core.editor を code -w にするのは辛いものがあると思います。

なお、この記事で説明した方法は VS Code の(というか Remote-WSL 拡張の)実装に強く依存しているため、今後のアップデートで動かなくなる可能性はあると思います(今のところ3年ぐらいは問題なく使えている)。