awkによるマルチラインログの自動抽出と構造化JSON変換パイプラインの構築

Tech

SRE / DevOps Engineers, Infrastructure Automation Specialists Advanced (Linux shell scripting, awk internals, log parsing) Learn how to robustly parse multi-line logs using GNU awk RS and getline, transforming them into JSON for automated operations.

本記事はGeminiの出力をプロンプト工学で整理した業務ドラフト(未検証)です。

awkによるマルチラインログの自動抽出と構造化JSON変換パイプラインの構築

【導入と前提】

複数行にわたる障害ログやスタックトレースを抽出し構造化JSONとして自動転送する運用を堅牢化します。

  • OS/実行環境: Linux (Ubuntu 22.04 LTS / RHEL 9 以降推奨)

  • 必須ツール: GNU awk (gawk 5.0以降), jq (1.6以降), curl (7.68.0以降), systemd


【処理フローと設計】

graph TD
A["Raw Multi-line Log"] -->|gawk RS/getline| B["Parsed Event Stream"]
B -->|jq filter| C["Structured JSON Payload"]
C -->|curl with retry| D["Log Monitoring Platform"]

本構成では、ログ発生源から出力されるマルチライン(複数行)の例外スタックトレースを gawk のレコード分離子(RS)および getline 関数を駆使して単一のイベント単位へ集約します。抽出されたテキストはパイプライン経由で jq に渡され、安全にエスケープされたメタデータ付きJSONフォーマットへ整形された後、HTTP APIエンドポイントへ送信されます。


【実装:堅牢な自動化スクリプト】

以下のシェルスクリプトは、エラーハンドリング、一時ファイルの安全な破棄、リトライロジックを備えたポータブルな自動処理スクリプトです。

#!/usr/bin/env bash

set -euo pipefail

# -----------------------------------------------------------------------------


# ログ抽出・パース・転送スクリプト


# -----------------------------------------------------------------------------

# 一時ファイルの生成と安全な自動削除(trap)の設定

TMP_DIR=$(mktemp -d "/tmp/log_parser.XXXXXX")
TMP_PAYLOAD="${TMP_DIR}/payload.json"

cleanup() {

    # 終了時に一時ディレクトリを確実に削除

    rm -rf "${TMP_DIR}"
}
trap cleanup EXIT SIGINT SIGTERM

# 環境変数と設定値

readonly LOG_FILE="${1:-/var/log/app/error.log}"
readonly API_ENDPOINT="${API_ENDPOINT:-https://api.monitoring.internal/v1/logs}"
readonly API_TOKEN="${API_TOKEN:-secret-bearer-token}"

if [[ ! -f "${LOG_FILE}" ]]; then
    echo "[ERROR] ログファイルが存在しません: ${LOG_FILE}" >&2
    exit 1
fi

# -----------------------------------------------------------------------------


# GNU awkによるマルチラインログのパース処理


# RS(レコード区切り)に正規表現を使用して「日付始まりの行」を区切りとして扱う


# -----------------------------------------------------------------------------

gawk '
BEGIN {

    # 202X-XX-XX 形式の日付パターンが行頭にある箇所をレコード区切り(RS)とする

    RS = "\n(?=[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2})"
    FS = "\n"
    OFS = " "
}
{

    # 空行のスキップ

    if ($0 ~ /^[[:space:]]*$/) next

    # レコードの先頭行(1行目)からタイムスタンプとログレベルを取得

    timestamp = ""
    level = "UNKNOWN"

    # getlineによる継続行の読み込み補完例(特殊ヘッダーが分割されている場合の制御)

    if ($1 ~ /^([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}) \[([A-Z]+)\]/) {
        match($1, /^([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}) \[([A-Z]+)\]/, arr)
        timestamp = arr[1]
        level = arr[2]
    }

    # ERROR または CRITICAL を含むマルチラインイベントのみを抽出

    if (level == "ERROR" || level == "CRITICAL" || $0 ~ /Exception|Error/) {

        # 改行コードを保持したままメッセージを構成

        msg = $0

        # TSV形式(Timestamp, Level, Message)で標準出力(パイプ先)へ出力


        # メッセージ内のタブ文字は事前置換

        gsub(/\t/, " ", msg)
        printf "%s\t%s\t%s\n", timestamp, level, msg
    }
}' "${LOG_FILE}" | \
jq -R -s -c '
    split("\n") | map(select(length > 0)) | map(
        split("\t") as $cols |
        {
            "timestamp": $cols[0],
            "level": $cols[1],
            "message": $cols[2],
            "source": "app_error_log"
        }
    )
' > "${TMP_PAYLOAD}"

# 送信対象データの存在確認

if [[ ! -s "${TMP_PAYLOAD}" ]] || [[ "$(jq 'length' "${TMP_PAYLOAD}")" -eq 0 ]]; then
    echo "[INFO] 転送対象となるマルチラインログは検出されませんでした。"
    exit 0
fi

# -----------------------------------------------------------------------------


# APIへの安全なデータ転送 (curl リトライ設定)


# -----------------------------------------------------------------------------

