一度前回作ったdockerコンテナ上で動かしているアプリケーションがコケた。失敗通知はSlackに飛ぶため確認可能だが、ログは保存していないため失敗原因が確認できなかった。アプリケーションを手元で実行したが再現できず、改修ポイントがわからなかった。まーwebスクレイピングのアプリケーションなので失敗したら手動でやれば良いやと思ってログを保存しておかない判断したのはそうなのだが、やっぱ失敗するとログを確認したくなるので保存する。 なんか流行ってるらしいGrafanaで可視化する方針とした。エラーメッセージを見るのでなんかグラフとか出すわけでは無いけど。。。とりあえず流行ってるらしいからGrafanaを使う。ログの保存は親和性高そうだったため、Grafana作成元が出しているLokiにした。 Lokiにタイムスタンプ、ログ本体、検索用ラベルを合わせたJsonを投げると保存してくれる。GrafanaはLokiに保存されたラベルをキーにログを検索できる。結果aws Cloudwatch Logsっぽいものが作れた。

完成した画面

grafana-explore-loki

概要

  • システム構成の選定
    • ログシステムとアプリケーションシステムの通信構成
    • ログ転送方法
  • システム構築
  • Grafanaでログ確認

ログシステムとアプリケーションシステムの通信構成

アプリケーションシステムはDocker上のコンテナで動かしているため、ログシステムも同様にDocker上で動かすことにする。商用環境のようにホストを分けて構築するのもありかと思ったが、お金に余裕は無いためNASと利用しているUbuntuサーバに立ち上げたDocker上で完結させる(アプリケーションとログサーバを同一Docker上で動作させる)ことにした。ただ問題はログ管理システムとアプリケーションシステムは別ライフサイクル、ログ管理は半永続的に起動しておりアプリケーションはバッチであるため処理終了後に消したい。したがって別のDockerfile(compose)で管理するため、ネットワークが別である。

問題

どうやってアプリケーションログをログ管理システム側に渡すか。候補は下記3択

  • 案① ファイル経由
  • ネットワーク経由
    • 案② ホスト IP経由
    • 案③ コンテナネットワーク完結

案① ファイル経由

共通のディレクトリをマウントしておきアプリケーションが吐いたログをログ管理システムが吸い上げる

  • Good
    • コンテナ環境であることを意識しなくて良い
    • 外部への通信が発生しないためセキュア
  • Bad
    • ログ書き込み完了を待ってアプリケーションを終了する必要がある
    • アプリケーションが増えた場合ディレクトリをうまいこと切っていかないといけないのが面倒そう
    • コンテナに不揮発性な環境を作成するのがなんか嫌

ネットワーク経由

dcoker-network
案② ホスト IP経由

コンテナ環境は特に意識せず外部サーバに投げる たまたま送信先の外部サーバが同じホスト内で動作している

  • Good
    • ネットワークレベルでアプリケーションが分離される
    • コンテナ環境であることを意識しなくて良い
  • Bad
    • 外部ネットワークからLogを受信することと同意であるため、ファイヤーウォールにて通信を許可する必要がある
    • 一度ホストから出るため障害点が他と比べて多い
    • コンテナがホストのIPアドレスを何らかの方法で知る必要がある
      • ルータ、サーバ特に何もしていないため再起動か何かのタイミングでIPが変わる可能性があるため、動的に取得する(もしくは固定する)方法を検討する必要がある。
案③ コンテナネットワーク完結

ログ転送用共有コンテナネットワークを作成し、アプリケーション及びログシステム両方のコンテナを参加させる。

  • Good
    • 外部からの通信を許可しないためセキュア
    • (管理しているdocker-compose.ymlは別だが)docker-compose.ymlに定義したホスト名で通信可能
  • Bad
    • Dockerに依存した構成
    • ログ管理システムとアプリケーションのネットワークを分離できない

結論

動いているのはただの宅内趣味サーバであるため、セキュアであることが第一優先。コンテナネットワーク完結で構築することにした。副作用はログを保存したい場合、今後全てのアプリケーションはログ転送用共有コンテナネットワークに参加する必要がある。よく知らないが割当可能なネットワークCIDRが決まっているならば、スケールできない可能性がある。

ログ転送方法

