Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 7 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,28 +144,20 @@ jobs:
git describe --tags >/dev/null 2>&1 || flag="--snapshot"
goreleaser build --single-target --clean $flag

# Mirrors release.yml's build-linux job: same Alpine container, same
# statically-linked netgo build via `make release-build-linux`, so CI
# verifies the exact binary a release ships without waiting for a tag.
# Mirrors release.yml's build-linux job: same Alpine container (via
# compose.yaml's release-linux service), same statically-linked netgo
# build via `make release-build-linux`, so CI verifies the exact binary a
# release ships without waiting for a tag.
build-release-linux:
strategy:
matrix:
include:
- os: ubuntu-latest
goreleaser_arch: x86_64
goarch: amd64
- os: ubuntu-24.04-arm
goreleaser_arch: arm64
goarch: arm64
runs-on: ${{ matrix.os }}
env:
GORELEASER_VERSION: 2.18.1
steps:
- uses: actions/checkout@v7
- name: Build inside Alpine container
run: |
docker run --rm -v "$PWD:/src" -w /src golang:1.22-alpine sh -c "
set -e
apk add --no-cache make gcc musl-dev libpcap-dev clang llvm libbpf-dev git bash curl
git config --global --add safe.directory /src
curl -sL https://github.com/goreleaser/goreleaser/releases/download/v${GORELEASER_VERSION}/goreleaser_Linux_${{ matrix.goreleaser_arch }}.tar.gz | tar xz -C /usr/local/bin goreleaser
make release-build-linux
"
run: make release-linux-docker LINUX_ARCH=${{ matrix.goarch }}
32 changes: 9 additions & 23 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,45 +85,31 @@ jobs:
# unmodified on any Linux distro/kernel new enough for the syscalls it
# uses -- not just whatever glibc happened to be on the build host.
#
# The build itself (make bpf-generate + goreleaser) runs via a plain
# `docker run` against the Alpine image, not the `container:` job key: the
# GitHub Actions runner cannot execute any JS-based action (actions/checkout,
# goreleaser-action) inside an Alpine container on an arm64 runner, only on
# x64 -- so checkout happens on the host runner (glibc, unaffected) and
# goreleaser is fetched as a plain static binary and invoked with a shell
# command instead of goreleaser-action.
# The build itself (make bpf-generate + goreleaser) runs via `docker
# compose run` against the Alpine image defined in compose.yaml, not the
# `container:` job key: the GitHub Actions runner cannot execute any
# JS-based action (actions/checkout, goreleaser-action) inside an Alpine
# container on an arm64 runner, only on x64 -- so checkout happens on the
# host runner (glibc, unaffected) and goreleaser is fetched as a plain
# static binary and invoked with a shell command instead of
# goreleaser-action.
build-linux:
needs: verify
strategy:
matrix:
include:
- os: ubuntu-latest
goarch: amd64
goreleaser_arch: x86_64
- os: ubuntu-24.04-arm
goarch: arm64
goreleaser_arch: arm64
runs-on: ${{ matrix.os }}
env:
GORELEASER_VERSION: 2.18.1
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.ref_name }}
- name: Build and package inside Alpine container
run: |
docker run --rm -v "$PWD:/src" -w /src golang:1.22-alpine sh -c "
set -e
apk add --no-cache make gcc musl-dev libpcap-dev clang llvm libbpf-dev git bash jq p7zip curl
git config --global --add safe.directory /src
curl -sL https://github.com/goreleaser/goreleaser/releases/download/v${GORELEASER_VERSION}/goreleaser_Linux_${{ matrix.goreleaser_arch }}.tar.gz | tar xz -C /usr/local/bin goreleaser
make release-build-linux
bin=\$(jq -r '.[] | select(.type==\"Binary\") | .path' dist/artifacts.json)
mkdir -p out
cp \"\$bin\" trafficmon
7z a -tzip out/trafficmon-linux-${{ matrix.goarch }}.zip trafficmon
"
run: make release-linux-docker LINUX_ARCH=${{ matrix.goarch }}
- uses: actions/upload-artifact@v4
with:
name: trafficmon-linux-${{ matrix.goarch }}
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ dist/
*.test
*.out
*.zip
*.pprof
/trafficmon

