前言
服務一多,log 就散在各處:app-a、app-c、app-b 三個 Laravel 服務各自寫 storage/logs,nginx 寫在 /var/log/nginx,MySQL 的慢查詢又在另一個 volume。出事的時候我得 ssh 進去一個一個 tail、grep,光是判斷「錯誤是從哪個服務先冒出來的」就要花十幾分鐘。這篇記錄我怎麼用 Grafana Loki + Grafana Alloy,把這些 log 集中到同一個地方、遮掉敏感資料,再加碼把 MySQL 慢查詢也收進來,以及過程中踩到的三個大坑。
【系列】自架 VM 維運實戰 第 4 篇/共 5 篇
上一篇:【監控】Prometheus + Grafana 監控 Docker 服務與 Slack 告警
下一篇:【效能】k6 壓測 Laravel Octane:找出 2 核 VM 的真實上限
這篇延續系列上一篇的 Prometheus + Grafana:指標已經有了,接下來補上 log。Grafana 的安裝與 provisioning 上一篇已經介紹過,這裡只講 Loki 相關的部分。
先交代環境:Loki 3.7.8、Grafana Alloy v1.20.1、Grafana 12,全部用 Docker Compose 跑在一台 2 核 / 12 GB 的小 VM 上,Loki 只綁 127.0.0.1,資料保留 30 天。
為什麼不用 ELK?
我評估過 ELK,結論是在這個規模「養不起」。Elasticsearch 光是 JVM heap 通常就要吃掉 2 到 4 GB,還要處理 shard、index lifecycle、mapping 衝突;而我的需求只是「按服務、按等級、按關鍵字撈 log,順便畫個圖、發個告警」。Loki 的設計哲學不同:只對 label 建索引,log 內文當成壓縮過的 chunk 存起來,查詢時再 grep。代價是全文搜尋比較慢,但對一台 12 GB 的機器,Loki 容器我只給了 mem_limit: 768m,Alloy 只給 256m,實際跑起來綽綽有餘。
| 項目 | ELK | Loki + Alloy |
|---|---|---|
| 索引方式 | 全文倒排索引 | 只索引 label,內文不索引 |
| 記憶體需求 | Elasticsearch 通常 2 GB 起跳 | Loki 768 MB + Alloy 256 MB 上限 |
| 維運複雜度 | shard、mapping、ILM | 單一二進位檔,filesystem 儲存 |
| 與 Grafana 整合 | 需另外接 datasource | 原生,同一套 LogQL 與告警 |
為什麼不掛 docker.sock?
蒐集容器 log 最常見的做法是讓 Alloy(或 Promtail)掛 /var/run/docker.sock,用 Docker service discovery 自動找容器。我刻意不這樣做:docker.sock 等於主機的 root 權限,誰能對它下指令,就能起一個掛載整個根目錄的特權容器。為了讀 log 開這麼大的洞不划算。
我的設計原則很單純:只讀 log 檔、全部唯讀掛載。Laravel 本來就寫檔、nginx 本來就寫檔、MySQL slow log 也是檔案,Alloy 只要看得到檔案就夠了,不需要認識任何容器。
架構與元件
整條管線只有三段:Alloy 讀檔 → 解析與遮罩 → 寫進 Loki,Grafana 負責查詢、儀表板與告警。
| 元件 | 角色 | 重點設定 |
|---|---|---|
| Alloy | 讀 log 檔、解析、遮罩、推送 | 唯讀掛載 log 目錄;mem_limit 256m |
| Loki | 儲存與查詢 | 單機模式、TSDB + filesystem、保留 30 天 |
| Grafana | 儀表板與告警 | App Logs 儀表板 + 兩條 Slack 告警 |
來源共五類 log:三個 Laravel 服務、nginx(access 與 error)、MySQL slow log。下面是 Compose 裡 Loki 與 Alloy 的區塊(路徑已通用化):
loki:
image: grafana/loki:3.7.8
container_name: mon-loki
restart: unless-stopped
command: ["-config.file=/etc/loki/config.yaml"]
volumes:
- ./loki/config.yaml:/etc/loki/config.yaml:ro,Z
- loki-data:/loki
ports:
- "127.0.0.1:3100:3100"
networks: [monitoring]
mem_limit: 768m
alloy:
image: grafana/alloy:v1.20.1
container_name: mon-alloy
restart: unless-stopped
user: "0" # 讀 nginx log(0640 root 群組)需要 root
security_opt: ["label=disable"] # 不重新標記宿主機 log 目錄的 SELinux 標籤,只對此容器關閉檢查
command: ["run", "--server.http.listen-addr=0.0.0.0:12345", "--storage.path=/var/lib/alloy/data", "/etc/alloy/config.alloy"]
volumes:
- ./alloy/config.alloy:/etc/alloy/config.alloy:ro
- alloy-data:/var/lib/alloy/data
- /var/www/app-a/storage/logs:/logs/app-a:ro
- /var/www/app-c/storage/logs:/logs/app-c:ro
- /var/www/app-b/storage/logs:/logs/app-b:ro
- /var/log/nginx:/logs/nginx:ro
- /var/lib/docker/volumes/shared-mysql-data/_data:/logs/mysql:ro # 只讀 *-slow.log
ports:
- "127.0.0.1:12345:12345"
networks: [monitoring]
mem_limit: 256m
有兩個細節值得說明。第一,Alloy 為什麼以 root 跑:nginx 的 log 權限是 0640、群組 root,非 root 帳號讀不到;我選擇讓 Alloy 用 root,但因為所有掛載都是 :ro,最糟也只是「能讀」,不能改。第二,security_opt: ["label=disable"]:這台 VM 開著 SELinux,如果用 :Z 掛載,Docker 會去重新標記宿主機的 log 目錄,可能影響原本的 nginx 與 PHP;所以只對 Alloy 這個容器關掉 label 檢查,不動宿主機的標籤。注意 Loki 的設定檔掛載才用 :ro,Z,那是 Loki 自己的設定目錄,標記無妨。
Alloy 的 HTTP 埠也只綁 127.0.0.1:12345,要看 pipeline 狀態與 debug 時從本機進去即可。
Loki 設定重點
Loki 用單機模式,設定很短,但有兩個地方後面會跟踩坑直接相關:保留期與 schema。
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
log_level: warn
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2026-01-01 # 必須早於可補收的最舊 log(30 天前),否則寫入會回 500
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 720h # 30 天
reject_old_samples: true
reject_old_samples_max_age: 720h # 允許補收 30 天內的舊 log
allow_structured_metadata: true
volume_enabled: true
compactor:
working_directory: /loki/compactor
retention_enabled: true
delete_request_store: filesystem
compaction_interval: 10m
- retention_period: 720h:保留 30 天,要真的刪資料還需要
compactor.retention_enabled: true,兩者缺一不可。 - reject_old_samples_max_age: 720h:Loki 預設只收一週內的樣本,我要讓 Alloy 第一次啟動時能補收 30 天內的舊 log,所以放寬到跟保留期一致。
- schema_config.from:schema 的生效日期,這個值後面會害我吃苦頭。
- auth_enabled: false:單租戶,加上只綁 127.0.0.1,由 Grafana 在同一台機器上代為查詢。
Alloy pipeline 逐段解說
Alloy 的設定語言是 River 風格的元件組合:local.file_match 找檔案、loki.source.file 讀檔、loki.process 做一連串 stage 處理、loki.write 送出。我的資料流向是:
loki.source.file (每個來源)
→ loki.process (各來源自己的解析:抽 level / env、校正時間、丟掉不要的)
→ loki.process "mask" (所有來源共用的遮罩)
→ loki.write "default" (推到 Loki)
來源:只用 label 描述「是誰」
local.file_match "app_a" {
path_targets = [{"__path__" = "/logs/app-a/*.log", "app" = "app-a", "source" = "laravel"}]
}
local.file_match "nginx_access" {
path_targets = [{"__path__" = "/logs/nginx/access.log", "app" = "nginx", "source" = "nginx-access"}]
}
loki.source.file "app_a" {
targets = local.file_match.app_a.targets
forward_to = [loki.process.laravel_utc.receiver]
}
每個來源只帶兩個靜態 label:app(哪個服務)與 source(哪種 log)。再加上後面抽出來的 level、env、status_class、db_user,整體 label 的組合數非常有限。Loki 的效能與成本都跟「label 組合數(cardinality)」有關,這是設計時最重要的紀律:label 只放有限集合的值,不放 user id、URL、完整狀態碼。
Laravel:三個服務、三種時間格式
這是這個專案最麻煩的地方。同樣是 Laravel 的 laravel.log,三個服務的時間格式卻不一樣:
| 服務 | 範例 | 時區 |
|---|---|---|
| app-a | [2026-10-13 10:31:41] production.ERROR: ... | UTC |
| app-c | [2026-10-13 19:07:34] [36] [UNKNOWN] (1043) local.ERROR: ... | 台北時間,多了 pid 等欄位 |
| app-b | [2026-10-13T00:00:07.230442+00:00] [123] [UNKNOWN] (1) production.ERROR: ... | ISO 8601,含微秒與時區 |
如果不處理,Loki 會把「讀到這行的時間」當成 log 時間,補收舊 log 時整個時間軸就全亂了。所以我拆成三個 loki.process,分別用 stage.timestamp 指定 location 為 UTC、Asia/Taipei 與 RFC3339Nano;其餘邏輯(multiline、抽 level 與 env)三個共用同一份 regex。下面是 app-a 用的那一段:
loki.process "laravel_utc" {
stage.multiline {
firstline = "^\\[\\d{4}-\\d{2}-\\d{2}[ T]\\d{2}:\\d{2}:\\d{2}"
max_wait_time = "3s"
}
stage.regex {
expression = "^\\[(?P<ts>[^\\]]+)\\](?: \\[\\d+\\] \\[[^\\]]*\\] \\(\\d+\\))? (?P<env>[a-z]+)\\.(?P<level>[A-Z]+):"
}
stage.labels {
values = { level = "", env = "" }
}
stage.timestamp {
source = "ts"
format = "2006-01-02 15:04:05"
location = "UTC"
}
// 超過 Loki 保留期的舊 log 在送出前丟掉,避免 Loki 回 400、產生大量寫入錯誤
stage.drop {
older_than = "696h"
drop_counter_reason = "older_than_retention"
}
forward_to = [loki.process.mask.receiver]
}
逐個 stage 說明:
- stage.multiline:Laravel 的 exception 會連同整段 stack trace 寫成很多行,只有第一行有時間戳。用「以
[日期 時間開頭」當一筆的起點,其餘行都併進同一筆,這樣在 Grafana 看到的是一個完整的錯誤,而不是上百行零散的#12 /vendor/...。max_wait_time = "3s"是等下一筆開頭出現的最長時間,避免最後一筆卡在記憶體裡。 - stage.regex:一次抽出
ts、env(production / local)與level(ERROR / WARNING / INFO...)。中間那段可選的(?: \[\d+\] ...)?就是用來吃掉 app-c 與 app-b 多出來的 pid 欄位,所以三個服務能共用同一個 regex。 - stage.labels:把
level與env升格為 label,之後可以直接用{level="ERROR"}篩選,速度比對內文做字串比對快得多。 - stage.timestamp:重點在
location。app-a 是 UTC、app-c 設Asia/Taipei,app-b 的字串本身帶時區,直接用RFC3339Nano。 - stage.drop older_than:踩坑一的解法,後面詳述。
注意文章中程式碼的反斜線:River 字串裡要寫 \\d 才能表示 regex 的 \d,這是第一次寫的時候很容易漏掉一層而讓整個 regex 失效的地方。
nginx:丟掉雜訊、狀態碼轉成 status_class
loki.process "nginx_access" {
// blackbox 每 30 秒探測一次,不收
stage.drop {
expression = "Blackbox-Exporter"
}
stage.regex {
expression = "^\\S+ - \\S+ \\[(?P<ts>[^\\]]+)\\] \"(?P<method>\\S+) [^\"]*\" (?P<status>\\d{3}) "
}
stage.template {
source = "status_class"
template = "{{ if .status }}{{ printf \"%.1s\" .status }}xx{{ end }}"
}
stage.labels {
values = { status_class = "" }
}
stage.timestamp {
source = "ts"
format = "02/Jan/2006:15:04:05 -0700"
}
stage.drop {
older_than = "696h"
drop_counter_reason = "older_than_retention"
}
forward_to = [loki.process.mask.receiver]
}
這段有兩個設計決定。第一,我的 Prometheus 有 Blackbox-Exporter 每 30 秒探測一次網站健康狀態,一天就是 2,880 筆毫無資訊量的 access log,一開始就 stage.drop 掉。第二,狀態碼我不直接當 label:200、201、204、301、302、404、499、502... 每多一種就多一條 stream,而 stream 數量正是 Loki 的成本來源。我用 stage.template 取第一個字元再接上 xx,轉成 2xx / 3xx / 4xx / 5xx 四種,label 基數永遠只有 4。要查特定狀態碼時,再對內文做 |= " 502 " 這種過濾即可。
nginx error log 的處理類似,只是格式是 2026/10/13 10:31:41 [error] ...,抽出 level 與時間,時區用 UTC。
敏感資料遮罩
Laravel 的 log 和 SQL 裡最容易夾帶敏感資料:exception 訊息裡有整包 request payload、debug 時有人把 token 印出來、通知服務會記錄 FCM device token、使用者 email 常出現在登入失敗的訊息裡。這些東西一旦進了 Loki,就是「又多了一個地方要保護的個資庫」。所以我的原則是:在送進 Loki 之前就遮掉,Loki 裡根本不存在原始值。
所有來源最後都匯入同一個 loki.process "mask",只要維護一份規則:
loki.process "mask" {
// FCM device token(例:xxxx:APA91b...)
stage.replace {
expression = "[\\w-]{10,}:(APA91[\\w-]{40,})"
replace = "***"
}
// password / token / secret / api_key 的值
stage.replace {
expression = "(?i)(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key)\\\\?[\"']?\\s*[:=]\\s*\\\\?[\"']?([^\"'\\s,}\\\\]{4,})"
replace = "***"
}
// Authorization: Bearer xxx
stage.replace {
expression = "(?i)bearer\\s+([\\w\\-.~+/]+=*)"
replace = "***"
}
// email 的帳號部分
stage.replace {
expression = "([\\w.+-]+)@[\\w-]+\\.[\\w.-]+"
replace = "***"
}
forward_to = [loki.write.default.receiver]
}
stage.replace 有個很好用的特性:只取代 capture group 裡的內容,括號外的部分原樣保留。所以 password=abc12345 會變成 password=***,key 還在、值不見了,除錯時仍然知道「這裡有帶 password 欄位」;email 則是只遮帳號部分、保留網域,一樣能看出是哪個網域的使用者。四條規則各自的對象:
| 規則 | 遮的對象 | 保留什麼 |
|---|---|---|
| FCM token | device token 中 APA91 開頭的那一段 | 冒號前的前綴 |
| 密碼類 | password / secret / token / api_key / access_key 的值 | 欄位名稱與分隔符 |
| Bearer | Authorization 標頭裡的 token | Bearer 這個字 |
| @ 前面的帳號 | 網域 |
第二條規則最複雜,因為 Laravel 常把 payload 以 JSON 形式印出來,key 前後會有引號甚至被跳脫成 \\",所以 regex 裡 \\\\?[\"']? 就是在容忍「可能有反斜線、可能有引號」。值的結尾遇到引號、空白、逗號、右大括號就停止,至少 4 個字元才遮,避免把 token=1 這種無害值也蓋掉造成困擾。
我的驗證方式是造假資料:準備含 FCM token、password 欄位與 Bearer token 的測試 log 丟進 pipeline,實測 3/3 成功遮罩(涵蓋 token/password、Bearer、FCM 三類;email 規則未列入該次測試)。遮罩是一種「盡力而為」的防線,regex 一定有漏網之魚,所以還是要搭配「log 本身不要印敏感資料」的程式規範,這點必須誠實講清楚。
踩坑三連發
設定寫完、啟動 Alloy,我以為大功告成,結果連續踩了三個坑,而且它們有因果關係:都是「第一次啟動補收舊 log」引起的。
坑一:補收的舊 log 超過保留期,被 Loki 拒收
Alloy 第一次啟動時,會把檔案裡既有的 log 全部讀一遍。舊 log 檔裡有不少早已超過 30 天的資料。Loki 設了 30 天保留期,而超過 reject_old_samples_max_age(我跟保留期一樣設 720h)的樣本,Loki 會直接回 400 拒收。結果是 Alloy 這邊出現大量寫入錯誤、舊資料進不去,要靠 stage.drop 先自己把過期資料濾掉。
解法是在每個 pipeline 結尾加上 stage.drop,在送出前先把過期資料自己丟掉:
// 超過 Loki 保留期的舊 log 在送出前丟掉,避免 Loki 回 400、產生大量寫入錯誤
stage.drop {
older_than = "696h"
drop_counter_reason = "older_than_retention"
}
這裡的 696h 是 29 天,比 Loki 的 30 天(720h)少一天,刻意留一個安全邊際:log 從讀取到送出之間會有延遲,如果剛好卡在 30 天的邊界,還是會被拒收。drop_counter_reason 會讓被丟掉的筆數出現在 Alloy 的 metrics,之後可以確認到底丟了多少。每個 loki.process(共 6 個)都要加,漏一個那個來源就會中招。
坑二:schema_config 的 from 日期晚於最舊資料,查不到
我最初的 schema_config.from 寫的是部署當天的日期,直覺上「從今天開始用這個 schema」很合理。但是補收的舊 log 時間戳在這個日期之前,Loki 找不到對應那一天的 schema,寫入會回 500,就算勉強寫進去也查不到。
解法是把 from 改得夠早,早於任何可能補收的最舊 log(也就是 30 天前)。我直接設成 2026-01-01,因為 schema 只是描述「這段時間用什麼索引格式」,往前設並不會多佔任何空間。這也是上面 Loki 設定檔裡那行註解的由來。
坑三:補收的「幾小時前」資料,查不到
前兩個坑修好後,舊資料終於進去了,但我在 Grafana 查「最近 6 小時」卻是空的,查「最近 2 天」卻有資料,非常詭異。
原因出在 Loki 的寫入路徑:新進來的資料先留在 ingester 的記憶體裡,要等到 chunk 滿了或閒置夠久才會 flush 到儲存層。而 querier 有個 query_ingesters_within 參數(Loki 3.7.8 仍然存在,預設 3h,我在主機上用 loki -print-config-stderr 確認過):查詢時,超過這個時間窗的時段只查儲存層、不會去問 ingester。補收的「幾小時前」資料偏偏還躺在 ingester 記憶體、尚未 flush,儲存層沒有、又不問 ingester,所以查不到。
解法有兩個:我實際用的是手動呼叫 /flush 端點,讓 ingester 立刻把記憶體裡的資料寫到儲存層;另一個是暫時調大 query_ingesters_within,讓查詢也去問 ingester(需要改設定並重啟,我覺得 flush 比較乾淨):
# 因為 Loki 只綁 127.0.0.1,直接在主機上執行
curl -X POST http://127.0.0.1:3100/flush
執行完再查,「最近 6 小時」的資料就都出現了。這個操作只在第一次補收或大批重送之後需要;平常新 log 是即時進來、正常 flush,不會遇到這個狀況。
額外一個坑:單檔 bind mount 不能用 sed -i
上一篇在 prometheus.yml 踩過的坑,這次在 Alloy 又遇到一次。Compose 裡我同樣是把單一檔案 ./alloy/config.alloy bind mount 進容器,用 sed -i 修改後,主機上的檔案內容確實變了,但容器裡看到的還是舊的,熱重載當然沒有任何效果。
原因:sed -i 的做法是寫一個暫存檔再 rename 覆蓋原檔,inode 換了;而單檔 bind mount 綁的是「舊的 inode」,容器看到的仍是那個已經被取代的舊檔。解法有兩種:
- 用
tee或cat > 原檔做「原地覆寫」,不更換 inode,容器馬上看到新內容,再對 Alloy 觸發 reload。 - 或者乾脆重建容器(
docker compose up -d --force-recreate alloy),讓它重新綁定新 inode。
如果你的 bind mount 是整個目錄而非單一檔案,就沒有這個問題。
加碼:MySQL 慢查詢進 Loki
Log 集中起來之後,我立刻想到下一步:把 MySQL 的 slow query log 也收進來。這樣 Laravel 噴 500 的時候,我可以在同一個時間軸上看到「同一秒 MySQL 有沒有慢查詢」。先在 MySQL 設定 long_query_time = 1(超過 1 秒才記錄),然後處理三個問題。
問題一:檔名會變
MySQL 的 slow log 檔名是「容器 hostname」加上 -slow.log。容器一重建,hostname 就變,檔名跟著變。所以我用萬用字元匹配:
local.file_match "mysql_slow" {
path_targets = [{"__path__" = "/logs/mysql/*-slow.log", "app" = "mysql", "source" = "mysql-slow"}]
}
問題二:一筆慢查詢是多行,開頭還有啟動檔頭
slow log 每一筆長這樣:以 # Time: 開頭,接著 # User@Host、# Query_time 幾行註解,最後才是 SQL。另外 mysqld 每次啟動會在檔案裡寫一段版本資訊檔頭(以 /usr/sbin/mysqld 開頭)。如果用單純的 multiline,這段檔頭會被黏到下一筆慢查詢上。我的做法是讓兩種開頭都算「一筆的起點」,檔頭自成一筆之後再 drop:
loki.process "mysql_slow" {
stage.multiline {
firstline = "^(# Time: |/usr/sbin/mysqld)"
max_wait_time = "3s"
max_lines = 200
}
stage.drop {
expression = "^/usr/sbin/mysqld"
drop_counter_reason = "mysql_startup_header"
}
stage.regex {
expression = "^# Time: (?P<ts>\\S+)\\n# User@Host: (?P<db_user>[\\w-]+)\\["
}
stage.labels {
values = { db_user = "" }
}
stage.timestamp {
source = "ts"
format = "RFC3339Nano"
}
// SQL 字串常值(session payload、除錯工具寫入的內容、token 等)一律換成 ?
stage.replace {
expression = "'((?:[^'\\\\]|\\\\.)*)'"
replace = "?"
}
stage.drop {
older_than = "696h"
drop_counter_reason = "older_than_retention"
}
forward_to = [loki.process.mask.receiver]
}
db_user 當 label 是個有限集合(每個服務一個資料庫帳號),不會爆基數,之後就能按帳號分組,一眼看出是哪個服務在拖慢資料庫。
問題三:SQL 裡的字串常值是個資地雷
慢查詢最常見的是 session 資料表的 UPDATE(payload 整包 base64)、除錯工具寫入的 JSON 內容、帶 token 的查詢條件。這些如果原樣進 Loki 就糟了,而且字串太長也毫無閱讀價值。我用一條 stage.replace 把所有單引號包起來的字串常值的內容換成 ?,結果是 '?'(引號本身保留),只留下 SQL 結構與數字,效果就像 prepared statement 的樣子。優化慢查詢時需要的是「哪張表、哪個條件、掃了幾列」,不是參數值本身。接著這份已去識別的內容還會再經過共用的 mask 一次,等於兩道防線。
取捨:我其實掛了整個 MySQL 資料目錄
這裡要誠實交代一個不夠漂亮的地方。為了讀到 slow log,我把整個 MySQL 資料目錄(Docker volume)以唯讀方式掛進以 root 執行的 Alloy。設定檔雖然只讀 *-slow.log,但容器技術上讀得到所有資料檔,前面「只讀 log 檔」的原則在這一項打了折扣。更好的做法是讓 MySQL 用 slow_query_log_file 把 slow log 寫到獨立目錄,Alloy 只掛那個目錄。我目前先接受這個折衷,列為待改善項目。
先試跑,再上線
我沒有直接改正式的 Alloy。做法是起一個臨時的 Alloy 容器,把輸出元件換成 loki.echo——只把處理後的結果印在 stdout,完全不寫入 Loki。這樣可以安心反覆調整 regex。試跑結果:11 筆慢查詢全部解析成功、0 錯誤,在輸出裡搜尋敏感字串 0 筆,確認後才把設定套用到正式的 Alloy。這個「echo 試跑」的流程我很推薦,任何會改動 log 內容的 pipeline 都應該先這樣驗。
至於 Query_time,我沒有把它升為 label(數值型 label 基數會爆),而是留在內文,查詢時用 regexp 抽出來再 unwrap 計算,見下一節。
Grafana 儀表板與告警
首先讓 Grafana 認得 Loki。跟上一篇的 Prometheus 一樣用 provisioning,在 grafana/provisioning/datasources/ 多放一個檔案,重啟 Grafana 就會自動建立資料來源。uid 固定下來,儀表板與告警規則才能穩定引用它:
# grafana/provisioning/datasources/loki.yml
apiVersion: 1
datasources:
- name: Loki
uid: loki # 儀表板與告警用這個 uid 引用
type: loki
access: proxy # 由 Grafana 後端代查,Loki 不必對外開放
url: http://loki:3100
jsonData:
maxLines: 1000 # Explore / logs 面板一次最多回傳的行數
資料來源建好之後,我做了一個「App Logs」儀表板,面板包含:
- 各服務的錯誤數(ERROR 以上)
- 依服務與等級的 log 量趨勢
- nginx 狀態碼分佈(2xx / 3xx / 4xx / 5xx)
- log 明細:可用「服務、等級、關鍵字」三個變數篩選
- MySQL 慢查詢:次數、各帳號最長執行時間、明細
用到的 LogQL 大致如下,都是很基本的寫法:
# 各服務過去 5 分鐘的 ERROR 以上筆數
sum by (app) (count_over_time({source="laravel", level=~"ERROR|CRITICAL|ALERT|EMERGENCY"}[5m]))
# nginx 狀態碼分佈
sum by (status_class) (count_over_time({app="nginx", source="nginx-access"}[$__interval]))
# log 明細:服務 / 等級 / 關鍵字 三個儀表板變數
{app=~"$app", level=~"$level"} |= "$keyword"
# MySQL 慢查詢次數(依帳號)
sum by (db_user) (count_over_time({app="mysql", source="mysql-slow"}[$__interval]))
# 每個帳號最長的查詢時間(秒)
max by (db_user) (
max_over_time(
{app="mysql", source="mysql-slow"}
| regexp `# Query_time: (?P<qt>[\d.]+)`
| unwrap qt [$__interval]
)
)
最後一條就是用 regexp 從內文抽出 Query_time,再用 unwrap 當成數值取最大值。這正是「不把 Query_time 當 label」的好處:資料保持低基數,又照樣能算統計。
兩條告警
儀表板是用來「看」的,真正救命的是告警。我只設了兩條,避免告警疲勞,都送 Slack:
| 告警 | LogQL | 閾值 |
|---|---|---|
| 應用程式錯誤 log 暴增 | sum by (app) (count_over_time({source="laravel", level=~"ERROR|CRITICAL|ALERT|EMERGENCY"}[5m])) | 5 分鐘 > 10 筆 |
| nginx 5xx 回應偏多 | sum(count_over_time({app="nginx", status_class="5xx"}[5m])) | 5 分鐘 > 20 次 |
兩條都放在 Grafana 的 provisioning(alerting/rules.yaml 的 logs 群組),每分鐘評估一次,noDataState: OK——沒有資料代表沒有錯誤,不該告警。設定檔進版控,環境壞掉重建時告警不會消失。這裡也是 status_class 派上用場的地方:5xx 這個 label 直接用在選擇器上,查詢非常快。
小結
回顧這一輪,一台 2 核 12 GB 的小機器,用 Loki + Alloy 就把五類 log 集中起來,而且資源壓得很低。整理幾個我覺得最值得帶走的重點:
- 不掛 docker.sock:只讀 log 檔、全部
:ro掛載,比起 docker.sock 風險小很多;但 root、label=disable與 MySQL 資料目錄的掛載都是取捨,要老實寫清楚。 - label 只放低基數的值:app、source、level、env、status_class、db_user;狀態碼轉
2xx/3xx/4xx/5xx,Query_time 用 unwrap 而非 label。 - 送進 Loki 前就遮罩:FCM token、密碼類欄位、Bearer、email 帳號,再加上 SQL 字串常值換成
?;但遮罩只是防線之一,程式本身仍不該印敏感資料。 - 第一次補收舊 log 的三個坑:加
stage.drop older_than = "696h"、把 schema 的from改早、必要時POST /flush。 - 單檔 bind mount 不要用 sed -i:用 tee 原地覆寫或重建容器。
- 改 pipeline 先用 loki.echo 試跑:不寫入、只印出,確認解析與遮罩都正確才上線。
下一步我打算把 Laravel 的 request id 串進 log,這樣可以從一個 nginx 5xx 直接跳到對應的 Laravel exception 與 MySQL 慢查詢。如果你也在用 Laravel 又不想養 ELK,Loki + Alloy 絕對是值得先試的組合。
系列文章:自架 VM 維運實戰
保護 → 部署 → 觀測 → 調校,建議依序閱讀:
- 第 1 篇:【資安】Cloudflare Access 實戰:不用 VPN 保護後台與 Grafana
- 第 2 篇:【CI/CD】Jenkins 打造安全的 Laravel 部署:JCasC 與受限 SSH
- 第 3 篇:【監控】Prometheus + Grafana 監控 Docker 服務與 Slack 告警
- 第 4 篇:【監控】Loki + Alloy 集中 Laravel 與 Nginx Log:遮罩與踩坑(本篇)
- 第 5 篇:【效能】k6 壓測 Laravel Octane:找出 2 核 VM 的真實上限
No comments yet.