可視化はGrafana、ログ保存はLokiと決まっているがログ転送を誰が担うかが決まっていない。アプリケーションログをLokiが受け取れるJsonフォーマットに誰が整形するのか、そして誰がLokiへ転送するのかを決める。候補は下記3択

  • 案① アプリケーション自身が整形・転送を実施
  • 案② アプリケーション側にfluent bitを導入し、整形・転送を実施
  • 案③ Dockerが提供するロギング・ドライバにてアプリケーションログをログ管理システムへ転送。ログ管理システムのfluent bitが整形し、Lokiへ転送
案① アプリケーション自身が整形・転送を実施
  • Good
    • ログ保存システムの構成がシンプル
  • Bad
    • アプリケーションにログ処理をさせるのは責務が分離できていない
    • アプリケーション側の責務が増えるためスケールしづらい
    • 転送管理がDocker管理のレイヤでは無いため、コンテナネットワーク完結構成の利点が活かせない
案② アプリケーション側にfluent bitを導入し、整形・転送を実施
  • Good
    • 責務が分離できる
    • スケールし易い
    • 責務は分離されているがアプリケーションと整形・転送FluentBitが近いため、ラベル管理が楽になるかもしれない
  • Bad
    • fluent bitがファイル経由でアプリケーションとログのやり取りを実施することになるため、却下したファイル経由構成になってしまう。
案③ Dockerが提供するロギング・ドライバにてアプリケーションログをログ管理システムへ転送。ログ管理システムのfluent bitが整形し、Lokiへ転送
  • Good
    • 責務が分離できる
    • スケールし易い
    • アプリケーションのログ転送をDockerの機能を使用するためコンテナネットワーク完結構成と親和性が高い
  • Bad
    • Dockerに依存した構成
    • ただアプリケーションのログを転送するロギング・ドライバ、転送されたログを整形・Lokiへ転送するFluentBitとコンポーネントが多いため、障害点が多い
    • ログ管理システムのFluentBitが全アプリケーションログのラベルを管理する必要があるため、複雑になるかもしれない

結論

log-transfer

コンテナネットワーク完結と親和性が高く、責務を分離できる案③を採用。副作用として、ログ管理システムのFluentBitが受け取ったログを見て適切なラベルを付与する必要があるため、作り込みが必要である。

システム構築

構成は決まったため、実際に構成する

  • Docker
    • ログ転送用共有コンテナネットワーク作成
  • アプリケーション
    • Dockerロギング・ドライバ導入
  • ログ管理システム
    • FluentBit導入
    • Loki導入
    • Grafana導入

Docker: ログ転送用共有コンテナネットワーク作成

ログ転送用共有コンテナネットワーク作成する。このネットワークの管理をどうするか3択ある。

  • 案① アプリケーションのdocker-compose.ymlで作成
  • 案② ログ管理システムのdocker-compose.ymlで作成
  • 案③ どちらでも管理しない。手動で(docker network createコマンドで)作成
案① アプリケーションのdocker-compose.ymlで作成
  • Good
    • 無い
  • Bad
    • ログ転送用共有コンテナネットワークをアプリケーション側で管理するのは不自然
案② ログ管理システムのdocker-compose.ymlで作成
  • Good
    • ログ転送用共有コンテナネットワークをログ管理システム側で管理するのは自然
    • 責務分離が綺麗
  • Bad
    • はじめにログ管理システムを作成しなければならない制約が生まれる
案③ どちらでも管理しない。手動で(docker network createコマンドで)作成
  • Good
    • 一度だけoneコマンド実行すれば、後は気にしなくて良いため運用が楽
  • Bad
    • どの設定ファイルにも残らないため、管理が面倒

結論

当初は責務が綺麗な案②で対応を進めていた。だたログ管理システムのdocker compose buildを実行するたびに作成したログ転送用共有コンテナネットワークのIDが変わるため、build済みのアプリケーションが共有コンテナネットワークを見つけられず下記エラーとなってしまった。

