ECSにデプロイしたDockerコンテナのログをfluentbitで出し分ける

前回の記事で、ECSにデプロイしたDockerコンテナからFireLensとfluentbitでログ出力ができるようになりました。

今回は、ログの出し分けについて説明します。

出し分けるログ

ログはExpress.jsへのアクセスログと、アプリケーション内からのアプリケーションログの2つを想定します。
まずは、それぞれのログの出し方です。

アクセスログ

Express.jsのアプリのアクセスログは、Morganというパッケージを使うと簡単に出力できます。

まずは、morganをインストールします。

npm i --save morgan
npm i --save-dev @types/morgan

アプリのindex.tsに下記のように記述します。

import 'dotenv/config'
import http from 'http'
import morgan from 'morgan' // この行を追記
import corm from 'cors'
import express from 'express'

// ログの出力フォーマット(これはWebサーバのアクセスログ風)
const morganFormat = '[ACCESS] :remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"'    

const app: express.Express = express()
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use(morgan(morganFormat)) // この行を追記
app.use(cors())

const server: http.Server = http.createServer(app)
server.listen(process.env.PORT, () => {
    console.log(`Start on port ${process.env['PORT']}.`)
})

これでアクセスログが出力されるようになります。
先頭に[ACCESS]と付けているのは、後でログを出し分けるためです。

アプリケーションログ

アプリケーションログは、log4jsを使って出力すると良いでしょう。

まずはパッケージのインストールを行います。

npm i --save log4js
npm i --save-dev @types/log4js

index.tsと同じフォルダに、logger.tsを作成し、下記のようにします。

import * as log4js from 'log4js'

log4js.configure({
    appenders: {
        out: {
            type: 'stdout',
            layout: {
                type: 'pattern',
                pattern: '[%p] %c - %m'
            }
        }
    },
    categories: {
        default: { appenders: ['out'], level: 'debug' }
    }
})

export const logger = log4js.getLogger()

ここでエクスポートしたloggerを、ログを出したい箇所でインポートしてログ出力します。
例えば、index.tsの中で・・・(追記箇所のみ)

import { logger } from './logger'

const server: http.Server = http.createServer(app)
server.listen(process.env.PORT, () => {
    logger.info(`Start on port ${process.env['PORT']}.`) // この行を追記
})

このようにすると、ログレベルに合わせてログが出力されます。また、logger.tsではログレベルをログの先頭に出力するようにしているので、この例では[INFO]という文字列になります。

ログの出し分け

いよいよログの出し分けです。前の記事ではCloudWatchにのみログを出していますが、今回はアクセスログはS3、アプリケーションログはCloudWatchという風にしてみましょう。

FireLensからS3にログを出力する際に、Kinesys Firehoseを使用します。

CDKのlib/cdk-stack.tsを下記のようにします。(関連箇所のみ)
ログ出力用のS3バケットの作成と、Firehoseのストリーム作成が重要です。コンテナから出力されるログとFirehoseストリームの紐付けはここでは行わず、次にextra.confで行います。

// タスク定義
const taskDefinition = new ecs.FargateTaskDefinition(this, 'MyTaskDefinition', {
  runtimePlatform: {
    operatingSystemFamily: ecs.OperatingSystemFamily.LINUX,
    cpuArchitecture: ecs.CpuArchitecture.X86_64,
  }
})

// ログ出力用のS3バケットの作成
const logBucket = new s3.Bucket(this, 'MyLogBucket', {
  bucketName: '<ログを出力するバケット名>',
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true
})

// Firehose用のロール定義
const firehoseRole = new iam.Role(this, 'FirehoseRole', {
  assumedBy: new iam.ServicePrincipal('firehose.amazonaws.com')
})

firehoseRole.addToPolicy(new iam.PolicyStatement({
  actions: [
    's3:PutObject',
    's3:GetObject',
    'a3:ListBucket'
  ],
  resources: [
    logBucket.bucketArn,
    `${logBucket.bucketArn}/*`
  ]
}))

// S3へのログ出力用のストリーム作成
const stream = new firehose.CfnDeliveryStream(this, 'MyDeliveryStream', {
  s3DestinationConfiguration: {
    bucketArn: logBucket.bucketArn,
    roleArn: firehoseRole.roleArn
  },
  deliveryStreamName: 'helloecs-stream'
})

