
/blogs
ลองใช้ Grafana Alloy เก็บ log เข้า Loki
Introduction
สวัสดีครับ พอดีที่บ้านผมมี container รันอยู่หลายตัว แล้วเวลาจะดู log ทีก็ต้อง docker logs ไล่ทีละตัว ซึ่งพอมันเยอะขึ้นเรื่อย ๆ ก็เริ่มไม่ไหวแล้วครับ เลยคิดว่าถึงเวลารวม log ไว้ที่เดียวสักที
ตัวที่คนใช้กันมานานคือ Promtail แต่พอไปเปิดเอกสารดูก็เจอบรรทัดนี้ครับ "Promtail is end of life (EOL) as of March 2, 2026" คือหมดอายุไปตั้งแต่ต้นปีแล้ว ไม่มี update ไม่มี security fix อีกต่อไป (Grafana Agent ก็หมดอายุไปก่อนหน้านั้นตั้งแต่ 1 พฤศจิกายน 2025 เหมือนกัน) ตัวที่ Grafana ให้ย้ายไปคือ Alloy ผมเลยข้ามไปลองตัวนี้เลยดีกว่า
บทความนี้ผมรันจริงทั้งหมดบนเครื่องตัวเองด้วย docker นะครับ output ที่แปะไว้ก๊อปมาจาก terminal ตรง ๆ ไม่ได้พิมพ์ขึ้นมาเอง เวอร์ชันที่ใช้คือ Alloy v1.18.0 กับ Loki 3.7.4
ของที่ต้องมีก่อนคือ docker กับ jq (เอาไว้อ่าน JSON ที่ Loki ตอบกลับมา ถ้ายังไม่มีก็ brew install jq หรือ apt install jq) แล้วก็ต้องมี port 3100 กับ 12345 ว่างอยู่ด้วย
Alloy คืออะไร
Alloy คือ distribution ของ OpenTelemetry Collector ที่ Grafana ทำขึ้นมาเองครับ (OpenTelemetry Collector คือตัวกลางมาตรฐานที่คอยรับข้อมูลพวก log, metrics, traces จากที่นึงแล้วส่งต่อไปอีกที่นึง) เอาไว้เก็บ log, metrics, traces แล้วก็ profiles ส่งไปยังปลายทางที่เราต้องการ พูดง่าย ๆ คือของเดิมที่เคยแยกเป็น Promtail (log) กับ Prometheus agent (metrics) ตอนนี้รวมมาอยู่ในตัวเดียวแล้ว
ที่ผมชอบคือมันเขียน config เป็นก้อน ๆ เรียกว่า component แล้วเอา output ของก้อนนึงต่อเข้า input ของอีกก้อนได้เลย ไม่ได้เขียนเป็น YAML ก้อนใหญ่ ๆ ที่ต้องมานั่งไล่ว่า key ไหนอยู่ใต้ key ไหน เดี๋ยวจะได้เห็นกันครับ
เช็คเวอร์ชันได้แบบนี้
docker run --rm grafana/alloy:latest --version
alloy, version v1.18.0 (branch: HEAD, revision: a435563)
build user: root@buildkitsandbox
build date: 2026-07-20T09:02:30Z
go version: go1.26.5
platform: linux/arm64
tags: gore2regex,netgo,embedalloyui,promtail_journal_enabled
วิธีการติดตั้ง
ผมยก Loki ขึ้นมาก่อนเพื่อให้มีปลายทางให้ส่ง แล้วค่อยตาม Alloy ทีหลัง สร้าง network ให้สองตัวคุยกันได้ด้วย
docker network create alloy-net
docker run -d --name loki-demo \
--network alloy-net \
-p 3100:3100 \
grafana/loki:latest
Loki ใช้เวลาบูตพอสมควรครับ ของผมรอไป 17 วินาทีกว่าจะพร้อมรับ query เช็คได้แบบนี้
curl -s http://localhost:3100/ready
ถ้ายังไม่พร้อมจะได้ Ingester not ready: waiting for 15s after being ready กลับมา รอจนกว่าจะขึ้นคำว่า ready แล้วค่อยไปต่อ
จากนั้นเตรียมโฟลเดอร์กับไฟล์ log ตัวอย่างที่จะให้ Alloy อ่าน
mkdir -p ~/alloy/logs
echo '{"level":"info","msg":"service started"}' > ~/alloy/logs/app.log
เขียน config
สร้างไฟล์ ~/alloy/config.alloy ขึ้นมาครับ จะใช้ editor เปิดเขียนเองก็ได้ หรือก๊อปทั้งก้อนนี้ไปวางใน terminal เลยก็ได้
cat > ~/alloy/config.alloy <<'EOF'
local.file_match "applog" {
path_targets = [{
__path__ = "/var/log/demo/*.log",
job = "demo-app",
host = "homelab",
}]
}
loki.source.file "applog" {
targets = local.file_match.applog.targets
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint {
url = "http://loki-demo:3100/loki/api/v1/push"
}
}
EOF
ขั้นตอนนี้ห้ามข้ามนะครับ ถ้ายังไม่มีไฟล์นี้แล้วไปสั่ง docker run เลย docker จะสร้างโฟลเดอร์ชื่อ config.alloy ให้แทน แล้ว container จะพังตั้งแต่ยังไม่ทันขึ้น พร้อม error ที่งงมากว่า not a directory แถม docker logs ก็ว่างเปล่าไม่มีอะไรให้ดูด้วย
อ่าน config จากบนลงล่างได้เลยครับ
local.file_matchคือตัวบอกว่าจะอ่านไฟล์ไหนบ้าง ตรงjobกับhostคือ label ที่จะติดไปกับ log ทุกบรรทัด เอาไว้ค้นทีหลังloki.source.fileคือตัวอ่านไฟล์ สังเกตว่าtargetsชี้ไปที่ผลลัพธ์ของบล็อกแรกตรง ๆ (local.file_match.applog.targets) แล้วforward_toก็ชี้ต่อไปยังบล็อกที่สามloki.writeคือตัวส่งออก
การต่อกันแบบนี้แหละครับที่เป็นจุดขายของ Alloy คือ output ของ component นึงเสียบเข้า input ของอีกตัวได้เลย
อีกเรื่องที่ควรรู้ไว้คือ loki.source.file มีบล็อก file_match ในตัวอยู่แล้ว ใส่ glob ลงไปใน __path__ ตรง ๆ ได้โดยไม่ต้องมี local.file_match แยกออกมา แต่ผมเลือกเขียนแยกเพราะอยากให้เห็นภาพว่า component ต่อกันยังไง แล้วถ้าวันหลังมี component อื่นอยากใช้ targets ชุดเดียวกันก็ดึงไปใช้ต่อได้
ทีนี้รันได้แล้ว
docker run -d --name alloy-demo \
--network alloy-net \
-p 12345:12345 \
-v ~/alloy/config.alloy:/etc/alloy/config.alloy \
-v ~/alloy/logs:/var/log/demo \
grafana/alloy:latest \
run --server.http.listen-addr=0.0.0.0:12345 \
--storage.path=/var/lib/alloy/data \
/etc/alloy/config.alloy
ตรง --server.http.listen-addr=0.0.0.0:12345 อย่าลืมใส่นะครับ ถ้าไม่ใส่มันจะไป bind ที่ 127.0.0.1 ข้างใน container แล้วเราจะเข้าหน้า UI จากข้างนอกไม่ได้เลย
หน้า UI ของ Alloy
เปิด http://localhost:12345 แล้วไปที่เมนู Graph ได้เลยครับ (หน้าแรกที่เด้งขึ้นมาจะเป็น Components) มันจะวาด pipeline ให้ดูว่า component ไหนต่อกับอันไหน

