ログ確認に(精神的な)限界を感じ、Mac上のローカルLLMに少しだけ助けてもらう仕組みを作ってみた話
(AIに記事をかいてもらいました :)
はじめに
日々の業務でFivetranやdbtなどのデータパイプラインを運用しているのですが、トラブルが起きるたびに管理画面を開いたり、ログを追うためにクエリを叩き続けたりする作業に、少しばかり疲れを感じていました。
「自分のスキル不足を補うために、もう少しだけログ確認を楽にできないだろうか…」
そう思い悩み、昨今話題のAIの力を借りてみることにしました。外部のクラウドAPIに機密データを投げるのは不安だったため、勉強も兼ねて手元のMacBook Air内で完結する小さな「ローカルAIOps環境」を構築してみました。
まだまだアーキテクチャとして至らない点も多々あるかと思いますが、個人の備忘録として、また同じようにログ運用に悩む方の少しでもお役に立てればと思い、構築手順を共有させていただきます。
今回構築したささやかな構成
全体像としては以下のような構成になります。外部通信を行わず、Macのローカルリソースのみで稼働しています。
処理の流れ:
- K3s 上のコンテナ群がログを出力する。
- OpenTelemetry (OTel) Collector がログを収集し、ClickHouse へ蓄積する。
- ワークフローエンジンの n8n が定期的に ClickHouse を確認し、新しいエラーログを抽出する。
- n8n から、Macのホスト側で待機させているローカルLLM(Liquid AI LFM2.5)へログを渡し、内容の意訳と要約をお願いする。
- LLMが生成してくれた要約レポートを、n8n経由で Slack に通知する。
前提となる環境
- Apple Silicon Mac (M1/M2/M3)
- Podman Desktop および k3d がインストール済みであること
-
kubectlコマンドが実行可能なこと - Slack の Incoming Webhook URL が発行済みであること
- ローカルLLMの実行環境(
llama.cpp/llama-server)が構築済みであること - ※今回は、軽量かつ精度が高いと感じた
LiquidAI/LFM2.5-1.2B-InstructのGGUFモデルを利用させていただきました。
構築手順
ここからは、実際に手元で構築した際の手順を記載します。
Step 1: ローカルLLMの起動
まずは、解析を担ってくれるLLMを起動します。K3s(コンテナ内部)からアクセスできるように、--host 0.0.0.0 を指定して起動しておくのがポイントでした。
# Macのターミナルで実行(モデルのパスはご自身の環境に合わせて変更してください)
llama-server -hf LiquidAI/LFM2.5-1.2B-Instruct-GGUF:Q4_K_M --host 0.0.0.0 --port 8080
Step 2: K3sクラスターの作成
テスト用の小さなKubernetesクラスターを k3d で作成します。
k3d cluster create aiops-cluster
Step 3: マニフェストの適用(基盤構築)
必要なコンポーネントを順番にデプロイしていきます。管理がしやすいよう、一度YAMLファイルに書き出してから apply する形をとっています。
3-1. ClickHouse(ログ蓄積)
OTelからのログ受信ポート(9000)と、n8nからのHTTPアクセス用ポート(8123)を開放しています。
cat <<EOF > clickhouse.yaml
apiVersion: v1
kind: Service
metadata:
name: clickhouse
spec:
selector:
app: clickhouse
ports:
- name: tcp
protocol: TCP
port: 9000
targetPort: 9000
- name: http
protocol: TCP
port: 8123
targetPort: 8123
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: clickhouse
spec:
replicas: 1
selector:
matchLabels:
app: clickhouse
template:
metadata:
labels:
app: clickhouse
spec:
containers:
- name: clickhouse
image: clickhouse/clickhouse-server:23.8
ports:
- containerPort: 9000
- containerPort: 8123
env:
- name: CLICKHOUSE_USER
value: "admin"
- name: CLICKHOUSE_PASSWORD
value: "password123"
EOF
kubectl apply -f clickhouse.yaml
3-2. OTel Collector(ログ収集)
クラスター内のコンテナログを拾い集め、ClickHouseへ転送する設定です。
cat <<EOF > otel.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: otel-config
data:
config.yaml: |
receivers:
filelog:
include: [ /var/log/pods/*/*/*.log ]
operators:
- type: container
processors:
batch:
exporters:
clickhouse:
endpoint: tcp://clickhouse:9000
username: admin
password: password123
ttl_days: 1
logs_table_name: otel_logs
service:
pipelines:
logs:
receivers: [filelog]
processors: [batch]
exporters: [clickhouse]
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: otel-collector
spec:
selector:
matchLabels:
app: otel-collector
template:
metadata:
labels:
app: otel-collector
spec:
containers:
- name: otel-collector
image: otel/opentelemetry-collector-contrib:0.88.0
args: ["--config=/etc/otel/config.yaml"]
volumeMounts:
- name: config
mountPath: /etc/otel
- name: varlogpods
mountPath: /var/log/pods
readOnly: true
- name: varlibdockercontainers
mountPath: /var/lib/docker/containers
readOnly: true
volumes:
- name: config
configMap:
name: otel-config
- name: varlogpods
hostPath:
path: /var/log/pods
- name: varlibdockercontainers
hostPath:
path: /var/lib/docker/containers
EOF
kubectl apply -f otel.yaml
3-3. n8n(自動化ワークフロー)
連携処理を担うn8nです。日本時間で動作するように環境変数を設定しています。
cat <<EOF > n8n.yaml
apiVersion: v1
kind: Service
metadata:
name: n8n
spec:
selector:
app: n8n
ports:
- protocol: TCP
port: 5678
targetPort: 5678
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: n8n
spec:
replicas: 1
selector:
matchLabels:
app: n8n
template:
metadata:
labels:
app: n8n
spec:
containers:
- name: n8n
image: docker.io/n8nio/n8n:latest
ports:
- containerPort: 5678
env:
- name: GENERIC_TIMEZONE
value: "Asia/Tokyo"
- name: TZ
value: "Asia/Tokyo"
EOF
kubectl apply -f n8n.yaml
3-4. テスト用アプリケーション(Chaos Logger)
動作確認のために、あえてエラーログを定期的に出力するPodも用意しました。
cat <<EOF > chaos.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: chaos-logger
spec:
replicas: 1
selector:
matchLabels:
app: chaos-logger
template:
metadata:
labels:
app: chaos-logger
spec:
containers:
- name: logger
image: busybox
command:
- /bin/sh
- -c
- |
while true; do
rand=\$((RANDOM % 4))
case \$rand in
0) echo "[ERROR] Failed to connect to database 'user_db' at 10.43.2.5: Connection timed out. Retry attempt 3/3 failed.";;
1) echo "[CRITICAL] Payment gateway API returned HTTP 503 Service Unavailable. Transaction ID: txn_\$RANDOM dropped.";;
2) echo '{"level":"error","service":"auth-api","message":"NullPointerException in TokenValidator.java:45","trace":"java.lang.NullPointerException\n\tat com.app.auth.TokenValidator.validate(TokenValidator.java:45)"}';;
3) echo "[INFO] User login successful. UserID: 10024";;
esac
sleep \$((RANDOM % 20 + 10))
done
EOF
kubectl apply -f chaos.yaml
Step 4: n8n ワークフローの構築
Pod が全て Running 状態になったら、n8n にアクセスするためにポートフォワードを設定します。
kubectl port-forward svc/n8n 5678:5678
ブラウザで http://localhost:5678 にアクセスし、以下の流れでワークフローを組みました。
- Schedule Trigger: 30秒間隔で定期実行する。
- HTTP Request (ClickHouse): 直近40秒間に発生したエラーログのみを抽出する(ログの重複検知を防ぐため、少しのりしろを持たせています)。
- If ノード: 抽出結果が0件の場合は、LLMへのリクエストを行わずに処理を終了する(不要なリソース消費を防ぐため)。
-
HTTP Request (LLM):
http://host.containers.internal:8080/v1/chat/completions宛にログデータを送り、SREの視点で要約をお願いするプロンプトを設定。 - HTTP Request (Slack): LLMから返ってきた要約テキストをSlackへPOSTする。
🛠️ 手っ取り早く試したい方向け:インポート用JSON
こちらのJSONをn8nのキャンバスに貼り付けると、上記のフローが再現できます。(※SlackのURLやClickHouseの認証情報はご自身の環境に合わせて修正をお願いいたします)
{
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "seconds",
"seconds": 30
}
]
}
},
"id": "schedule-trigger",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.1,
"position": [460, 240]
},
{
"parameters": {
"method": "POST",
"url": "http://clickhouse:8123/",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendBody": true,
"contentType": "raw",
"rawContentType": "text/plain",
"body": "SELECT Timestamp, Body \nFROM default.otel_logs \nWHERE (Body LIKE '%WARNING%' OR Body LIKE '%ERROR%' OR Body LIKE '%CRITICAL%')\n AND Timestamp > now() - interval 40 second\nORDER BY Timestamp DESC \nFORMAT JSON"
},
"id": "clickhouse-query",
"name": "HTTP Request (ClickHouse)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [680, 240],
"credentials": {
"httpBasicAuth": {
"id": "YOUR_CREDENTIAL_ID",
"name": "ClickHouse Auth"
}
}
},
{
"parameters": {
"conditions": {
"number": [
{
"value1": "={{ $json.rows }}",
"operation": "larger"
}
]
}
},
"id": "if-error-exists",
"name": "If Error Exists",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [900, 240]
},
{
"parameters": {
"method": "POST",
"url": "http://host.containers.internal:8080/v1/chat/completions",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"local-model\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"あなたは優秀なインフラエンジニア(SRE)です。提供されたシステムログを確認し、何が起きているのか日本語で簡潔に要約して報告してください。\"\n },\n {\n \"role\": \"user\",\n \"content\": {{ JSON.stringify($json.data) }}\n }\n ],\n \"temperature\": 0.1\n}"
},
"id": "llm-analyze",
"name": "HTTP Request (LLM)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [1120, 220]
},
{
"parameters": {
"method": "POST",
"url": "YOUR_SLACK_WEBHOOK_URL",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"text\": \"🚨 *K3s AIアラート (LFM2.5)* 🚨\\n\\n\" + $json.choices[0].message.content\n}"
},
"id": "slack-notify",
"name": "HTTP Request (Slack)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.1,
"position": [1340, 220]
}
],
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "HTTP Request (ClickHouse)",
"type": "main",
"index": 0
}
]
]
},
"HTTP Request (ClickHouse)": {
"main": [
[
{
"node": "If Error Exists",
"type": "main",
"index": 0
}
]
]
},
"If Error Exists": {
"main": [
[
{
"node": "HTTP Request (LLM)",
"type": "main",
"index": 0
}
]
]
},
"HTTP Request (LLM)": {
"main": [
[
{
"node": "HTTP Request (Slack)",
"type": "main",
"index": 0
}
]
]
},
"HTTP Request (Slack)": {
"main": [
[]
]
}
}
}
終わりに
まだまだ改善の余地は山ほどある簡素な仕組みですが、無味乾燥なログが少しだけ分かりやすい文章になって通知されるようになり、日々の監視に対する精神的なハードルが少し下がったように感じます。
テスト稼働中も「データベースへの接続がタイムアウトしています」など、LLMが的確に意訳して教えてくれるため、今まで見落としていた細かな事象にも気づきやすくなりました。
大層なシステムではありませんが、小さく手元で始めてみるのも学びが多くて面白いなと実感しています。今後は機密情報のマスキング(PII対策)を挟むなど、より実用的な構成にも挑戦してみたいです。
もし「もっとこうした方が良いよ!」といったアドバイスやご指摘がありましたら、ぜひお手柔らかに教えていただけますと幸いです。最後までお読みいただき、ありがとうございました。
*(クリーンアップ手順:検証が終わった後は k3d cluster delete aiops-cluster で簡単に破棄できます)