# bpf2go-generated bindings + compiled BPF objects (see procinfo/bpf/*/gen_linux.go);
# regenerate with `make bpf-generate`.
Expand Down
11 changes: 11 additions & 0 deletions Dockerfile.linux-build
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Build environment for the Linux release binary: static, netgo-tagged, built
# against musl so it carries no glibc symbol-versioning floor and no
# libpcap.so runtime dependency, and runs unmodified on any Linux
# distro/kernel new enough for the syscalls it uses.
FROM golang:1.22-alpine

RUN apk add --no-cache make gcc musl-dev libpcap-dev clang llvm libbpf-dev git bash jq p7zip curl

WORKDIR /src

ENV GORELEASER_VERSION=2.18.1
22 changes: 16 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ MODULE_DIRS := . cmd/trafficmon receiver cmd/otel-collector

OCB_VERSION := v0.119.0

# goreleaser refuses to build at all without a reachable tag. CI's checkout
# has no tags (default shallow, non-tag checkout); release.yml's does (tags
# fetched, checked out at the release tag), so this expands to empty there.
GORELEASER_SNAPSHOT_FLAG := $(shell git describe --tags >/dev/null 2>&1 || echo --snapshot)
# arch selecting which compose.yaml service release-linux-docker runs --
# release-linux-$(LINUX_ARCH) must exist there. Override on the command line
# for a different arch, e.g. `make release-linux-docker LINUX_ARCH=arm64`.
LINUX_ARCH ?= amd64

.PHONY: help all build lint test clean run bpf-generate generate-otel-collector build-otel-collector release-build release-build-linux
# goreleaser refuses to build at all without a reachable tag. Only
# release.yml's checkout (running against a tag ref) omits this flag; every
# other build, including local ones, passes --snapshot.
GORELEASER_SNAPSHOT_FLAG := $(if $(filter tag,$(GITHUB_REF_TYPE)),,--snapshot)

.PHONY: help all build lint test clean run bpf-generate generate-otel-collector build-otel-collector release-build release-build-linux release-linux-docker

define PROMPT
@echo
Expand Down Expand Up @@ -66,10 +71,15 @@ release-build-linux: bpf-generate
$(call PROMPT, $@)
goreleaser build --single-target --clean --id trafficmon-linux $(GORELEASER_SNAPSHOT_FLAG)

#: run release-build-linux inside the compose.yaml Alpine/musl container instead of on the host, for LINUX_ARCH (default amd64; e.g. `make release-linux-docker LINUX_ARCH=arm64`) -- each arch is its own compose service, pinned to a matching `platform:`, so the container's gcc always matches the arch it's targeting
release-linux-docker:
$(call PROMPT, $@)
docker compose run --rm release-linux-$(LINUX_ARCH)

#: remove build artifacts
clean:
$(call PROMPT, $@)
rm -rf bin/ dist/
rm -rf bin/ dist/ out/