นี่แหละครับคือสิ่งที่อธิบายไปเมื่อกี้ local.file_match ยิงไปหา loki.source.file แล้วต่อไป loki.write ตรงตาม config ที่เขียนไว้เป๊ะ ๆ เส้นสีเขียวคือ log ถ้ามี metrics หรือ traces ด้วยก็จะเป็นคนละสี อันนี้มีประโยชน์มากตอน config เริ่มใหญ่ เพราะดูรูปทีเดียวรู้เลยว่าอะไรต่อกับอะไร
เช็คว่า log เข้าจริง
ลองเขียน log เพิ่มเข้าไปในไฟล์
for i in 1 2 3; do
echo "{\"level\":\"info\",\"msg\":\"handled request $i\"}" >> ~/alloy/logs/app.log
done
echo '{"level":"error","msg":"database connection refused"}' >> ~/alloy/logs/app.log
รอสัก 2-3 วินาทีให้ Alloy อ่านไฟล์แล้วส่งเข้า Loki ก่อนนะครับ ถ้ายิง query ทันทีจะเห็นแค่บรรทัดแรกบรรทัดเดียวแล้วนึกว่าพัง
ถามก่อนว่า Loki รู้จัก label อะไรบ้าง
curl -s http://localhost:3100/loki/api/v1/labels
{ "status": "success", "data": ["filename", "host", "job", "service_name"] }
job กับ host คือที่เราใส่ไว้ใน config เอง ส่วนอีกสองตัวมาคนละที่กันครับ filename เป็นของ Alloy ที่เอา __path__ มาแปะให้ทุกบรรทัด ส่วน service_name เป็นของ Loki ที่ map มาจาก label job ให้อัตโนมัติตอน ingest
ทีนี้ลองดึง log ออกมาดูจริง ๆ
curl -sG http://localhost:3100/loki/api/v1/query_range \
--data-urlencode 'query={job="demo-app"}' \
--data-urlencode 'limit=10'
ที่ได้กลับมาคือ JSON ก้อนเดียวยาว ๆ หน้าตาประมาณนี้
{"status":"success","data":{"resultType":"streams","result":[{"stream":{"detected_level":"error","filename":"/var/log/demo/app.log","host":"homelab","job":"demo-app","service_name":"demo-app"},"values":[["1785760821561036812","{\"level\":\"error\",\"msg\":\"database connection refused\"}"]]},...
อ่านไม่ไหวครับ ต่อ jq เอาเฉพาะบรรทัด log ดีกว่า
curl -sG http://localhost:3100/loki/api/v1/query_range \
--data-urlencode 'query={job="demo-app"}' \
--data-urlencode 'limit=10' | jq -r '.data.result[].values[][1]'
{"level":"error","msg":"database connection refused"}
{"level":"info","msg":"handled request 3"}
{"level":"info","msg":"handled request 2"}
{"level":"info","msg":"handled request 1"}
{"level":"info","msg":"service started"}
ครบทั้ง 5 บรรทัดที่เขียนลงไฟล์ไป
กลับไปดูใน JSON ก้อนบนอีกทีจะเห็น detected_level โผล่มาด้วย อันนี้ผมไม่ได้ใส่ไว้ใน config เลยครับ เป็น Loki ที่อ่านเนื้อ log แล้วแยกให้เองว่าบรรทัดไหน error บรรทัดไหน info ทั้งที่ผมไม่ได้เขียน parser อะไรไว้สักตัว
ถ้าอยากกรองเฉพาะ error ก็ใส่ filter ต่อท้าย query ได้
curl -sG http://localhost:3100/loki/api/v1/query_range \
--data-urlencode 'query={job="demo-app"} |= "error"' \
--data-urlencode 'limit=10' | jq -r '.data.result[].values[][1]'
{"level":"error","msg":"database connection refused"}
ตรวจ config ก่อนรัน
Alloy มีคำสั่ง validate ให้ใช้ครับ ไม่ต้องรันจริงแล้วมานั่งดู log ว่าพังตรงไหน
docker run --rm -v ~/alloy/config.alloy:/c.alloy \
grafana/alloy:latest validate /c.alloy
ถ้าผ่านจะไม่มีอะไรขึ้นมาเลย ทีนี้ลองทำให้มันพังดูบ้าง ผมสร้างไฟล์ bad.alloy ที่ใส่ attribute มั่ว ๆ เข้าไปหนึ่งตัว
cat > ~/alloy/bad.alloy <<'EOF'
loki.write "default" {
endpoint {
url = "http://loki-demo:3100/loki/api/v1/push"
}
bogus_arg = 1
}
EOF
docker run --rm -v ~/alloy/bad.alloy:/bad.alloy \
grafana/alloy:latest validate /bad.alloy
Error: /bad.alloy:5:3: unrecognized attribute name "bogus_arg"
4 | }
5 | bogus_arg = 1
| ^^^^^^^^^^^^^
6 | }
Error: validation failed
2026/08/03 13:32:02 collector server run finished with error: validation failed
ชี้ให้เลยว่าบรรทัด 5 ตัวอักษรที่ 3 พร้อมขีดเส้นใต้ให้ด้วย
ระวังอย่าไปสับสนกับคำสั่ง fmt นะครับ สองตัวนี้ตรวจคนละชั้นกัน fmt ดูแค่ syntax คือวงเล็บปิดครบไหม โครงสร้างถูกไหม (ผมลองเอา config ที่ลืมปิดวงเล็บไปให้ มันฟ้อง expected }, got EOF แล้ว exit 1) แต่มันไม่เช็คว่าชื่อ attribute หรือ component นั้นมีอยู่จริงหรือเปล่า ตัว bogus_arg ข้างบนผ่าน fmt ฉลุยด้วย exit 0 แถมยังพ่น config ที่จัดหน้าเรียบร้อยแล้วออกมาให้อีก ต้องใช้ validate ถึงจะจับได้
ย้ายจาก Promtail
ใครที่มี config Promtail อยู่แล้วไม่ต้องนั่งแปลมือครับ Alloy มีคำสั่ง convert ให้ ผมลองเอา config สั้น ๆ แบบนี้ไปแปลงดู
cat > ~/alloy/promtail.yaml <<'EOF'
clients:
- url: http://localhost:3100/loki/api/v1/push
scrape_configs:
- job_name: system
static_configs:
- targets: [localhost]
labels:
job: varlogs
__path__: /var/log/*.log
EOF
docker run --rm -v ~/alloy/promtail.yaml:/p.yaml \
grafana/alloy:latest convert --source-format=promtail /p.yaml
ได้ผลออกมาแบบนี้
loki.source.file "system" {
targets = [{
__address__ = "localhost",
__path__ = "/var/log/*.log",
job = "varlogs",
}]
forward_to = [loki.write.default.receiver]
file_match {
enabled = true
}
legacy_positions_file = "/var/log/positions.yaml"
}
loki.write "default" {
endpoint {
url = "http://localhost:3100/loki/api/v1/push"
}
external_labels = {}
}
สังเกตว่ามันเลือกใช้ file_match block ในตัวของ loki.source.file แทนที่จะแยก local.file_match ออกมาเหมือนที่ผมเขียนไว้ข้างบน ซึ่งก็ถูกทั้งคู่ครับ
ใช้ได้เลยแต่แนะนำให้อ่านทวนก่อนสักรอบ อย่าเอาไปใช้ทั้งดุ้นเลยทีเดียว
ลบของทิ้ง
ลองเสร็จแล้วเก็บกวาดด้วยนะครับ
docker rm -f alloy-demo loki-demo
docker network rm alloy-net
rm -rf ~/alloy
สรุป
ที่ลองมาผมว่า Alloy เข้าใจง่ายกว่า Promtail อยู่พอสมควรครับ ตรงที่ config เป็น component ต่อกันเป็นท่อ แล้วมีหน้า UI ให้ดูว่าท่อมันต่อถูกไหม เวลาพังจะหาต้นตอง่ายกว่าเยอะ
ที่เขียนในบทความนี้เป็นแค่ log อย่างเดียวนะครับ จริง ๆ Alloy ทำ metrics กับ traces ได้ด้วยในตัวเดียวกัน ถ้ามีโอกาสจะมาเขียนต่อ
ส่วนใครที่ยังใช้ Promtail อยู่ ถึงตอนนี้มันยังทำงานได้ปกติก็จริง แต่ EOL ไปแล้วตั้งแต่เดือนมีนาคมครับ แปลว่าถ้ามีช่องโหว่โผล่มาก็จะไม่มีใครแก้ให้แล้ว ควรวางแผนย้ายไว้แต่เนิ่น ๆ ดีกว่า และมีคำสั่ง convert ช่วยอยู่แล้วด้วย
References
Related Articles



