Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

ECSのログ管理にFirelensを導入してみた

はじめに

こんにちは。SRE部の板谷(@SItaya5)です。

Gunosyでは様々なプロダクトでECS(Amazon Elastic Container Service)を使用してタスクを実行しています。 ECSの起動タイプにはEC2とFargateの2種類がありますが、どちらのタイプも混在しています。

ログの送信先としては、主にPapertrailというサービスを使用しています。 しかし、Fargateで実行しているタスクに関しては、ログの送信先(Log Driver)に選択の余地がなく、Cloudwatch Logs一択でした。 そのため、EC2で実行しているタスクのログはPapertrailに送信されていましたが、一方のFargateで実行しているタスクはCloudwatch Logsに送信せざるを得ませんでした。 このように起動タイプ毎にログの送信先が分かれており、管理が煩雑になっていました。 また、それぞれのログ設定方法に互換性が無いという問題もありました。

昨年AWSよりFirelensがリリースされ、ECSのタスクにおけるログ制御に関して柔軟な設定ができるようになりました。 特にFargateにおいてFirelensを使用することで、Cloudwatch Logsのようなマネージドサービスだけでなく、Papertrailを始めとした外部のサービスにもログを送信できるようになりました。 また、EC2・Farageteどちらのプラットフォームを使用していても設定方法の違いがありません。 これにより、起動タイプに依存せず、同じ方法でログの管理・設定ができるようになり、上述の問題を解決することができました。

本記事ではFirelensの導入方法や、導入にあたって得られた知見をまとめます。

aws.amazon.com

Firelensとは

ECSのTask Definitionで設定されるLog Driverの一種です。 使用するためには、FluentdまたはFluent Bitをサイドカーコンテナとしてタスクに定義します。 Firelensを有効にすることでFluentdやFluent Bitの設定ファイル(fluent.conf、fluent-bit.conf )が内部で生成され、それを基にログが処理されます。

Fluentd・Fluent BitのDocker Imageは、必要なプラグインを内包して自作したカスタムイメージも使用できます。 弊社ではPapertrailを使用していたため、Papertrailのプラグインが存在するFluentdを採用し、プラグインを含めたカスタムイメージを作成しました。

尚、Fluent Bitに関しては、AWSからFirelens向けのDocker Imageが提供されています。 このイメージには、Cloudwatch LogsとFirehose用のプラグインが内包されています。

設定方法

マネジメントコンソールからの設定方法を記載します。 jsonの記載方法に関してはこちらが参考になります。

Firelensの有効化

タスク定義画面の下部にあるチェックボックスを有効にし、FluendまたはFluent Bitの選択とDocker Imageの指定を行います。

有効にすると、log_routerという名前のコンテナがタスク定義に追加されます。

Log Coofigurationの設定

対象のコンテナのLog Configurationにawsfirelensを指定します。

optionsにはPluginの名前と、そのプラグインに必要なパラメータを設定します。これらは出力設定の生成に使用されます。 また、include-patternexclude-patternというキーを指定することで正規表現を用いたフィルタリングを設定することも可能です。詳しくはこちらを参照してください。

log_router自身のログを保管する場合には、Log Driverにawsfirelens以外のものを指定します。

log_routerコンテナにFirelens Configurationを設定

Firelens ConfigurationはFirelensを有効にした時点で設定されていますが、以下の場合には更新が必要です。

  • カスタム設定ファイルを読み込ませる場合

FirelensではFluentd・Fluent Bitの設定ファイルを内部で自動生成(後述)しますが、カスタムファイルを指定することでそのファイルの内容を自動生成されるファイルにincludeすることができます。 カスタムファイルの場所はlog_routerコンテナ内、EC2で稼働しているタスクの場合はS3も選択可能です。S3を選択する場合には、タスク実行ロールにs3:GetObjects3:GetBucketLocationを許可する必要があります。

  • フィールドにECSのメタデータを含めない場合

enable-ecs-log-metadataというパラメータで、log_routerコンテナに以下のECSメタデータを追加するかを設定できます(デフォルトではtrue)。

  ecs_cluster : ECSクラスタの名前(Firelensだとarn)
  ecs_task_arn : Task Definitionのarn
  ecs_task_definition : Task Definitionの名前とリビジョン
  ec2_instance_id  : EC2のID(EC2でタスクを実行している場合のみ)