#: regenerate procinfo/bpf's bpf2go bindings + compiled BPF objects (Linux + BTF + clang/llvm/libbpf-dev/bpftool only; not part of `build`/`all` since the toolchain isn't available on a normal macOS dev machine -- CI runs this explicitly before building on the Linux leg)
bpf-generate:
Expand Down
53 changes: 53 additions & 0 deletions cmd/trafficmon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"log/slog"
"os"
"os/signal"
"runtime/pprof"
"syscall"
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/urfave/cli/v3"
Expand Down Expand Up @@ -41,6 +43,10 @@ func main() {
Aliases: []string{"i"},
Usage: `capture on these interfaces: comma-separated device names, or "any"/"default"/"localhost" (default "any")`,
},
&cli.DurationFlag{
Name: "pprof",
Usage: "capture a CPU + heap + goroutine profile for this long, writing trafficmon-*.pprof into the working directory; unset disables it",
},
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
level, err := parseLevel(cmd.String("level"))
Expand Down Expand Up @@ -110,6 +116,9 @@ func run(ctx context.Context, cmd *cli.Command) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return capturer.Run(ctx) })
g.Go(func() error { return source.Run(ctx) })
if d := cmd.Duration("pprof"); d > 0 {
g.Go(func() error { return capturePprof(ctx, d) })
}
g.Go(func() error {
defer stop()
p := tea.NewProgram(tui.NewModel(ctx, agg, resolver, capturer.HostnameCache(), ifaces), tea.WithAltScreen(), tea.WithContext(ctx), tea.WithMouseCellMotion())
Expand All @@ -123,6 +132,50 @@ func run(ctx context.Context, cmd *cli.Command) error {
return nil
}

// capturePprof runs a CPU profile for d (or until ctx is cancelled,
// whichever comes first), then writes it alongside a heap and goroutine
// snapshot taken at the end of that window to trafficmon-{cpu,heap,goroutine}.pprof
// in the working directory, each openable with `go tool pprof`.
func capturePprof(ctx context.Context, d time.Duration) error {
cpu, err := os.Create("trafficmon-cpu.pprof")
if err != nil {
return fmt.Errorf("create cpu profile: %w", err)
}
defer func() { _ = cpu.Close() }()

if err := pprof.StartCPUProfile(cpu); err != nil {
return fmt.Errorf("start cpu profile: %w", err)
}
slog.Info("pprof capture started", "duration", d)

select {
case <-time.After(d):
case <-ctx.Done():
}
pprof.StopCPUProfile()

for _, name := range []string{"heap", "goroutine"} {
if err := writeProfile(name); err != nil {
return err
}
}

slog.Info("pprof capture complete", "files", []string{"trafficmon-cpu.pprof", "trafficmon-heap.pprof", "trafficmon-goroutine.pprof"})
return nil
}

func writeProfile(name string) error {
f, err := os.Create("trafficmon-" + name + ".pprof") //nolint:gosec // name is always one of our own two hardcoded profile names, never attacker-controlled
if err != nil {
return fmt.Errorf("create %s profile: %w", name, err)
}
defer func() { _ = f.Close() }()
if err := pprof.Lookup(name).WriteTo(f, 0); err != nil {
return fmt.Errorf("write %s profile: %w", name, err)
}
return nil
}

// isShutdown reports whether err is just the result of the program being wound
// up in an orderly way, rather than something the user needs to be told about.
//
Expand Down
45 changes: 45 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Builds the Linux release binary inside Alpine (musl): a static,
# netgo-tagged binary compiled here carries no glibc symbol-versioning
# floor and no libpcap.so runtime dependency, so it runs unmodified on any
# Linux distro/kernel new enough for the syscalls it uses -- not just
# whatever glibc happened to be on the build host.
#
# One service per target arch, each pinned to its own `platform:` -- this is
# what makes `apk add gcc` install a gcc that actually matches GOARCH,
# rather than whatever gcc the host's own architecture would otherwise
# default the image to (cgo then hands that gcc a target-arch flag like
# -m64 it may not even recognise).
x-release-linux: &release-linux
build:
context: .
dockerfile: Dockerfile.linux-build
working_dir: /src
volumes:
- .:/src
entrypoint: sh
command:
- -c
- |
set -e
git config --global --add safe.directory /src
curl -sL "https://github.com/goreleaser/goreleaser/releases/download/v$${GORELEASER_VERSION}/goreleaser_Linux_$${GORELEASER_ARCH}.tar.gz" | tar xz -C /usr/local/bin goreleaser
make release-build-linux
bin=$$(jq -r '.[] | select(.type=="Binary") | .path' dist/artifacts.json)
mkdir -p out
cp "$$bin" trafficmon
7z a -tzip "out/trafficmon-linux-$${GOARCH}.zip" trafficmon

services:
release-linux-amd64:
<<: *release-linux
platform: linux/amd64
environment:
GOARCH: amd64
GORELEASER_ARCH: x86_64

release-linux-arm64:
<<: *release-linux
platform: linux/arm64
environment:
GOARCH: arm64
GORELEASER_ARCH: arm64
Loading