17 Commits

Author SHA1 Message Date
ff8cf98fa4 fix link 2025-11-05 09:02:51 +01:00
e813394d6b fix badge again 2025-10-31 23:21:56 +01:00
6f7b7eee28 fix badge 2025-10-31 23:21:22 +01:00
3a290956ae fix release 2025-10-31 22:55:16 +01:00
T. von Dein
7854627ff3 move to codeberg (#1)
Co-authored-by: Thomas von Dein <tom@vondein.org>
Reviewed-on: https://codeberg.org/scip/io-exporter/pulls/1
2025-10-31 22:29:44 +01:00
T.v.Dein
662dc39e8f dashboard and -h fix
* add grafana dashboard
* test png
* print help when -h is present regardless of other flags
2025-10-25 22:20:36 +02:00
T.v.Dein
b3e15c7b59 Grafana (#7) 2025-10-24 11:12:06 +02:00
c1199931ee log errors to error channel, not debug 2025-10-23 20:08:10 +02:00
903401f511 add badges to readme 2025-10-23 17:48:36 +02:00
fdb4090a6e fix #5: nobody understands context deadline exceeded, use timeout 2025-10-23 17:42:05 +02:00
d46b731f06 fix #4: do not compare read+write block on failure 2025-10-23 17:37:34 +02:00
ca19721084 fix #6: do not report elapsed time on failure 2025-10-23 17:32:37 +02:00
b48ea00b22 oops 2025-10-23 12:59:54 +02:00
ad80542619 return wg pointer 2025-10-23 12:41:34 +02:00
62f5b51be8 add waitgroup for goroutine 1 2025-10-23 12:39:09 +02:00
T.v.Dein
99222b1cae Refactor exporter (#3)
* refactor exporter code for better readability and structure
* modified startup log output a little
2025-10-23 12:14:33 +02:00
T.v.Dein
5184c3a03e Separate io tests to read and write mode with separate latencies (#2) 2025-10-22 18:02:26 +02:00
17 changed files with 1024 additions and 112 deletions

65
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,65 @@
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
- go mod tidy
gitea_urls:
api: https://codeberg.org/api/v1
download: https://codeberg.org
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- freebsd
archives:
- formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}_{{ .Tag }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: [zip]
- goos: linux
formats: [tar.gz,binary]
files:
- src: "*.md"
strip_parent: true
- src: Makefile.dist
dst: Makefile
wrap_in_directory: true
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
groups:
- title: Improved
regexp: '^.*?(feat|add|new)(\([[:word:]]+\))??!?:.+$'
order: 0
- title: Fixed
regexp: '^.*?(bug|fix)(\([[:word:]]+\))??!?:.+$'
order: 1
- title: Changed
order: 999
release:
header: "# Release Notes"
footer: >-
---
Full Changelog: [{{ .PreviousTag }}...{{ .Tag }}](https://codeberg.org/scip/io-exporter/compare/{{ .PreviousTag }}...{{ .Tag }})

27
.woodpecker/build.yaml Normal file
View File

@@ -0,0 +1,27 @@
matrix:
platform:
- linux/amd64
goversion:
- 1.24
labels:
platform: ${platform}
steps:
build:
when:
event: [push]
image: golang:${goversion}
commands:
- go get
- go build
- go test
linter:
when:
event: [push]
image: golang:${goversion}
commands:
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.5.0
- golangci-lint --version
- golangci-lint run ./...

32
.woodpecker/image.yaml Normal file
View File

@@ -0,0 +1,32 @@
# https://woodpecker-ci.org/plugins/docker-buildx
# enable Package unit and go to /scip/-/packages after building to link to proj
variables:
- &repo codeberg.org/${CI_REPO_OWNER}/io-exporter
steps:
dryrun:
image: docker.io/woodpeckerci/plugin-docker-buildx:latest
settings:
dockerfile: Dockerfile
platforms: linux/amd64
dry_run: true
repo: *repo
tags: latest
when:
event: [pull_request]
publish:
image: docker.io/woodpeckerci/plugin-docker-buildx:latest
settings:
dockerfile: Dockerfile
platforms: linux/amd64
repo: *repo
registry: codeberg.org
tags: latest,${CI_COMMIT_SHA:0:8},${CI_COMMIT_TAG}
username: ${CI_REPO_OWNER}
password:
from_secret: REGISTRY_TOKEN
when:
event: [tag]
branch: main

15
.woodpecker/release.yaml Normal file
View File

@@ -0,0 +1,15 @@
# build release
labels:
platform: linux/amd64
steps:
goreleaser:
image: goreleaser/goreleaser
when:
event: [tag]
environment:
GITEA_TOKEN:
from_secret: DEPLOY_TOKEN
commands:
- goreleaser release --clean --verbose

View File

@@ -1,4 +1,4 @@
FROM golang:1.24-alpine as builder FROM golang:1.24-alpine AS builder
RUN apk update RUN apk update
RUN apk upgrade RUN apk upgrade

18
Makefile.dist Normal file
View File

@@ -0,0 +1,18 @@
# -*-make-*-
.PHONY: install all
tool = epuppy
PREFIX = /usr/local
UID = root
GID = 0
all:
@echo "Type 'sudo make install' to install the tool."
@echo "To change prefix, type 'sudo make install PREFIX=/opt'"
install:
install -d -o $(UID) -g $(GID) $(PREFIX)/bin
install -d -o $(UID) -g $(GID) $(PREFIX)/share/doc
install -o $(UID) -g $(GID) -m 555 $(tool) $(PREFIX)/sbin/
install -o $(UID) -g $(GID) -m 444 *.md $(PREFIX)/share/doc/

View File

@@ -1,3 +1,7 @@
[![status-badge](https://ci.codeberg.org/api/badges/15500/status.svg?branch=main)](https://ci.codeberg.org/repos/15500)
[![License](https://img.shields.io/badge/license-GPL-blue.svg)](https://codeberg.org/scip/io-exporter/raw/branch/main/LICENSE)
[![Go Report Card](https://goreportcard.com/badge/codeberg.org/scip/io-exporter)](https://goreportcard.com/report/codeberg.org/scip/io-exporter)
# io-exporter # io-exporter
Report if a given filesystem is operating properly Report if a given filesystem is operating properly
@@ -14,10 +18,15 @@ specified via commandline.
```default ```default
io-exporter [options] <file> io-exporter [options] <file>
Options: Options:
-t --timeout <int> When should the operation timeout in seconds -t --timeout <int> When should the operation timeout in seconds
-l --label <label=value> Add label to exported metric -s --sleeptime <int> Time to sleep between checks (default: 5s)
-h --help Show help -l --label <label=value> Add label to exported metric
-v --version Show program version -i --internals Also add labels about resource usage
-r --read Only execute the read test
-w --write Only execute the write test
-d --debug Enable debug log level
-h --help Show help
-v --version Show program version
``` ```
## Output ## Output
@@ -31,14 +40,20 @@ io-exporter -l foo=bar -l blah=blubb t/blah
You'll get such metrics: You'll get such metrics:
```default ```default
# HELP io_exporter_io_latency how long does the operation take in seconds
# TYPE io_exporter_io_latency gauge
io_exporter_io_latency{file="/tmp/blah",maxwait="1",namespace="debug",pod="foo1"} 0.0001142815
# HELP io_exporter_io_operation whether io is working on the pvc, 1=ok, 0=fail # HELP io_exporter_io_operation whether io is working on the pvc, 1=ok, 0=fail
# TYPE io_exporter_io_operation gauge # TYPE io_exporter_io_operation gauge
io_exporter_io_operation{file="/tmp/blah",maxwait="1",namespace="debug",pod="foo1"} 1 io_exporter_io_operation{blah="blubb",exectime="1761148383705",file="t/blah",foo="bar",maxwait="1"} 1
# HELP io_exporter_io_read_latency how long does the read operation take in seconds
# TYPE io_exporter_io_read_latency gauge
io_exporter_io_read_latency{blah="blubb",exectime="1761148383705",file="t/blah",foo="bar",maxwait="1"} 0.0040411716
# HELP io_exporter_io_write_latency how long does the write operation take in seconds
# TYPE io_exporter_io_write_latency gauge
io_exporter_io_write_latency{blah="blubb",exectime="1761148383705",file="t/blah",foo="bar",maxwait="1"} 0
``` ```
You may also restrict the exporter to only test read (`-r` flag) or
write (`-w` flag) operation.
## Installation ## Installation
There are no released binaries yet. There are no released binaries yet.
@@ -73,12 +88,21 @@ docker compose run -v ./t:/pvc ioexporter /pvc/testfile
Or use the pre-build image: Or use the pre-build image:
```default ```default
docker run -u `id -u $USER` -v ./t:/pvc ghcr.io/tlinden/io-exporter:latest /pvc/testfile docker run -u `id -u $USER` -v ./t:/pvc codeberg.org/scip/io-exporter:latest /pvc/testfile
``` ```
## Grafana
I provide a [sample dashboard](grafana), which you can add to your grafana or use
as a starting point to integrate it into your monitoring setup.
It looks like this:
![Screenshot](https://codeberg.org/scip/io-exporter/raw/branch/main/grafana/screenshot.png)
# Report bugs # Report bugs
[Please open an issue](https://github.com/TLINDEN/io-exporter/issues). Thanks! [Please open an issue](https://codeberg.org/scip/io-exporter/issues). Thanks!
# License # License

1
blah
View File

@@ -1 +0,0 @@
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

View File

@@ -1,6 +1,11 @@
package cmd package cmd
import "github.com/ncw/directio" import (
"bytes"
"errors"
"github.com/ncw/directio"
)
// aligned allocs used for testing // aligned allocs used for testing
type Alloc struct { type Alloc struct {
@@ -25,3 +30,12 @@ func NewAlloc() *Alloc {
readBlock: directio.AlignedBlock(directio.BlockSize), readBlock: directio.AlignedBlock(directio.BlockSize),
} }
} }
func (alloc *Alloc) Compare() bool {
// compare
if !bytes.Equal(alloc.writeBlock, alloc.readBlock) {
return report(errors.New("read not the same as written"), nil)
}
return true
}

View File

@@ -15,7 +15,7 @@ import (
) )
const ( const (
Version = `v0.0.4` Version = `v0.0.8`
SLEEP = 5 SLEEP = 5
Usage = `io-exporter [options] <file> Usage = `io-exporter [options] <file>
Options: Options:
@@ -23,9 +23,15 @@ Options:
-s --sleeptime <int> Time to sleep between checks (default: 5s) -s --sleeptime <int> Time to sleep between checks (default: 5s)
-l --label <label=value> Add label to exported metric -l --label <label=value> Add label to exported metric
-i --internals Also add labels about resource usage -i --internals Also add labels about resource usage
-r --read Only execute the read test
-w --write Only execute the write test
-d --debug Enable debug log level -d --debug Enable debug log level
-h --help Show help -h --help Show help
-v --version Show program version` -v --version Show program version`
O_R = iota
O_W
O_RW
) )
// config via commandline flags // config via commandline flags
@@ -34,6 +40,8 @@ type Config struct {
Showhelp bool `koanf:"help"` // -h Showhelp bool `koanf:"help"` // -h
Internals bool `koanf:"internals"` // -i Internals bool `koanf:"internals"` // -i
Debug bool `koanf:"debug"` // -d Debug bool `koanf:"debug"` // -d
ReadMode bool `koanf:"read"` // -r
WriteMode bool `koanf:"write"` // -w
Label []string `koanf:"label"` // -v Label []string `koanf:"label"` // -v
Timeout int `koanf:"timeout"` // -t Timeout int `koanf:"timeout"` // -t
Port int `koanf:"port"` // -p Port int `koanf:"port"` // -p
@@ -60,6 +68,8 @@ func InitConfig(output io.Writer) (*Config, error) {
flagset.BoolP("help", "h", false, "show help") flagset.BoolP("help", "h", false, "show help")
flagset.BoolP("debug", "d", false, "enable debug logs") flagset.BoolP("debug", "d", false, "enable debug logs")
flagset.BoolP("internals", "i", false, "add internal metrics") flagset.BoolP("internals", "i", false, "add internal metrics")
flagset.BoolP("read", "r", false, "only execute the read test")
flagset.BoolP("write", "w", false, "only execute the write test")
flagset.StringArrayP("label", "l", nil, "additional labels") flagset.StringArrayP("label", "l", nil, "additional labels")
flagset.IntP("timeout", "t", 1, "timeout for file operation in seconds") flagset.IntP("timeout", "t", 1, "timeout for file operation in seconds")
flagset.IntP("port", "p", 9187, "prometheus metrics port to listen to") flagset.IntP("port", "p", 9187, "prometheus metrics port to listen to")
@@ -103,5 +113,10 @@ func InitConfig(output io.Writer) (*Config, error) {
conf.Labels = append(conf.Labels, Label{Name: parts[0], Value: parts[1]}) conf.Labels = append(conf.Labels, Label{Name: parts[0], Value: parts[1]})
} }
if !conf.ReadMode && !conf.WriteMode {
conf.ReadMode = true
conf.WriteMode = true
}
return conf, nil return conf, nil
} }

View File

@@ -1,47 +1,114 @@
package cmd package cmd
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"io" "io"
"log/slog" "log/slog"
"os" "os"
"sync"
"time" "time"
"github.com/ncw/directio" "github.com/ncw/directio"
) )
func die(err error, fd *os.File) bool { // our primary container for the io checks
slog.Debug("failed to check io", "error", err) type Exporter struct {
conf *Config
if fd != nil { alloc *Alloc
if err := fd.Close(); err != nil { metrics *Metrics
slog.Debug("failed to close filehandle", "error", err)
}
}
return false
} }
// Calls runcheck() with timeout type Result struct {
func runExporter(file string, alloc *Alloc, timeout time.Duration) bool { result bool
elapsed float64
}
func NewExporter(conf *Config, alloc *Alloc, metrics *Metrics) *Exporter {
return &Exporter{
conf: conf,
alloc: alloc,
metrics: metrics,
}
}
// starts the primary go-routine, which will run the io checks for ever
func (exp *Exporter) RunIOchecks() *sync.WaitGroup {
var wg sync.WaitGroup
wg.Add(1)
go func() {
for {
var res_r, res_w Result
exp.alloc.Clean()
if exp.conf.WriteMode {
res_w = exp.measure(O_W)
slog.Debug("elapsed write time", "elapsed", res_w.elapsed, "result", res_w.result)
}
if exp.conf.ReadMode {
res_r = exp.measure(O_R)
slog.Debug("elapsed read time", "elapsed", res_r.elapsed, "result", res_r.result)
}
if (exp.conf.WriteMode && exp.conf.ReadMode) && (res_r.result && res_w.result) {
if !exp.alloc.Compare() {
res_r.result = false
}
}
exp.metrics.Set(res_r, res_w)
time.Sleep(time.Duration(exp.conf.Sleeptime) * time.Second)
}
}()
return &wg
}
// call an io measurement and collect time needed
func (exp *Exporter) measure(mode int) Result {
start := time.Now()
result := exp.runExporter(mode)
// ns => s
now := time.Now()
elapsed := float64(now.Sub(start).Nanoseconds()) / 10000000000
// makes no sense to measure latency if operation failed
if !result {
elapsed = 0
}
return Result{elapsed: elapsed, result: result}
}
// Calls runcheck's with context timeout
func (exp *Exporter) runExporter(mode int) bool {
ctx := context.Background() ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, timeout) ctx, cancel := context.WithTimeout(ctx, time.Duration(exp.conf.Timeout)*time.Second)
defer cancel() defer cancel()
run := make(chan struct{}, 1) run := make(chan struct{}, 1)
var res bool var res bool
go func() { go func() {
res = runcheck(file, alloc) switch mode {
case O_R:
res = exp.runcheck_r()
case O_W:
res = exp.runcheck_w()
}
run <- struct{}{} run <- struct{}{}
}() }()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return die(ctx.Err(), nil) return report(ctx.Err(), nil)
case <-run: case <-run:
return res return res
} }
@@ -50,65 +117,66 @@ func runExporter(file string, alloc *Alloc, timeout time.Duration) bool {
// Checks file io on the specified path: // Checks file io on the specified path:
// //
// - open the file (create if it doesnt exist) // - opens it for reading
// - truncate it if it already exists
// - write some data to it
// - closes the file
// - re-opens it for reading
// - reads the block // - reads the block
// - compares if written block is equal to read block
// - closes file again // - closes file again
// //
// Returns false if anything failed during that sequence, // Returns false if anything failed during that sequence,
// true otherwise. // true otherwise.
func runcheck(file string, alloc *Alloc) bool { func (exp *Exporter) runcheck_r() bool {
alloc.Clean()
// write
fd, err := directio.OpenFile(file, os.O_RDWR|os.O_TRUNC|os.O_CREATE, 0640)
if err != nil {
die(err, nil)
}
for i := 0; i < len(alloc.writeBlock); i++ {
alloc.writeBlock[i] = 'A'
}
n, err := fd.Write(alloc.writeBlock)
if err != nil {
return die(err, fd)
}
if n != len(alloc.writeBlock) {
return die(errors.New("failed to write block"), fd)
}
if err := fd.Close(); err != nil {
return die(err, nil)
}
// read // read
in, err := directio.OpenFile(file, os.O_RDONLY, 0640) in, err := directio.OpenFile(exp.conf.File, os.O_RDONLY, 0640)
if err != nil { if err != nil {
die(err, nil) report(err, nil)
} }
n, err = io.ReadFull(in, alloc.readBlock) n, err := io.ReadFull(in, exp.alloc.readBlock)
if err != nil { if err != nil {
return die(err, in) return report(err, in)
} }
if n != len(alloc.writeBlock) { if n != len(exp.alloc.writeBlock) {
return die(errors.New("failed to read block"), fd) return report(errors.New("failed to read block"), in)
} }
if err := in.Close(); err != nil { if err := in.Close(); err != nil {
return die(err, nil) return report(err, nil)
} }
// compare return true
if !bytes.Equal(alloc.writeBlock, alloc.readBlock) { }
return die(errors.New("read not the same as written"), nil)
// Checks file io on the specified path:
//
// - open the file (create if it doesnt exist)
// - truncate it if it already exists
// - write some data to it
// - closes the file
//
// Returns false if anything failed during that sequence,
// true otherwise.
func (exp *Exporter) runcheck_w() bool {
// write
fd, err := directio.OpenFile(exp.conf.File, os.O_RDWR|os.O_TRUNC|os.O_CREATE, 0640)
if err != nil {
report(err, nil)
}
for i := 0; i < len(exp.alloc.writeBlock); i++ {
exp.alloc.writeBlock[i] = 'A'
}
n, err := fd.Write(exp.alloc.writeBlock)
if err != nil {
return report(err, fd)
}
if n != len(exp.alloc.writeBlock) {
return report(errors.New("failed to write block"), fd)
}
if err := fd.Close(); err != nil {
return report(err, nil)
} }
return true return true

View File

@@ -2,6 +2,7 @@ package cmd
import ( import (
"fmt" "fmt"
"time"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/collectors"
@@ -14,15 +15,17 @@ type Label struct {
// simple prometheus wrapper // simple prometheus wrapper
type Metrics struct { type Metrics struct {
run *prometheus.GaugeVec run *prometheus.GaugeVec
latency *prometheus.GaugeVec latency_r *prometheus.GaugeVec
registry *prometheus.Registry latency_w *prometheus.GaugeVec
values []string registry *prometheus.Registry
values []string
mode int
} }
func NewMetrics(conf *Config) *Metrics { func NewMetrics(conf *Config) *Metrics {
labels := []string{"file", "maxwait"} labels := []string{"file", "maxwait", "exectime"}
LabelLen := 2 LabelLen := 3
for _, label := range conf.Labels { for _, label := range conf.Labels {
labels = append(labels, label.Name) labels = append(labels, label.Name)
@@ -36,10 +39,17 @@ func NewMetrics(conf *Config) *Metrics {
}, },
labels, labels,
), ),
latency: prometheus.NewGaugeVec( latency_r: prometheus.NewGaugeVec(
prometheus.GaugeOpts{ prometheus.GaugeOpts{
Name: "io_exporter_io_latency", Name: "io_exporter_io_read_latency",
Help: "how long does the operation take in seconds", Help: "how long does the read operation take in seconds",
},
labels,
),
latency_w: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "io_exporter_io_write_latency",
Help: "how long does the write operation take in seconds",
}, },
labels, labels,
), ),
@@ -53,7 +63,8 @@ func NewMetrics(conf *Config) *Metrics {
if conf.Internals { if conf.Internals {
metrics.registry.MustRegister( metrics.registry.MustRegister(
metrics.run, metrics.run,
metrics.latency, metrics.latency_r,
metrics.latency_w,
// we might need to take care of the exporter in terms of // we might need to take care of the exporter in terms of
// resources, so also report those internals // resources, so also report those internals
@@ -65,28 +76,50 @@ func NewMetrics(conf *Config) *Metrics {
), ),
) )
} else { } else {
metrics.registry.MustRegister(metrics.run, metrics.latency) metrics.registry.MustRegister(metrics.run, metrics.latency_r, metrics.latency_w)
} }
// static labels // static labels
metrics.values[0] = conf.File metrics.values[0] = conf.File
metrics.values[1] = fmt.Sprintf("%d", conf.Timeout) metrics.values[1] = fmt.Sprintf("%d", conf.Timeout)
metrics.values[2] = fmt.Sprintf("%d", time.Now().UnixMilli())
// custom labels via -l label=value // custom labels via -l label=value
for idx, label := range conf.Labels { for idx, label := range conf.Labels {
metrics.values[idx+LabelLen] = label.Value metrics.values[idx+LabelLen] = label.Value
} }
switch {
case conf.ReadMode && conf.WriteMode:
metrics.mode = O_RW
case conf.ReadMode:
metrics.mode = O_R
case conf.WriteMode:
metrics.mode = O_W
}
return metrics return metrics
} }
func (metrics *Metrics) Set(result bool, elapsed float64) { func (metrics *Metrics) Set(result_r, result_w Result) {
var res float64 var res float64
if result { switch metrics.mode {
res = 1 case O_RW:
if result_r.result && result_w.result {
res = 1
}
case O_R:
if result_r.result {
res = 1
}
case O_W:
if result_w.result {
res = 1
}
} }
metrics.run.WithLabelValues(metrics.values...).Set(res) metrics.run.WithLabelValues(metrics.values...).Set(res)
metrics.latency.WithLabelValues(metrics.values...).Set(elapsed) metrics.latency_r.WithLabelValues(metrics.values...).Set(result_r.elapsed)
metrics.latency_w.WithLabelValues(metrics.values...).Set(result_w.elapsed)
} }

View File

@@ -7,11 +7,17 @@ import (
"net/http" "net/http"
"os" "os"
"strings" "strings"
"time"
// enable to debug with roumon
//_ "net/http/pprof"
// then: roumon -host=localhost -port=9187
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
) )
// Main program. starts 2 goroutines: our exporter and the http server
// for the prometheus metrics. The exporter reports measurement
// results to prometheus metrics directly
func Run() { func Run() {
conf, err := InitConfig(os.Stdout) conf, err := InitConfig(os.Stdout)
if err != nil { if err != nil {
@@ -23,36 +29,52 @@ func Run() {
os.Exit(0) os.Exit(0)
} }
metrics := NewMetrics(conf) if conf.Showhelp {
alloc := NewAlloc() fmt.Println(Usage)
os.Exit(0)
}
setLogger(os.Stdout, conf.Debug) setLogger(os.Stdout, conf.Debug)
go func() { metrics := NewMetrics(conf)
for { alloc := NewAlloc()
start := time.Now() exporter := NewExporter(conf, alloc, metrics)
result := runExporter(conf.File, alloc, time.Duration(conf.Timeout)*time.Second) wg := exporter.RunIOchecks()
// ns => s
now := time.Now()
elapsed := float64(now.Sub(start).Nanoseconds()) / 10000000000
slog.Debug("elapsed time", "elapsed", elapsed, "result", result)
metrics.Set(result, elapsed)
time.Sleep(time.Duration(conf.Sleeptime) * time.Second)
}
}()
http.Handle("/metrics", promhttp.HandlerFor( http.Handle("/metrics", promhttp.HandlerFor(
metrics.registry, metrics.registry,
promhttp.HandlerOpts{}, promhttp.HandlerOpts{},
)) ))
slog.Info("start testing and serving metrics on localhost", "port", conf.Port) slog.Info(" ╭──")
slog.Info("test setup", "file", conf.File, "labels", strings.Join(conf.Label, ",")) slog.Info(" │ io-exporter starting up", "version", Version)
slog.Info(" │ serving metrics", "host", "localhost", "port", conf.Port)
slog.Info(" │ test setup", "file", conf.File, "labels", strings.Join(conf.Label, ","))
slog.Info(" │ measuring", "read", conf.ReadMode, "write", conf.WriteMode, "timeout(s)", conf.Timeout)
slog.Info(" │ debugging", "enabled", conf.Debug)
slog.Info(" ╰──")
if err := http.ListenAndServe(fmt.Sprintf(":%d", conf.Port), nil); err != nil { if err := http.ListenAndServe(fmt.Sprintf(":%d", conf.Port), nil); err != nil {
log.Fatal(err) log.Fatal(err)
} }
wg.Wait()
}
func report(err error, fd *os.File) bool {
failure := err.Error()
if err.Error() == "context deadline exceeded" {
failure = "operation timed out"
}
slog.Error("io error", "error", failure)
if fd != nil {
if err := fd.Close(); err != nil {
slog.Debug("failed to close filehandle", "error", failure)
}
}
return false
} }

2
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/tlinden/io-exporter module codeberg.org/scip/io-exporter
go 1.23.5 go 1.23.5

580
grafana/dashboard.js Normal file
View File

@@ -0,0 +1,580 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 37,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "P5DCFC7561CCDE821"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"fillOpacity": 70,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineWidth": 0,
"spanNulls": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 0
},
{
"color": "green",
"value": 1
}
]
},
"unit": "bool_yes_no"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 24,
"x": 0,
"y": 0
},
"id": 4,
"options": {
"alignValue": "left",
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"mergeValues": true,
"rowHeight": 0.9,
"showValue": "auto",
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.3.8+security-01",
"targets": [
{
"editorMode": "code",
"expr": "sum(io_exporter_io_operation{container=\"ioexporter\",namespace=\"$namespace\"}) by (pod)",
"legendFormat": "{{pod}}",
"range": true,
"refId": "A"
}
],
"title": "IO Operation Result",
"type": "state-timeline"
},
{
"datasource": {
"type": "prometheus",
"uid": "P5DCFC7561CCDE821"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 10,
"w": 12,
"x": 0,
"y": 9
},
"id": 1,
"options": {
"legend": {
"calcs": [
"lastNotNull",
"max",
"min"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.3.8+security-01",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "P5DCFC7561CCDE821"
},
"editorMode": "code",
"expr": "sum(io_exporter_io_read_latency{container=\"ioexporter\",namespace=\"$namespace\"}) by (pod)",
"legendFormat": "{{pod}}",
"range": true,
"refId": "A"
}
],
"title": "IO Read Latency",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "P5DCFC7561CCDE821"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 10,
"w": 12,
"x": 12,
"y": 9
},
"id": 3,
"options": {
"legend": {
"calcs": [
"lastNotNull",
"max",
"min"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.3.8+security-01",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "P5DCFC7561CCDE821"
},
"editorMode": "code",
"expr": "sum(io_exporter_io_write_latency{container=\"ioexporter\",namespace=\"$namespace\"}) by (pod)",
"legendFormat": "{{pod}}",
"range": true,
"refId": "A"
}
],
"title": "IO Write Latency",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "P5DCFC7561CCDE821"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic-by-name"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 55,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 0,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "normal"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "s"
},
"overrides": [
{
"__systemRef": "hideSeriesFrom",
"matcher": {
"id": "byNames",
"options": {
"mode": "exclude",
"names": [
"Value"
],
"prefix": "All except:",
"readOnly": true
}
},
"properties": [
{
"id": "custom.hideFrom",
"value": {
"legend": false,
"tooltip": false,
"viz": true
}
}
]
}
]
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 19
},
"id": 5,
"interval": "1m",
"options": {
"legend": {
"calcs": [
"lastNotNull",
"mean",
"min",
"max"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.3.8+security-01",
"targets": [
{
"datasource": {
"uid": "$datasource"
},
"editorMode": "code",
"expr": "sum(node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate{namespace=\"$namespace\", container=\"ioexporter\"}) by (pod)",
"format": "time_series",
"intervalFactor": 2,
"legendFormat": "{{pod}}",
"range": true,
"refId": "A"
}
],
"title": "CPU Usage IO Exporter",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "P5DCFC7561CCDE821"
},
"description": "Memory Usage and Limit in Bytes",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "decbytes"
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 19
},
"id": 6,
"options": {
"alertThreshold": true,
"legend": {
"calcs": [
"mean",
"lastNotNull",
"max",
"min"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"pluginVersion": "11.3.8+security-01",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "P5DCFC7561CCDE821"
},
"editorMode": "code",
"exemplar": true,
"expr": "sum(container_memory_working_set_bytes{namespace=\"$namespace\", container=\"ioexporter\"}) by (pod)",
"format": "time_series",
"interval": "",
"intervalFactor": 2,
"legendFormat": "{{pod}}",
"range": true,
"refId": "A"
}
],
"title": "Average Memory Usage IO Explorer",
"type": "timeseries"
}
],
"preload": false,
"schemaVersion": 40,
"tags": [],
"templating": {
"list": [
{
"current": {
"text": "exporter-test",
"value": "exporter-test"
},
"definition": "label_values(io_exporter_io_operation,namespace)",
"includeAll": true,
"name": "namespace",
"options": [],
"query": {
"qryType": 1,
"query": "label_values(io_exporter_io_operation,namespace)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"refresh": 1,
"regex": "",
"type": "query"
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "io-test",
"uid": "ef1tcv0azbpc0e",
"version": 11,
"weekStart": ""
}

BIN
grafana/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

View File

@@ -1,6 +1,6 @@
package main package main
import "github.com/tlinden/io-exporter/cmd" import "codeberg.org/scip/io-exporter/cmd"
func main() { func main() {
cmd.Run() cmd.Run()