弊社で使用しているFluentdではPapertrailの画面上でECSのクラスタ名などが確認できるよう、これらのメタデータをカスタム設定ファイルで以下のように使用しています。

<filter **>
  @type record_transformer
  enable_ruby
  <record>
    hostname ${record['ecs_cluster'].to_s.split("/").last}${record['ec2_instance_id'].nil? ? "" : ".#{record['ec2_instance_id']}"}
    program ${record['ecs_task_definition'].to_s.sub(/:/, "-")}.${tag.sub(/-firelens/, "")}
  </record>
</filter>

Firelens Configurationの設定は、執筆時点(2020/6/1)でマネジメントコンソールからの更新はできないので、JSONを直接編集します。 以下の設定例を参考にしてください。

"firelensConfiguration":{
            "type":"fluentd",
            "options":{
               "config-file-type":"file",
               "config-file-value":"/fluentd/etc/custom-filter.conf"
            }
}

依存関係の設定

必須ではありませんが、log_routerコンテナが先に起動するような設定をしておいた方がベターでしょう。起動時にエラーが発生した場合、確実にログが見れるようにするためです。

各コンテナに以下のような設定をします。

"dependsOn": [
    {
        "containerName": "log_router",
        "condition": "START"
    }
]

設定ファイルの自動生成

上述の通り、FirelensにはFluentdまたはFlunet Bitの設定ファイルを自動生成する仕組みが備わっています。 最後にその仕組みについてみてみましょう。

自動生成される設定ファイルの内容に関して、影響を与える設定は以下の3種類がありました。

  1. Log Optionsへの記載
  2. Firelens Conifgurationのoptionsで指定したカスタム設定ファイルの記載
  3. ECSメタデータの有無

これらがどのように取り込まれていくのかをECS Agentのソースコードから確認してみます。 github.com 設定ファイルの内容を生成しているのは、firelensconfig_unix.goに記載されているgenerateConfigというメソッドです。

1. Log Optionsへの記載

まず、Log Optionsへの記載に関しては、以下の部分で設定がされています。

// Specify log stream output. Each container that uses the firelens container to stream logs
// may have its own output section with options, constructed from container's log options.
for containerName, logOptions := range firelens.containerToLogOptions {
    tag := fmt.Sprintf(fluentTagOutputFormat, containerName, matchAnyWildcard) // Each output section is distinguished by a tag specific to a container.
    newConfig, err := addOutputSection(tag, firelens.firelensConfigType, logOptions, config)
    if err != nil {
        return nil, fmt.Errorf("unable to apply log options of container %s to firelens config: %v", containerName, err)
    }
    config = newConfig
}

記載内容はlogOptionsという変数に設定されており、addOutputSectionメソッドに渡すことで設定ファイルの内容が生成されています。 addOutputSectionは同じファイルで定義されており、Log Optionsのkeyによって処理が分けられています。

for key, value := range logOptions {
    switch key {
    case outputKey:
        continue
    case includePatternKey:
        config.AddIncludeFilter(value, "log", tag)
    case excludePatternKey:
        config.AddExcludeFilter(value, "log", tag)
    default: // This is a plugin specific option.
        outputOptions[key] = value
    }
}

~~~~~~~~~~
省略
~~~~~~~~~~

// Output key is specified. Add an output section.
config.AddOutput(output, tag, outputOptions)
return config, nil

これらの場合分けは、以下のように区別されています。

  • outputKeyはoutputプラグインの名前で、Fluendの場合は@type、Fluent Bitの場合はNameというキーで指定されます。
  • includePatternKeyとexcludePatternKeyはそれぞれinclude-patternexclude-patternキーで指定されたフィルタリングの設定です。
  • defaultはプラグイン毎に必要なその他のキー全てが該当します。

これらのパラメータは、AddIncludeFilterAddExcludeFilterAddOutputというメソッドに渡されているのが分かります。

2. カスタム設定ファイルの記載

カスタム設定ファイルを指定した場合は以下のように読み込まれます。

// Include external config file if specified.
if firelens.externalConfigType == ExternalConfigTypeFile {
    config.AddExternalConfig(firelens.externalConfigValue, generator.AfterFilters)
} else if firelens.externalConfigType == ExternalConfigTypeS3 {
    var s3ConfPath string
    if firelens.firelensConfigType == FirelensConfigTypeFluentd {
        s3ConfPath = S3ConfigPathFluentd
    } else {
        s3ConfPath = S3ConfigPathFluentbit
    }
    config.AddExternalConfig(s3ConfPath, generator.AfterFilters)
}
seelog.Infof("Included external firelens config file at: %s", firelens.externalConfigValue)

