デプロイログをOpenTelemetryとファイルに出力するネイティブ対応を追加

basecamp/kamal

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)にマッピングされます。

デプロイコマンド(deployredeployrollbacksetup)では、OTel Semantic Conventionsに準拠した deployment.* 属性が付与されます。カスタム属性は kamal.* 名前空間下に定義されています:

  • kamal.commandkamal.runtimekamal.deploy_version
  • kamal.performerkamal.run_id
  • deployment.environment.namedeployment.iddeployment.namedeployment.status

ライフサイクルイベントは kamal.startkamal.completekamal.failed の3種類で、on_finish では例外の有無に応じて kamal.complete または kamal.failed を発火し、exception.typeexception.message 属性を付与します。

FileLogger

Kamal::Output::FileLoggeron_start でタイムスタンプ付きファイル名(YYYY-MM-DDTHH-MM-SS_[destination_]command.log)のファイルを開き、on_finish で完了メッセージまたは失敗メッセージを書き込んで閉じます。on_start 前に受け取ったログ行は @filenil のため無視されます。

設定インターフェース

config/deploy.ymloutput: セクションを追加することで有効化されます:

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 はロックを必要としないコマンドには使われていませんでしたが、modifylock: 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 の継承だけで新しいログ宛先を組み込める拡張性が確保されています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
6afb1d98

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)、背景、技術的な変更、設計判断(各論)、まとめ(結論)の3部構成が明確に守られており、非常に分かりやすい構成です。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```ruby:filepath```)およびGitHubのPRリンク記法([#1803](URL))は正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

OpenTelemetry, SSHKit, ActiveSupport::Notificationsなどの用語が前提知識として扱われており、対象読者である専門知識を持つエンジニアに適した技術レベルです。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各パラグラフはトピックセンテンスで始まり、1段落1トピックの原則が守られています。段落の長さも適切で、非常に高い可読性を確保しています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているコードブロック(`cli/base.rb`, `output/formatter.rb`など)は、提供されたDiff情報と完全に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「OTLP HTTP」「ActiveSupport::BroadcastLogger」「OTel Semantic Conventions」など、専門用語が文脈に応じて正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

`modify`メソッドがイベント通知とロック管理を統合する役割や、スレッドローカル変数によるコンテキスト伝播の仕組みなど、技術的な説明は正確かつ論理的です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内の主張はすべてPRのDescriptionやDiff内のコードで裏付けられています。例えば、OtelShipperのバッチサイズやフラッシュ間隔、severityのマッピングなどはコードから正確に引用されており、ハルシネーションは一切ありません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#1803)、定数(BATCH_SIZE = 100、FLUSH_INTERVAL = 5秒)、OTelのseverityNumberなどが正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトル「デプロイログをOpenTelemetryとファイルに出力するネイティブ対応を追加」は、PRの主題を的確に表現しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事はPRで提供された情報のみに基づいており、バージョンのサポート状況や将来の廃止予定といった外部知識の追記はありません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

機能が「追加されました」と過去形で記述されており、PRの内容と時間表現が一致しています。