curl -sS \
    --fail \
    --connect-timeout 5 \
    --max-time 30 \
    --retry 3 \
    --retry-delay 2 \
    --retry-connrefused \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer ${API_TOKEN}" \
    -X POST \
    --data-binary "@${TMP_PAYLOAD}" \
    "${API_ENDPOINT}"

# curlの主要オプションの解説:


# -sS: サイレントモードだがエラー時のみ標準エラー出力へ表示


# --fail: HTTPレスポンスエラー(4xx/5xx)時に異常終了コードを返す


# --connect-timeout / --max-time: 接続および全処理のタイムアウト設定(秒)


# --retry / --retry-delay / --retry-connrefused: 接続拒否を含む一時障害時の指数バックオフリトライ


# --data-binary: 改行コードやデータを無変換でPOSTボディとして送信

echo "[INFO] ログデータの転送が正常に完了しました。"

自動化用 systemd ユニット設定例

定期実行および障害監視を行うための systemd サービスおよびタイマーファイルです。

/etc/systemd/system/log-parser.service

[Unit]
Description=Multi-line Log Extractor and Forwarder Service
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=varlog
Group=varlog
ExecStart=/usr/local/bin/parse_and_forward.sh /var/log/app/error.log
Environment="API_ENDPOINT=https://api.monitoring.internal/v1/logs"
EnvironmentFile=-/etc/default/log-parser
ProtectSystem=full
ProtectHome=true
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target

/etc/systemd/system/log-parser.timer

[Unit]
Description=Run Multi-line Log Parser every 5 minutes

[Timer]
OnCalendar=*:0/5
Persistent=true

[Install]
WantedBy=timers.target

【検証と運用】

1. 手動実行による動作検証(ドライラン)

ダミーのマルチラインログを作成し、出力されるJSONペイロードの構造を検証します。

# テスト用ダミーログの作成

cat << 'EOF' > /tmp/test_multiline.log
2023-10-27 10:00:00 [INFO] Normal startup completed.
2023-10-27 10:05:23 [ERROR] Database connection failed.
java.sql.SQLException: Connection refused
    at com.example.db.Connector.connect(Connector.java:42)
    at com.example.main.App.run(App.java:15)
2023-10-27 10:10:00 [INFO] Heartbeat OK.
EOF

# パイプライン単体テスト実行

API_ENDPOINT="https://httpbin.org/post" ./parse_and_forward.sh /tmp/test_multiline.log

2. systemd タイマーの稼働ログ確認

journalctl を利用して、バックグラウンド実行時の出力およびエラーを確認します。

# タイマーの次回実行時刻の確認

systemctl list-timers log-parser.timer

# サービス実行ログのリアルタイム追跡

journalctl -u log-parser.service -n 50 -f --no-pager

【トラブルシューティングと落とし穴】

  1. getline の戻り値チェック漏れによる無限ループ

    • 問題: awk 内で getline を利用する際、ファイルの終端(EOF: 戻り値 0)や読み込みエラー(戻り値 -1)をチェックしないと、無限ループや想定外の挙動を引き起こします。

    • 対策: while ((ret = getline line < "file") > 0) のように必ず戻り値が正であることを確認する制御文を組み込んでください。

  2. POSIX awk と GNU awk (gawk) の RS 互換性問題

    • 問題: POSIX標準の awk では RS(Record Separator)に1文字しか指定できません。マルチラインログの区切りとして「正規表現(例: 行頭の日付)」を使用できるのは GNU awk (gawk) 等の拡張実装に限られます。

    • 対策: スクリプトのシバンの明記および実行コマンドとして gawk を明示的に指定し、ポータビリティが必要な場合は POSIX 準拠の標準行処理ロジックへフォールバックさせてください。

  3. 機密情報(API Token)の露出

    • 問題: トークンや認証情報をスクリプト内にハードコードしたり、ps コマンドで引数として見える形で渡すと漏洩リスクが生じます。

    • 対策: systemdEnvironmentFile 機能を利用し、パーミッションを 0600 に制限した外部ファイルから環境変数として読み込ませてください。

  4. 一時ファイルの残存によるディスク圧迫

    • 問題: スクリプトが途中でエラー終了した際、mktemp で作成した一時ファイルが残り続ける問題。

    • 対策: 冒頭で定義している通り trap cleanup EXIT SIGINT SIGTERM を宣言し、どのような終了ステータスでも確実にクリーニングされる構造にしてください。


【まとめ】

運用の冪等性と堅牢性を維持するために、以下の3点を意識してください。

  1. レコード区切りの厳密化: GNU awk の正規表現 RS を活用し、ログの区切り(タイムスタンプ等)を正確に識別してマルチラインイベントを1つの塊としてパースする。

  2. 安全なパイプライン化とJSON構造化: jqcurl --data-binary を使用して改行コードや特殊文字のエスケープを安全に処理し、データ破損を防ぐ。

  3. 完全なシグナルハンドリング: set -euo pipefailtrap による一時リソースの削除を徹底し、障害発生時もシステム汚染を起こさない作りにする。

ライセンス:本記事のテキスト/コードは特記なき限り CC BY 4.0 です。引用の際は出典URL(本ページ)を明記してください。
利用ポリシー もご参照ください。

コメント

タイトルとURLをコピーしました