feat: log aggregation — the machine ships its logs to Loki
The gameplay logs sat on /dump on the machine, readable only over SSH. Now they land in Loki for query and tail: - k8s/loki/loki.yaml: Loki 3.4.4 monolithic on the home cluster's monitoring namespace — filesystem storage on CephFS, 30-day retention, traefik ingress at loki.k8s-home.lqx.net with a cert-manager cert, Grafana datasource auto-registered via the sidecar. reject_old_samples_max_age is a year: the machines backfill their history on first ship. - shipper/: logshipper, a ~300-line static Go service for the machine. Tails /dump/mode.log and /dump/switch.log, batches, pushes to Loki's push API. Per-file offsets are saved only after a successful push (down Loki = retry-then-catch-up, never loss); first run starts at each file's end, SHIP_FROM_START=1 ships history; truncation is noticed. Lines keep their own RFC 3339 stamp as the Loki timestamp; unstamped lines (the boot banner) inherit the next stamped line's, so a stream never starts at "now" and jumps back. After a backfill it calls /flush — old entries are invisible in the ingester's head until they reach the store (found the hard way: Loki answers 204 to an old entry on a new stream and then never serves it). Labels: job=pinball, machine, source, game (from the boot line). /etc/init.d/log_shipper runs it at boot. Deployed: Loki is live and verified (query through the ingress). The shipper is verified end-to-end against the cluster from this Mac; the machine was unreachable at deploy time — the README has the install commands. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+221
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
// logshipper - tails the pinball machine's gameplay logs and pushes them to Loki.
|
||||
//
|
||||
// Tails /dump/mode.log and /dump/switch.log, batches their lines, and POSTs them to
|
||||
// Loki's /loki/api/v1/push. Per-file read offsets are saved only after a successful
|
||||
// push, so a down Loki means retry-then-catch-up, never loss. Configuration is env:
|
||||
//
|
||||
// LOKI_URL push target (default https://loki.k8s-home.lqx.net)
|
||||
// SHIP_FILES comma list (default /dump/mode.log,/dump/switch.log)
|
||||
// SHIP_STATE offset state file (default /dump/.logshipper.state)
|
||||
// MACHINE the machine label (default this host's name)
|
||||
// SHIP_FROM_START 1 = ship from each file's start on first run (default: from its end)
|
||||
// SHIP_INSECURE 1 = skip TLS verification (for a Loki without a trusted cert)
|
||||
// SHIP_EVERY poll interval (default 2s)
|
||||
//
|
||||
// Runs under /etc/init.d/log_shipper; its stderr goes to the syslog.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
lokiURL = envOr("LOKI_URL", "https://loki.k8s-home.lqx.net")
|
||||
files = strings.Split(envOr("SHIP_FILES", "/dump/mode.log,/dump/switch.log"), ",")
|
||||
statePath = envOr("SHIP_STATE", "/dump/.logshipper.state")
|
||||
machine = envOr("MACHINE", hostname())
|
||||
fromStart = os.Getenv("SHIP_FROM_START") == "1"
|
||||
insecure = os.Getenv("SHIP_INSECURE") == "1"
|
||||
every = 2 * time.Second
|
||||
maxPush = 3 << 20 // under Loki's 4 MiB push limit
|
||||
)
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func hostname() string {
|
||||
if h, err := os.Hostname(); err == nil && h != "" {
|
||||
return h
|
||||
}
|
||||
return "stern-spike2"
|
||||
}
|
||||
|
||||
// readNewLines returns the complete lines added to path since offset, and the offset to
|
||||
// save once they are pushed (never past the last newline). A file truncated under us
|
||||
// (": > mode.log" is safe housekeeping) restarts from 0.
|
||||
func readNewLines(path string, offset int64) (lines []string, sent int64, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, offset, err
|
||||
}
|
||||
defer f.Close()
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, offset, err
|
||||
}
|
||||
resume := offset
|
||||
if fi.Size() < offset {
|
||||
resume = 0
|
||||
}
|
||||
if _, err = f.Seek(resume, io.SeekStart); err != nil {
|
||||
return nil, offset, err
|
||||
}
|
||||
buf, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, resume, err
|
||||
}
|
||||
s := string(buf)
|
||||
if cut := strings.LastIndexByte(s, '\n'); cut >= 0 {
|
||||
lines = strings.Split(strings.TrimSuffix(s[:cut+1], "\n"), "\n")
|
||||
sent = resume + int64(cut) + 1
|
||||
} else {
|
||||
sent = resume // no complete line yet
|
||||
}
|
||||
return lines, sent, nil
|
||||
}
|
||||
|
||||
func push(payload []byte) error {
|
||||
return post("/loki/api/v1/push", payload)
|
||||
}
|
||||
|
||||
// pushFlush asks Loki to write the ingester's chunks to the store now.
|
||||
func pushFlush() error {
|
||||
return post("/flush", nil)
|
||||
}
|
||||
|
||||
func post(path string, payload []byte) error {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if insecure {
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second, Transport: transport}
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
body = bytes.NewReader(payload)
|
||||
}
|
||||
resp, err := client.Post(lokiURL+path, "application/json", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
read, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("loki said %s: %s", resp.Status, strings.TrimSpace(string(read)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type shipFile struct {
|
||||
path string
|
||||
source string
|
||||
game string
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.LUTC)
|
||||
if v := os.Getenv("SHIP_EVERY"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
every = d
|
||||
}
|
||||
}
|
||||
st, err := loadState(statePath)
|
||||
if err != nil {
|
||||
log.Printf("state file unreadable (%v) - starting fresh", err)
|
||||
st = state{}
|
||||
}
|
||||
var ship []shipFile
|
||||
for _, path := range files {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
ship = append(ship, shipFile{path: path, source: baseName(path)})
|
||||
}
|
||||
log.Printf("shipping %s to %s (labels: job=pinball machine=%s, every %s)",
|
||||
strings.Join(files, ", "), lokiURL, machine, every)
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
tick := time.Tick(every)
|
||||
for {
|
||||
for i := range ship {
|
||||
f := &ship[i]
|
||||
offset, known := st[baseName(f.path)]
|
||||
if !known && !fromStart {
|
||||
// first run: start from the file's end; run once with SHIP_FROM_START=1
|
||||
// to ship the history already on disk
|
||||
if fi, err := os.Stat(f.path); err == nil {
|
||||
offset = fi.Size()
|
||||
}
|
||||
}
|
||||
lines, sent, err := readNewLines(f.path, offset)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Printf("%s: %v", f.path, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
continue
|
||||
}
|
||||
stamps := assignTimestamps(lines, time.Now())
|
||||
var entries []Entry
|
||||
for i, line := range lines {
|
||||
if g := extractGame(line); g != "" {
|
||||
f.game = g
|
||||
}
|
||||
entries = append(entries, Entry{Source: f.source, Game: f.game, TS: stamps[i], Line: line})
|
||||
}
|
||||
pushed := true
|
||||
for _, payload := range buildPayloads(entries, machine, maxPush) {
|
||||
if err := push(payload); err != nil {
|
||||
log.Printf("push failed (%v) - %s stays at offset %d", err, f.path, offset)
|
||||
pushed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !pushed {
|
||||
continue // the offset is saved only when the whole batch is in Loki
|
||||
}
|
||||
if needsFlush(entries, time.Now()) {
|
||||
// a backfill: the old entries are invisible in the ingester's head until
|
||||
// they reach the store (live tails never come through here)
|
||||
if err := pushFlush(); err != nil {
|
||||
log.Printf("flush failed (%v) - old entries may not be queryable yet", err)
|
||||
}
|
||||
}
|
||||
st[baseName(f.path)] = sent
|
||||
if err := saveState(statePath, st); err != nil {
|
||||
log.Printf("state save failed: %v", err)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-sig:
|
||||
log.Printf("stopping")
|
||||
return
|
||||
case <-tick:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func baseName(path string) string {
|
||||
if i := strings.LastIndexByte(path, '/'); i >= 0 {
|
||||
return path[i+1:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
Reference in New Issue
Block a user