% docker compose -f etc/docker/docker-compose.yml up  
WARN[0000] Found orphan containers ([fluent-bit loki]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up. 
Attaching to radiko-downloader-1
Error response from daemon: failed to set up container networking: network 91232e6a16dfb27765a9aa3c906a6da1289945af85aa7aac7aa4cd7229a36f26 not found

案②はログ管理システムの変更がアプリケーション側に波及してしまう構成であったため、消去法で案③で構築することにした。管理負担を少しでも下げるため、ログ管理システムのREADME.mdに初期構築手順として残すこととした。

ログ転送用共有コンテナネットワーク作成

% docker network create shared-log-network

アプリケーション: Dockerロギング・ドライバ導入

元々dockerfile直起動であったためdocker-composeを作成した。追加するのはDockerロギング・ドライバを設定するlogging項目と、すでに作成済みネットワークに参加するためnetworks項目を追加した。Dockerロギング・ドライバがコンテナ名fluent-bit、port24224宛に、sample-appの標準出力情報を転送する設定である。また、どのアプリケーションが送付したログが判断できるようにするためtagを追加している。FluentBitがLokiに転送する際、このtagをラベル情報として転送させる予定。

docker-compose.yml

services:
  sample-app:
    build:
      context: ../..
      dockerfile: etc/docker/Dockerfile
    image: docker-sample-app
    networks:
      - shared-log-network
    logging:
      driver: fluentd
      options:
        fluentd-address: fluent-bit:24224
        tag: sample-app

networks:
  shared-log-network:
    external: true

ログ管理システム: FluentBit導入

やることは下記2つ

  • Dockerロギング・ドライバから転送を受け付ける
  • container nameをタグに設定する
Dockerロギング・ドライバから転送を受け付ける

アプリケーションと同じように作成済みネットワークに参加させる。アプリケーション側で転送先にコンテナ名fluent-bitを指定しているためcontainer_nameに記載、転送先portも合わせて書いておく。設定ファイルをdocker compose側で差し込んでもよいし、dockerfile側に書いてしまってimageに含めてしまっても良い、、、。CI/CDはホストにて一度imageをbuildし、後は呼び出すだけ構成を考えていたためdockerfileで管理する方針を選択した。副作用としては、設定ファイルを変えるたびに再buildする必要がある。

docker-compose.yml

services:
  fluent-bit:
    build:
      context: ../../adapter/FluentBit/
      dockerfile: etc/docker/Dockerfile
    container_name: fluent-bit
    ports:
      - "24224:24224"
      - "24224:24224/udp"
    depends_on:
      loki:
        condition: service_healthy
    networks:
      - shared-log-network

networks:
  shared-log-network:
    external: true

Dockerfile

設定ファイルだけ管理。

FROM fluent/fluent-bit:latest

COPY etc/fluent-bit.conf /fluent-bit/etc/fluent-bit.conf
COPY etc/add_tag.lua /fluent-bit/etc/add_tag.lua
container nameをタグに設定する

INPUT項目でport 24224宛はすべて受け取るように設定。OUTPUT項目でLoki転送とラベル付与を行う。Lokiのラベリングは大項目(job),中項目(app)とし、アプリケーションログは基本docker-logsで中項目に各アプリケーション名(コンテナ名)を指定する形とした。基本Grafanaでは中項目のコンテナ名でフィルタできればログを漁れるだろう予定。ただアプリケーション側のdocker-compose.ymlで指定したtag: sample-appをappに渡したかったが$tagで拾えなかった。AI曰く設定ファイルでは直接$tagが展開できない制約があるらしく、専用のtagをappに渡す関数を作る必要があるとのことなのでadd_tag.luaを作ってもらった。 これでアプリケーションは、ロギング・ドライバで転送するだけでラベリングされLokiに保存できるようになる。Dockerで動かしているアプリケーション以外のログ、例えば急に外気温とかを測ってGrafanaで見える化したい欲望が湧いた場合は別OUTPUT項目を作成しMatchを工夫することで別ラベルを設定できるようになる(はず)。基本はアプリケーションログ保存がメインになると思うため、Matchは*としておいた。

fluent-bit.conf

[SERVICE]
    Log_Level    info

[INPUT]
    Name   forward
    Listen 0.0.0.0
    Port   24224

[FILTER]
    Name    lua
    Match   *
    script  /fluent-bit/etc/add_tag.lua
    call    add_tag

[OUTPUT]
    Name   loki
    Match  *
    Host   loki
    Port   3100
    Labels job=docker-logs, app=$app
    # さらに、コンテナ名などの詳細情報をラベルとして抽出
    Label_Keys  $container_name, $container_id

add_tag.lua

docker-compose.ymlのlogging項目で指定したtagを、$appで参照できるようにするAIに作ってもらった関数。return で返している1はレコードを変更ありで通過させる意味らしい。ついでに-1を指定するとレコードを削除との意味。

function add_tag(tag, timestamp, record)
    record["app"] = tag
    return 1, timestamp, record
end

ログ管理システム: Loki導入

やることは下記2つ

  • FluentBitからログ転送を受け付ける
  • Lokiの設定
    • ログ保存先
    • ログローテート
FluentBitからログ転送を受け付ける

ログ管理システムは同一docker-compose.ymlで管理するためFluentBitで作成したものに追記する。ネットワーク周りはfluentbitと変わらず、受け取るport番号だけ変える。Lokiはログを保存するため、保存先のディレクトリをvolumesに指定。また依存関係上Lokiが起動してからFluentBitを立ち上げたいため、healthcheck項目を追加。内容はLokiのサンプルをまんま使った。loki専用healthcheckコマンドがあるらしい。

docker-compose.yml

services:
  loki:
    build:
      context: ../../log/Loki
      dockerfile: etc/docker/Dockerfile
    container_name: loki
    ports:
      - "3100:3100"
    volumes:
      - /docker/logs/loki:/loki
    networks:
      - shared-log-network
    healthcheck:
      test: [ "CMD", "/usr/bin/loki", "-health" ]
      start_period: 30s
      interval: 10s
      timeout: 5s
      retries: 5

  fluent-bit:
    build:
      context: ../../adapter/FluentBit/
      dockerfile: etc/docker/Dockerfile
    container_name: fluent-bit
    ports:
      - "24224:24224"
      - "24224:24224/udp"
    depends_on:
      loki:
        condition: service_healthy
    networks:
      - shared-log-network

networks:
  shared-log-network:
    external: true

Dockerfile

FluentBit同様に設定ファイルはDockerfileで管理させる。

FROM grafana/loki:3.6.7

COPY etc/loki-config.yaml /etc/loki/loki-config.yaml
Lokiの設定

いろいろ機能があるようだが、要件は指定先にログを保存することとログは1ヶ月でローテートさせること。

loki-config.yaml

# Loki configuration file

# マルチテナントモードを有効/無効を設定
# true: HTTPヘッダーに X-Scope-OrgID を含めることで、ユーザーごとにログを分離して管理する
# false: 単一テナントモードで、すべてのログが同じストレージに保存される
auth_enabled: false

# server configuration
server:
  http_listen_port: 3100

# save logs directly configuration
common:
  # lokiがログを保存するディレクトリのベースパス
  path_prefix: /loki
  storage:
    filesystem:
      # ログの生データ(chunks)を保存するディレクトリ
      chunks_directory: /loki/chunks
      # ログを監視しアラートを飛ばすためのルールファイルを保存するディレクトリ
      rules_directory: /loki/rules
  # ログの複製数を指定。複数のログ保存先を持つ場合に使用
  replication_factor: 1
  # Lokiの内部コンポーネント同士が「誰がどのデータを担当しているか」という情報を共有するための名簿(ハッシュリング)の設定
  # ログ保存の冗長化を行わないため、メモリ上で完結させる
  ring:
    kvstore:
      store: inmemory

# ingester configuration
ingester:
  # シャットダウン時に未フラッシュのデータを強制的にストレージに書き出す
  flush_on_shutdown: true
  # WAL(Write-Ahead Log)の設定
  # WALを有効にすることで、受信したログをメモリとディスクの両方に書き出す
  # コンテナ再起動時にWALを再生して、未フラッシュのデータを復元できる
  wal:
    enabled: true
    # WALファイルの保存先。/lokiはvolumeマウント済みのため永続化される
    dir: /loki/wal

# log storage configuration
schema_config:
  configs:
      # ログの保存方法を定義するセクション。途中から新しいスキーマへ変更することが可能
      # このスキーマは2020-10-24以降のログに適用される
    - from: 2020-10-24
      # インデックスをどのエンジンで管理するかを指定。tsdbがデファクトスタンダード
      # tsdb: 時系列データベースを使用してインデックスを管理
      # boltdb-shipper: BoltDBを使用してインデックスを管理
      store: tsdb
      # ログの保存方法を指定。ローカルに保存するためfilesystemを指定
      # filesystem: ローカルファイルシステムに保存する方式
      # gcs: Google Cloud Storageを使用する方式
      # s3: Amazon S3を使用する方式
      object_store: filesystem
      # Lokiが内部的に使用するスキーマのバージョン。tsdbを使用するため、v13を指定
      # tsdbはv13が最新で、boltdb-shipperはv11が最新
      schema: v13
      # インデックスのプレフィックスとローテーションの期間を指定
      #インデックスはログのメタデータを管理するための構造で、クエリの高速化用に使用される
      index:
        # 保存するインデックスのプレフィックスを指定
        # 特にこだわりは無いため、デフォルトのindex_を使用
        prefix: index_
        # インデックスのローテーション期間を指定
        # ここでは24時間ごとに新しいインデックスが作成されるように設定
        period: 24h

# log rotation configuration
limits_config:
  # ログの保持期間を指定
  # ここでは31日保持させるため744時間(24h*31日)に設定
  retention_period: 744h 
  # 古いログが送られた際に拒否するかどうかを指定
  # fluentbitが吸い上げられなかったデータを送る可能性があるかも
  # しかしながら重要なログは無いため拒否設定とする
  # true: 古いログを拒否する
  # false: 古いログも受け入れる
  reject_old_samples: true
  # 拒否する古いログの期間を指定
  # ログ保存期間に合わせることで、古いログの混在を防止する
  reject_old_samples_max_age: 744h

# ログを消す実行役の設定
compactor:
  # ログ削除プロセスが使用する一時ディレクトリ
  working_directory: /loki/compactor
  # ログ削除を有効にするかどうかを指定
  # true: ログ削除を有効にする
  # false: ログ削除を無効にする
  retention_enabled: true 

ログ管理システム: Grafana導入

同様にdocker-compose.ymlへGrafanaを追記する。ネットワークや依存関係はFluentBitと同様。Grafanaの設定ファイル(IDやパスワード等)を保存する必要があるためvolumesにディレクトリパスを指定する。

docker-compose.yml

services:
  loki:
    build:
      context: ../../log/Loki
      dockerfile: etc/docker/Dockerfile
    container_name: loki
    ports:
      - "3100:3100"
    volumes:
      - /docker/logs/loki:/loki
    networks:
      - shared-log-network
    healthcheck:
      test: [ "CMD", "/usr/bin/loki", "-health" ]
      start_period: 30s
      interval: 10s
      timeout: 5s
      retries: 5

  fluent-bit:
    build:
      context: ../../adapter/FluentBit/
      dockerfile: etc/docker/Dockerfile
    container_name: fluent-bit
    ports:
      - "24224:24224"
      - "24224:24224/udp"
    depends_on:
      loki:
        condition: service_healthy
    networks:
      - shared-log-network

  grafana:
    build:
      context: ../../visualization/grafana/
      dockerfile: etc/docker/Dockerfile
    container_name: grafana
    ports:
      - "3000:3000"
    volumes:
      - /docker/setting/grafana:/var/lib/grafana
    depends_on:
      loki:
        condition: service_healthy
    networks:
      - shared-log-network

networks:
  shared-log-network:
    external: true

Dockerfile

FluentBit同様に設定ファイルはDockerfileで管理させる。

FROM grafana/grafana:12.3.6-security-01

COPY etc/provisioning/datasources /etc/grafana/provisioning/datasources

loki.yaml

Grafanaが参照するデータソース(Loki)を設定する。 今後データソースは増えるかもしれないためetc/provisioning/datasources配下に設置し、COPYでまっると送る作戦。

apiVersion: 1

datasources:
  - name: Loki
    type: loki
    access: proxy
    url: http://loki:3100
    isDefault: true

Grafanaでログ確認

データ参照先としてLokiを選択する。接続先としてloki.yamlをすでに設定しているため、GUIで選ぶだけ。

ExploreLoki を選択。

grafana-explore

Label filters項目でLokiに保存されているLabel一覧がでるので、コレでフィルタする。右上の青いボタン🔁でクエリを実行できる

grafana-label-filter

Logs volumeにクエリ結果が表示される

grafana-grafana-logs