デプロイログをOpenTelemetryとファイルに出力するネイティブ対応を追加
Kamalのデプロイ操作に対して、ログをOTLP HTTP経由でOpenTelemetryコレクターへ転送するか、タイムスタンプ付きファイルに書き出す仕組みが追加されました。output: 設定セクションを追加するだけで、既存のデプロイフローを変えることなくオブザーバビリティ基盤との統合が可能になります。
背景
これまでKamalのデプロイ実行ログは標準出力に表示されるのみで、外部のログ収集基盤に転送する手段がありませんでした。デプロイの成否や実行時間を計測したり、障害時に詳細なログを事後確認したりするには、CI/CDパイプライン側でログをキャプチャするなどの独自実装が必要でした。
PR内で「インフラ変更を行うすべてのコマンドを単一の統合ポイントで捕捉する」という設計方針が示されており、それを実現するために with_lock の役割を拡張した modify メソッドが導入されています。これにより、ロック取得という副作用だけでなく、出力キャプチャやライフサイクルイベントの通知も同一の呼び出し境界で処理できるようになりました。
技術的な変更
modify メソッドによる統合ポイントの集約
全CLIコマンドで with_lock の直接呼び出しが modify(lock: true) に置き換えられ、インフラ変更操作の入口が一本化されました。
lib/kamal/cli/base.rb に追加された modify メソッドは、Kamal::Commander#modify を呼び出してから、lock: true が指定された場合のみ with_lock でブロックを包みます:
def modify(lock: false)
KAMAL.modify(command: command, subcommand: subcommand) do
lock ? with_lock { yield } : yield
end
end
Kamal::Commander#modify はネストの深さを @modify_depth で管理し、最外殻の呼び出しのみが ActiveSupport::Notifications を通じて modify.kamal イベントを発火します。ネストされた modify 呼び出しはイベントを重複発火せず、ensure 節で modify_finished を確認してから output_logger を閉じます。
また、Thorの say メソッドがオーバーライドされ、CLIメッセージも自動的にロガーへ転送されます:
def say(message = "", *)
super
KAMAL.log(message.to_s)
end
ログ出力パイプラインの構造
ログは2つの経路でキャプチャされます。1つはThorの say 経由のCLIメッセージ、もう1つはSSHKitのフォーマッターを拡張した Kamal::Output::Formatter によるリモートコマンド出力です。
Kamal::Output::Formatter はSSHKitの Pretty フォーマッターを継承し、各コマンドの出力時にスレッドローカル変数でコンテキストを付与します:
def with_command_context(command, iostream: nil)
Thread.current[:kamal_host] = command.host.to_s
Thread.current[:kamal_iostream] = iostream
yield
ensure
Thread.current[:kamal_host] = nil
Thread.current[:kamal_iostream] = nil
end
これにより各ログ行には server.address(ホスト)と log.iostream(stdout/stderr)の情報が付随し、OtelLoggerがOTelセマンティクス属性として利用できます。
キャプチャされたログは ActiveSupport::BroadcastLogger が受け取り、設定された複数のロガーに並列で配信します。各ロガーは Kamal::Output::BaseLogger を継承し、modify.kamal イベントを ActiveSupport::Notifications 経由でサブスクライブすることでライフサイクルに連動します。
OtelLogger と OtelShipper
Kamal::Output::OtelLogger はログ行の受け取りと、デプロイライフサイクルの構造化イベント送信を担います。スレッドローカルのコンテキスト(ホスト、iostream、severity)を読み取り、Kamal::OtelShipper に渡します。
OtelShipper はスレッドセーフな Queue でログ行をバッファリングし、バッチサイズ(BATCH_SIZE = 100)に達するか5秒間隔(FLUSH_INTERVAL)でOTLP HTTP /v1/logs エンドポイントへフラッシュします。SSHKitのseverityレベルはOTelのseverityNumber(DEBUG=5、INFO=9、WARN=13、ERROR=17、FATAL=21)にマッピングされます。
デプロイコマンド(deploy、redeploy、rollback、setup)では、OTel Semantic Conventionsに準拠した deployment.* 属性が付与されます。カスタム属性は kamal.* 名前空間下に定義されています:
-
kamal.command、kamal.runtime、kamal.deploy_version -
kamal.performer、kamal.run_id -
deployment.environment.name、deployment.id、deployment.name、deployment.status
ライフサイクルイベントは kamal.start・kamal.complete・kamal.failed の3種類で、on_finish では例外の有無に応じて kamal.complete または kamal.failed を発火し、exception.type・exception.message 属性を付与します。
FileLogger
Kamal::Output::FileLogger は on_start でタイムスタンプ付きファイル名(YYYY-MM-DDTHH-MM-SS_[destination_]command.log)のファイルを開き、on_finish で完了メッセージまたは失敗メッセージを書き込んで閉じます。on_start 前に受け取ったログ行は @file が nil のため無視されます。
設定インターフェース
config/deploy.yml に output: セクションを追加することで有効化されます:
output:
otel:
endpoint: http://otel-gateway:4318
file:
path: tmp/kamal/
Kamal::Configuration::Output が設定を読み込み、LOGGER_TYPES ハッシュで "otel" → Kamal::Output::OtelLogger、"file" → Kamal::Output::FileLogger の対応を定義します。output: セクションが存在しない場合は enabled? が false を返し、ロガーは生成されません。
設計判断
with_lock をそのまま拡張するのではなく modify という新メソッドで包む設計が選ばれています。
with_lock はロックを必要としないコマンドには使われていませんでしたが、modify は lock: false(デフォルト)でも出力キャプチャとイベント通知を行います。deploy コマンド全体を modify で囲み、その内側のロックが必要な処理のみを modify(lock: true) で囲む二重ネスト構造により、ビルドとプッシュ(ロック不要)とデプロイ本体(ロック必要)の両方のログが単一の modify.kamal ライフサイクルに収まります。
ベストエフォート方針も明示されています。output_logger の失敗がデプロイをブロックしないよう、Formatter#write_message では rescue nil でロガーへの書き込みエラーを握り潰し、OtelShipperのフラッシュ失敗もデプロイ処理に影響しない設計になっています。
またスレッドローカル変数によるコンテキスト伝播は、SSHKitが並列で複数ホストにコマンドを実行する際にホストとiostream情報を各スレッドで独立して保持するための選択であり、共有状態への競合なしに per-host タグ付けを実現しています。
まとめ
modify メソッドによる統合ポイントの確立と ActiveSupport::Notifications を活用したイベント駆動設計により、既存のデプロイロジックを変えることなくプラガブルなログ出力基盤が実現されました。OTelとファイルの2バックエンドに加え、LOGGER_TYPES への追加と BaseLogger の継承だけで新しいログ宛先を組み込める拡張性が確保されています。