EKSクラスタにCloudWatchによるログ管理を導入

## 概要

AWSのマネージドKubernetesクラスタであるEKS上でRails製Webアプリケーションを動かし、クラスタの監視とアプリケーションのログをCloudWatchで一元管理できるようにします。 

クラスタの監視にはContainer Insightsを利用し、アプリケーションのログ収集には[fluentd-kubernetes-daemonset](https://github.com/fluent/fluentd-kubernetes-daemonset )を利用します。

Kubernetesのバージョン1.12以上が必要です。

それより古いバージョンを使っていたとしても、EKSを使っていればボタンをポチポチするだけで簡単にアップデートができます。

## 手順

### 1. KubernetesワーカーノードのEC2インスタンスのポリシー設定

まずはKubernetesのワーカーからCloudWatchへログを送信できるようにIAMの設定を行います。

ワーカーのロールにCloudWatchAgentServerPolicyをアタッチするだけです。

### 2. Container Insightsの導入

まずはクラスタの監視を行うContainer Insightsを導入します。

手順としては、CloudWatch用のネームスペース・サービスアカウント・ConfigMapを作成し、Container Insightsのデーモンセットをデプロイします。

https://docs.amazonaws.cn/en_us/AmazonCloudWatch/latest/monitoring/Container-Insights-setup-metrics.html

```bash
$ kubectl apply -f https://raw.githubusercontent.com/aws-samples/amazon-cloudwatch-container-insights/master/k8s-yaml-templates/cloudwatch-namespace.yaml
$ kubectl apply -f https://raw.githubusercontent.com/aws-samples/amazon-cloudwatch-container-insights/master/k8s-yaml-templates/cwagent-kubernetes-monitoring/cwagent-serviceaccount.yaml
$ curl -O https://raw.githubusercontent.com/aws-samples/amazon-cloudwatch-container-insights/master/k8s-yaml-templates/cwagent-kubernetes-monitoring/cwagent-configmap.yaml
$ micro cwagent-configmap.yaml # エディタはなんでもいいが、cluster_namesをContainer Insightsを追加するクラスタ名で埋める
$ kubectl apply -f cwagent-configmap.yaml
```

これだけでCloudWatchにいろいろログが送られるようになります。また、自動ダッシュボードであらかた見たい指標は見られるようにもなります。

では次にRailsのアプリケーションログをCloudWatchに送れるようにしましょう。

## 3. Railsアプリ側のログ出力設定

fluentd-kubernetes-daemonsetは、各Podの標準出力を勝手にCloudwatchに送信してくれます。

というわけで、Railsアプリケーションのすべてのログをファイルではなく標準出力に出すように変更。 config/{{environment}}.rb と config/puma.rb を編集します。

Rails5だと、production.logに以下のような記述があるので環境変数をセットすればそれで終わりです。

```ruby
  if ENV["RAILS_LOG_TO_STDOUT"].present?
    stdout_logger           = ActiveSupport::Logger.new(STDOUT)
    stdout_logger.formatter = config.log_formatter
    multiple_loggers = ActiveSupport::Logger.broadcast(stdout_logger)
    logger.extend(multiple_loggers)
  end
```

pumaのほうも、stdout_redirectの設定があればその行を消すだけです。

## 4. fluentd-kubernetes-daemonsetのデプロイ

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/Container-Insights-setup-logs.html

すでにCloudWatch用のネームスペースは追加してあるので、次のコマンドを実行するだけです。

```bash
$ curl -O https://s3.amazonaws.com/cloudwatch-agent-k8s-yamls/fluentd/fluentd.yml
// cluster_nameとregion_nameは適宜変える
$ kubectl create configmap cluster-info --from-literal=cluster.name=cluster_name --from-literal=logs.region=region_name -n amazon-cloudwatch
```

これだけでアプリケーションのログがCloudWatchに転送されます。

これだけでもいいという場合はここまででOKです。

## 5. ログのフォーマット設定

Railsの出力するログはそのままだと複数行で出力されるので、CloudWatch上では各行が別々のログとして扱われてしまいます。

そこで、複数行で出力するのではなくJSON形式で一行のログとして出力するようにします。

JSON形式になっていると、CloudWatch上でいい感じに検索ができるようになるなどいいことづくめです。

logrageを使いjson形式でリクエストログを出すように変更します。Gemfileにlogrageを追加して、bundle installしておきましょう。

```ruby:production.rb
Rails.application.configure do
  config.lograge.enabled = true
  config.lograge.formatter = Lograge::Formatters::Json.new
  config.lograge.custom_options = Proc.new do |event|
    exceptions = %w(controller action format id)
    {
      time: event.time,
      host: event.payload[:host],
      remote_ip: event.payload[:remote_ip],
      params: event.payload[:params].except(*exceptions),
      exception_object: event.payload[:exception_object],
      exception: event.payload[:exception],
      backtrace: event.payload[:exception_object].try(:backtrace),
    }.compact
  end
  
  ...
end
```



```ruby:application_controller.rb
class ApplicationController < ActionController::Base
  def append_info_to_payload(payload)
    super
    payload[:user_agent] ||= request.user_agent
    payload[:request_id] ||= request.request_id

    if @exception.present?
      payload[:exception_object] ||= @exception
      payload[:exception] ||= [@exception.class, @exception.message]
    end
  end
end
```

またこれだけだと、ActionControllerの外側、ActionDispatchレベルで起きるRoutingErrorに対応できないので次のようなモンキーパッチを追加します

```ruby:config/initializers/patches/logger.rb
if defined? Lograge
  class ActionDispatch::DebugExceptions
    alias_method :org_log_error, :log_error
    def log_error(request, wrapper)
      msg = {
        exception: wrapper.exception,
        backtrace: wrapper.exception.try(:backtrace),
        raw_request: request,
        raw_wrapper: wrapper
      }

      Rails.logger.fatal(msg.to_json)
    end
  end
end
```

しかし、このままだとせっかくJSON形式で出力したRails側のログはエスケープされたただの文字列になっています。

そこで、fluentdの設定も変えておきます。

さきほどの記事でダウンロードしたfluend.ymlに、以下の記述を追加して、json形式のログをパースするようにしておきます。こうすると、 parsed_log というキーでパースされたログが記録されるようになります。

```yaml:fluentd.yml
  <filter **>
    @type parser
    format json
    key_name log
    reserve_time true
    reserve_data true
    emit_invalid_record_to_error false
    hash_value_field parsed_log
  </filter>
```

追加する箇所は

https://s3.amazonaws.com/cloudwatch-agent-k8s-yamls/fluentd/fluentd.yml

の125行目あたり。そこに上記設定を追記して適用します。

 

また、ログストリーム名も標準だとポッド名になってしまい、ポッドが変わると別のログストリームになってしまいます。

個人的にはログストリームはポッドが変わっても同じログストリームにログが出てほしいので、ラベルから取るようにします。

```yaml:fluentd.yml
  <filter **>
    @type record_transformer
    @id filter_containers_stream_transformer
    enable_ruby # 追加
    <record>
      stream_name ${record["kubernetes"]["labels"]["app"]}
    </record>
  </filter>
```

将来的には、このログが出力したアプリケーションの最終コミットIDもわかったりすると便利そうだなぁと思います。

## まとめ

これでCloudWatchでクラスタの監視とアプリケーションログの集約ができるようになりました。