return config, nil

まずはFIrelensConfigurationのconfig-file-typefile(コンテナ内)とs3のどちらが指定されているのかを判定しています。 その後にconfig-file-valueで指定されていたパスをAddExternalConfigというメソッドに渡しています。

3.ECSメタデータの有無

最後にenable-ecs-log-metadataパラメータの設定によってECSのメタデータが取り込まれるのは以下の部分です。

if firelens.ecsMetadataEnabled {
    // Add ecs metadata fields to the log stream.
    config.AddFieldToRecord("ecs_cluster", firelens.cluster, matchAnyWildcard).
        AddFieldToRecord("ecs_task_arn", firelens.taskARN, matchAnyWildcard).
        AddFieldToRecord("ecs_task_definition", firelens.taskDefinition, matchAnyWildcard)
    if firelens.ec2InstanceID != "" {
        config.AddFieldToRecord("ec2_instance_id", firelens.ec2InstanceID, matchAnyWildcard)
    }
}

パラメータの設定内容はecsMetadataEnabledという変数に設定されています。trueの場合にAddFieldToRecordに各メタデータを渡しています。

設定ファイル生成の実装

ここまでで設定ファイルを生成するために5つのメソッドが呼ばれました。

  • AddIncludeFilter
  • AddExcludeFilter
  • AddOutput
  • AddExternalConfig
  • AddFieldToRecord

これらはawslabs/go-config-generator-for-fluentd-and-fluentbitリポジトリのgenerator.goで定義されています。 github.com また、同じリポジトリに、fluentd-template.gofluent-bit-template.go というファイルが用意されており、これらはそれぞれの設定ファイルのテンプレートです。 上記5つのメソッドでは、このテンプレートファイルのどこにパラメータを挿入するかを指定しています。

それではgenerator.goを見てみましょう。 このファイルでは、FluentConfigという、設定ファイルを生成するためのインターフェイスが定義されています。 同時にFluentConfigGeneratorという構造体が定義されており、上記5つのメソッドは全てFluentConfigGeneratorのメソッドです。

この構造体には、以下のようなフィールドが定義されています。

type FluentConfigGenerator struct {
    Inputs                    []LogPipe
    ModifyRecords             map[string]RecordModifier
    IncludeFilters            []RegexFilter
    ExcludeFilters            []RegexFilter
    IncludeConfigHeadOfFile   []string
    IncludeConfigAfterInputs  []string
    IncludeConfigAfterFilters []string
    IncludeConfigEndOfFile    []string
    Outputs                   []LogPipe
}

これらのフィールドは、テンプレートファイルから呼び出されます。要は、これらのフィールドに値を代入することによって、生成される設定ファイルの内容を制御しています。

例えばLog Optionsに指定したプラグインのパラメータは、AddOutput内でconfig.Outputsに設定されます。

func (config *FluentConfigGenerator) AddOutput(name string, tag string, options map[string]string) FluentConfig {
    config.Outputs = append(config.Outputs, LogPipe{
        Name:    name,
        Tag:     tag,
        Options: options,
    })
    return config
}

fluentd-template.goをみてみると以下の部分から呼び出されています。

{{- range .Outputs }}
<match {{ .Tag }}>
    @type {{ .Name }}
    {{- range $key, $value := .Options }}
    {{ $key }} {{ $value }}
    {{- end }}
</match>
{{ end -}}

Log Optionsに指定した値は設定ファイルのmatchタグ内に埋め込まれ、それぞれのパラメータ(Name、Tag、Options)が然るべき場所に呼び込まれていることが分かります。

実際に生成されるファイルを確認してみると、該当箇所は以下の通りなっています(Tagはfirelensconfig_unix.go の中でコンテナ名を基に設定されています)。

<match container-name-firelens**>
    @type papertrail
    papertrail_host "logsX.papertrailapp.com"
    papertrail_port XXXXX
</match>

まとめ

この記事ではFirelensの概要や設定方法、そしてFluentdの設定ファイルがどのように生成されているかを確認しました。

Firelensがリリースされたことで、ECS上の全てのタスクに同じログ管理方法を適用することができ、起動タイプに依らず1つのサービスにログを集約できるようになりました。 また、互換性が保たれることで開発者の負担を減らすこともできました。

参考