Rails on GKEでも理想の構造化ログを目指す
はじめに
最近GKEに触り始めたのですが、ログ周りでGCPで理想の構造化ログを出力する方法という記事が非常に勉強になりました。その記事ではGoだったので、Railsでも理想の構造化ログを目指してみました。ちょっとコードは整理されていないですが、とりあえずいい感じにログは見れるようになったと思うので公開します。言語に依存しない、どうすれば理想の構造化ログになるのか、に関しては上の記事をみてください。
Rails Serverのログ
大枠としては以下のようになります。
- アクセスログを独自のフォーマッタを使ったlogrageを使い
STDOUT
に出力 - アプリケーションログには
Rails.logger
を使い、独自のフォーマッタでSTDERR
に出力 - アクセスログとアプリケーションログをグループ化するための
"logging.googleapis.com/trace"
はActionDispatch::RequestId
が作るリクエストIDを元に作る
STDERR
に出力するのが少し気持ち悪いですが、アクセスログとアプリケーションログの親子関係を作るために必要なのでやむなしです。
アクセスログ
logrageは普通にインストールしておきます。
group :production do
gem "lograge"
end
これから説明していきますが、logrageの設定は以下です。
require_relative "../lib/lograge/formatters/google_cloud_logging"
Rails.application.configure do
# ...
proxies = ENV.fetch("TRUSTED_PROXIES", "").split(",").map {|cidr| IPAddr.new(cidr) }
config.action_dispatch.trusted_proxies = proxies + ActionDispatch::RemoteIp::TRUSTED_PROXIES
config.lograge.enabled = true
config.lograge.logger = ActiveSupport::Logger.new(STDOUT)
config.lograge.custom_options = lambda do |event|
{ time: Time.now }
end
config.lograge.custom_payload do |controller|
request = controller.request
{
request_id: request.request_id,
url: request.original_url,
user_agent: request.user_agent,
forwarded_for: request.forwarded_for || [],
remote_ip: request.remote_ip,
server_ip: ENV["POD_IP"],
referer: request.referer,
protocol: request.env["SERVER_PROTOCOL"],
}
end
config.lograge.formatter = Lograge::Formatters::GoogleCloudLogging.new
# ...
end
proxies = ENV.fetch("TRUSTED_PROXIES", "").split(",").map {|cidr| IPAddr.new(cidr) }
config.action_dispatch.trusted_proxies = proxies + ActionDispatch::RemoteIp::TRUSTED_PROXIES
では後々remote_ip
をとる上でロードバランサのIPがremote_ip
とされてしまわないように指定しています。config.action_dispatch.trusted_proxies
はArray
を渡すと完全に上書き、String
だと既存のものに追加されるので実は= ENV["LB_STATIC_IP"]
でもいいのですが、不安になる見た目をしているので明示的にデフォルトのものと合わせてArrayで指定しています。
TRUSTED_PROXIES
には$LOAD_BALANCER_STATIC_IP/32,130.211.0.0/22,35.191.0.0/16
のような値を設定しています。
130.211.0.0/22
と35.191.0.0/16
は自分が運用している環境では入ってきていませんが、外部 HTTP(S) 負荷分散の概要によると入ってくる可能性もあるので入れておいたほうがいいかと思います。
config.lograge.logger = ActiveSupport::Logger.new(STDOUT)
config.lograge.logger
を設定しないとRails.logger
が使われてしまうので必須です。
-
Rails.logger
には後でアプリケーションログを理想の構造化ログとして出力できるようにフォーマッタを指定します。なのでRails.logger
にmessageとして、logrageでフォーマットをしたものを渡すと入れ子のJSONになってしまい、アクセスログが理想の構造化ログになりません。 -
ActiveSupport::Logger
はデフォルトのフォーマッタとしてActiveSupport ::Logger::SimpleFormatter
が使われます。これは装飾なしにmessageを表示してくれるものです。logrageでフォーマットされたものがこのloggerに渡されるので、logger自体にはフォーマッタは指定しません。
config.lograge.custom_options
とconfig.lograge.custom_payload
ではhttpRequest
に必要になるものを集めています。
serverIp
は
The IP address (IPv4 or IPv6) of the origin server that the request was sent to.
https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#HttpRequest
とのことで、これでいいのか確信はもてていないのですがpodのプライベートIPにしています。マニフェストで以下のようにしています。
env:
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
参考: kubernetes - How to know a Pod's own IP address from inside a container in the Pod? - Stack Overflow
config.lograge.formatter = Lograge::Formatters::GoogleCloudLogging.new
ここで指定しているフォーマッタは
module Lograge
module Formatters
class GoogleCloudLogging
def call(payload)
{
severity: severity(payload.fetch(:status)),
timestamp: {
seconds: payload.fetch(:time).to_i,
nanos: payload.fetch(:time).nsec,
},
"logging.googleapis.com/trace": trace(payload.fetch(:request_id)),
httpRequest: http_request(payload),
forwarded_for: payload.fetch(:forwarded_for),
view_duration: payload[:view],
db_duration: payload[:db],
controller_and_action: "#{payload[:controller]}##{payload[:action]}",
}.to_json
end
def trace(request_id)
"projects/#{ENV["PROJECT_ID"]}/traces/#{request_id}"
end
# https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#HttpRequest
def http_request(payload)
{
requestMethod: payload.fetch(:method),
requestUrl: payload.fetch(:url),
status: payload.fetch(:status),
userAgent: payload.fetch(:user_agent),
remoteIp: payload.fetch(:remote_ip),
serverIp: payload.fetch(:server_ip),
referer: payload.fetch(:referer),
latency: "%0.6fs" % ((payload[:duration] || 0) / 1e3),
cacheLookup: false,
cacheHit: false,
cacheValidatedWithOriginServer: false,
protocol: payload.fetch(:protocol),
}
end
def severity(status)
case status
when 100..400 then "INFO"
when 400...500 then "WARNING"
else "ERROR"
end
end
end
end
end
余談ですが、ヘルスチェック用のリクエストをログに出したくない場合には
config.lograge.ignore_custom = lambda do |event|
event.name == "process_action.action_controller" &&
event.payload[:request].forwarded_for.blank? &&
event.payload[:request].user_agent.match?(/GoogleHC/)
end
などすればできます。
アプリケーションログ
loggerの設定をしていきます。
require_relative "../lib/logger/google_cloud_logging_formatter"
Rails.application.configure do
# ...
config.log_formatter = Logger::GoogleCloudLoggingFormatter.new
logger = ActiveSupport::Logger.new(STDERR)
logger.formatter = config.log_formatter
config.logger = logger
# ...
end
ポイントは概要で述べたように出力先がSTDERR
であることと、フォーマッターです。
class Logger::GoogleCloudLoggingFormatter < ::Logger::Formatter
KEY = :google_cloud_logging_formatter_trace_id
def call(level, time, program_name, message)
{
severity: severity(level),
timestamp: {
seconds: time.to_i,
nanos: time.nsec,
},
"logging.googleapis.com/trace": trace,
message: message,
}.to_json + "\n"
end
def self.set_thread_local_trace_id(id)
Thread.current[KEY] = id
end
private
def trace
"projects/#{ENV["PROJECT_ID"]}/traces/#{thread_local_trace_id}"
end
def thread_local_trace_id
Thread.current[KEY]
end
# https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity
# Logger::SEV_LABEL
def severity(level)
case level
when "WARN" then "WARNING"
when "FATAL" then "CRITICAL"
else level
end
end
end
リクエストIDはスレッドローカル変数に入れてみることにしました(Fiber Schedulerベースのサーバーになったら壊れる?)。
class ApplicationController < ActionController::Base
prepend_before_action do
Logger::GoogleCloudLoggingFormatter.set_thread_local_trace_id(request.request_id)
end
end
設定されるまでの間にRails.logger
があると間違ったグループ化がされてしまうので、setとclearするRackミドルウェアを書いて、ActionDispatch::RequestId
の直後に入れ方がいいかもしれないです。
Sidekiqのログ
Sidekiqもloggerのフォーマッタを指定することができます。
require_relative "../../lib/sidekiq/logger/formatters/google_cloud_logging"
Sidekiq.configure_server do |config|
# ...
config.log_formatter = Sidekiq::Logger::Formatters::GoogleCloudLogging.new
end
フォーマッタはこんな感じにしてみました。
module Sidekiq
class Logger
module Formatters
class GoogleCloudLogging < Base
def call(level, time, program_name, message)
hash = {
severity: severity(level),
timestamp: {
seconds: time.to_i,
nanos: time.nsec,
},
message: message,
pid: ::Process.pid,
tid: tid,
}
hash["logging.googleapis.com/trace"] = "projects/#{ENV["PROJECT_ID"]}/traces/#{ctx[:jid]}" if ctx[:jid]
hash["class"] = ctx[:class] if ctx[:class]
Sidekiq.dump_json(hash) << "\n"
end
private
# https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logseverity
# Logger::SEV_LABEL
def severity(level)
case level
when "WARN" then "WARNING"
when "FATAL" then "CRITICAL"
else level
end
end
end
end
end
end
おわりに
これでCloud Loggingのクエリで
resource.labels.container_name="$RAILS_SERVER_CONTAINER"
logName="projects/$PROJECT_NAME/logs/stdout"
などとすると、アクセスログが一覧でみれて、各アクセスログをクリックすると、そのリクエスト内でのアプリケーションログが一覧でみれます。
まだ長時間運用したわけではないので不十分な部分もあるかもしれないですが、それなりに調べることがあったので公開してみました。問題が見つかったら修正していきます。フィードバックをもらえると嬉しいです。
Discussion