// 前の記事で説明したconfファイルの読み込み
const parserConfAsset = new assets.Asset(this, 'ParserConfAsset', {
  path: path.join(__dirname, 'parser.conf')
})
const extraConfAsset = new assets.Asset(this, 'ExtraConfAsset', {
  path: path.join(__dirname, 'extra.conf')
})

// この辺は前の記事と同様
taskDefinition.addFirelensLogRouter('MyLogRouter', {
  firelensConfig: {
    type: ecs.FirelensLogRouterType.FLUENTBIT,
  },
  environment: {
    aws_fluent_bit_init_s3_1: `arn:aws:s3:::${parserConfAsset.s3BucketName}/${parserConfAsset.s3ObjectKey}`,
    aws_fluent_bit_init_s3_2: `arn:aws:s3:::${extraConfAsset.s3BucketName}/${extraConfAsset.s3ObjectKey}`,
  },
  image: ecs.ContainerImage.fromRegistry(
    'public.ecr.aws/aws-observability/aws-for-fluent-bit:init-latest'
  ),
  logging: ecs.LogDriver.awsLogs({
    streamPrefix: 'log-router'
  })
})

taskDefinition.defaultContainer = taskDefinition.addContainer('MyAppContainer', {
  image: ecs.ContainerImage.fromEcrRepository(repository, versionTag),
  logging: ecs.LogDrivers.firelens({
    options: {}
  }),
  portMappings: [
    {
      containerPort: 3000,
    }
  ],
})

// ログ出力用のポリシー定義
const logPolicyStatement = new iam.PolicyStatement({
  actions: [
    "logs:CreateLogStream",
    "logs:CreateLogGroup",
    "logs:DescribeLogStreams",
    "logs:PutLogEvents",
    "s3:GetObject",
    "s3:GetBucketLocation",
    "firehose:PutRecordBatch",
  ],
  resources: ["*"],
  effect: iam.Effect.ALLOW,
});
taskDefinition.addToTaskRolePolicy(logPolicyStatement);

次に、extra.confです。

[SERVICE]
    Parsers_File    parser.conf

[FILTER]
    Name            parser
    Match           *-firelens-*
    Key_Name        log
    Parser          json
    Preserve_Key    false
    Reserve_Data    true

[FILTER]
    Name            rewrite_tag
    Match           *-firelens-*
    Rule            $log ^\[(DEBUG|INFO|WARN|ERROR)\] APPLOG false
    Emitter_Name    app_logs

[FILTER]
    Name            rewrite_tag
    Match           *-firelens-*
    Rule            $log ^\[ACCESS\] ACCESSLOG false
    Emitter_Name    access_logs

[OUTPUT]
    Name                cloudwatch
    Match               APPLOG
    region              ap-northeast-1
    log_group_name      /hello/ecs
    log_stream_prefix   ecs-fluentbit-
    auto_create_group   true

[OUTPUT]
    Name                firehose
    Match               ACCESSLOG
    region              ap-northeast-1
    delivery_stream     helloecs-stream

コンテナの標準出力の内容(アクセスログやアプリケーションログ)は、FireLens(fluentbit)で取り込まれます。
その際、-firelens-というタグが自動的に付与されるので、それにマッチするログを処理します。

ログはJSON形式でやってきて、アクセスログやアプリケーションログの文字列は$logに格納されています。
その$logの文字列の先頭の値([INFO][ACCESS]など)を正規表現でマッチさせ、APPLOGやACCESSLOGというタグに置き換えます(rewrite_tagフィルタ)。

後は、2つの[OUTPUT]の設定でAPPLOGやACCESSLOGというタグをマッチさせ、CloudWatchまたはFirehose(経由でS3)のそれぞれに処理させるというわけです。
先ほど、cdk-stack.tsで作成したFirehoseストリームとの紐付けは、delivery_streamの設定値で行います。

これでログの出し分けができるようになりました。

この記事を書いた人

井上 研一

株式会社ビビンコ代表取締役、ITエンジニア/経済産業省推進資格ITコーディネータ。AI・IoTに強いITコーディネータとして活動。画像認識モデルを活用したアプリや、生成AIを業務に組み込むためのサービス「Gen2Go」の開発などを行っている。近著に「使ってわかった AWSのAI」、「ワトソンで体感する人工知能」。日本全国でセミナー・研修講師としての登壇も多数。