📈 Monitoring FreeBSD: From top to Prometheus
Four layers of monitoring, each tested: the live tools, the nightly report, your own checks run by cron, and metrics with alerts. Including what happens when something breaks
Three questions
Monitoring comes down to three questions, asked at different speeds:
- What is happening right now? The live tools. You sit and look.
- Is it healthy, and was it healthy while I was away? Reports and logs. The machine tells you, on a schedule.
- Will someone be told when it is not? Checks and alerts. This is the part people skip, and the part that matters at 3 a.m.
This page walks up those layers on a real FreeBSD 15.0 virtual machine, with something deliberately broken at each step, so that you see what the monitoring says when it has something to say. The layers: the base system's own tools, the nightly periodic report, your own checks with cron and syslog, and finally metrics with alert rules (node_exporter and Prometheus), plus a short look at SNMP. The firewall and hardening pages decide who may reach these services, so remember them as you go.
Layer 1: what is happening now
All of these are in the base system. They have interactive modes, and we show the one-shot versions, which are the ones you can paste into a report.
root@practice:~ # uptime
5:55PM up 1 min, 1 user, load averages: 0.36, 0.11, 0.04
root@practice:~ # top -b -d 1 | head -14
last pid: 63937; load averages: 0.36, 0.11, 0.04 up 0+00:01:01 17:55:01
38 processes: 1 running, 37 sleeping
CPU: 3.7% user, 0.0% nice, 14.2% system, 0.6% interrupt, 81.6% idle
Mem: 220M Active, 68M Inact, 264M Wired, 1408M Free
ARC: 136M Total, 17M MFU, 115M MRU, 696K Anon, 1400K Header, 2330K Other
100M Compressed, 202M Uncompressed, 2.02:1 Ratio
Swap: 2048M Total, 2048M Free
PID USERNAME THR PRI NICE SIZE RES STATE C TIME WCPU COMMAND
18178 root 11 0 0 814M 59M kqread 0 0:14 2.39% bhyve
86562 root 1 0 0 25M 10M select 0 0:00 0.05% sshd
60637 root 1 9 0 25M 12M select 0 0:00 0.00% sshd-session
13349 root 1 59 0 14M 4012K wait 0 0:00 0.00% sh
78634 root 1 0 0 15M 4452K select 1 0:00 0.00% devd
root@practice:~ # vmstat 1 3
procs memory page disks faults cpu
r b w avm fre flt re pi po fr sr vtb0 cd0 in sy cs us sy id
0 0 0 4.0T 1.4G 6.0k 0 28 0 6.5k 70 0 0 100 24k 2.9k 3 14 81
0 0 0 4.0T 1.4G 0 0 0 0 0 90 0 0 135 503 519 0 2 97
0 0 0 4.0T 1.4G 0 0 0 0 0 81 16 0 112 1.0k 772 0 7 91
root@practice:~ # swapinfo -h
Device Size Used Avail Capacity
/dev/vtbd0p2 2.0G 0B 2.0G 0%
root@practice:~ # iostat -x -c 2 -w 1 vtbd0
extended device statistics
device r/s w/s kr/s kw/s ms/r ms/w ms/o ms/t qlen %b
vtbd0 80 13 1729.8 452.3 0 2 1 0 0 2
extended device statistics
device r/s w/s kr/s kw/s ms/r ms/w ms/o ms/t qlen %b
vtbd0 2 53 11.8 1571.8 0 1 1 1 0 3
root@practice:~ # zpool iostat zroot 1 3
capacity operations bandwidth
pool alloc free read write read write
---------- ----- ----- ----- ----- ----- -----
zroot 7.48G 6.02G 76 14 1.60M 479K
zroot 7.48G 6.02G 0 0 0 0
zroot 7.48G 6.02G 0 0 0 0
root@practice:~ # netstat -i -n | head -5
Name Mtu Network Address Ipkts Ierrs Idrop Opkts Oerrs Coll
vtnet0 1500 <Link#1> 52:54:00:12:34:56 2048 0 0 1351 0 0
vtnet0 - 10.0.2.0/24 10.0.2.15 2044 - - 1346 - -
vtnet0 - 10.0.2.50/32 10.0.2.50 0 - - 0 - -
lo0 16384 <Link#2> lo0 0 0 0 0 0 0
root@practice:~ # netstat -w 1 -q 3 -I vtnet0
input vtnet0 output
packets errs idrops bytes packets errs bytes colls
103 0 0 7398 52 0 4800 0
97 0 0 6966 49 0 4482 0
1 0 0 54 1 0 162 0
- uptime gives the three load averages (the average number of programs waiting for a processor over 1, 5 and 15 minutes). Compare them with the number of processors: this machine has 2, so a load of 0.36 is relaxed, and a load of 8 would mean a queue.
- top -b is top in "batch" mode: one screen of text, then exit. The summary at the top tells you a lot: 38 processes, CPU split into user, system, interrupt and idle (81.6 percent idle here), memory (Active, Inactive, Wired and Free), and swap. On a ZFS system there is also an ARC line: the cache of ZFS, 136M here, with its compression: 2.02:1 Ratio. The processes are sorted by CPU, and the first one is a bhyve virtual machine, the vm-bhyve guest from the vm-bhyve page that started on its own at boot.
- vmstat 1 3 prints three one-second samples. r b w are processes running, blocked and swapped. fre is free memory (1.4G). pi po are pages moved in and out of swap: if po is not zero for long, the machine is short of memory. The last three columns, us sy id, are user, system and idle percentages again.
- swapinfo -h: swap use. 0B used of 2.0G.
- iostat -x -c 2 -w 1 vtbd0 shows the disk: reads and writes per second (r/s, w/s), kilobytes per second, the average time per operation in milliseconds (ms/r, ms/w) and %b, how busy the disk was. A disk that is near 100 percent busy with growing times is the bottleneck.
- zpool iostat zroot 1 3 is the same view from ZFS's side: operations and bandwidth for the whole pool. The first line is the average since boot, and the following ones are per second.
- netstat -i -n lists the network interfaces with packet and error counts (look at Ierrs, Oerrs and Coll: they should be zero), and netstat -w 1 -q 3 -I vtnet0 prints a packets-per-second view of one interface.
Two more live tools are in the base system and are interactive, so we did not run them for the page: gstat (a live view of every disk with its busy percentage) and systat (a full-screen set of views: -vmstat, -ifstat, -iostat and others). From the documentation, not run here.
Quick health: three commands
root@practice:~ # zpool status -x all pools are healthy root@practice:~ # zfs list -o name,used,avail,compressratio zroot | head -3 NAME USED AVAIL RATIO zroot 7.48G 5.60G 1.44x root@practice:~ # df -h / | tail -2 Filesystem Size Used Avail Capacity Mounted on zroot/ROOT/default 7.0G 1.4G 5.6G 21% / root@practice:~ # w 5:55PM up 1 min, 1 user, load averages: 0.33, 0.11, 0.04 USER TTY FROM LOGIN@ IDLE WHAT bsd pts/0 10.0.2.2 5:55PM - w root@practice:~ # last | head -3 bsd pts/0 10.0.2.2 Tue Oct 6 17:55 still logged in bsd pts/0 10.0.2.2 Tue Oct 6 17:54 - 17:54 (00:00) bsd pts/0 10.0.2.2 Tue Oct 6 17:54 - 17:54 (00:00)
- zpool status -x is the one to remember. It prints all pools are healthy, or, if anything is wrong, the full report for just the troubled pools. It is short, it reads the same everywhere, and it is easy to use in a script (you will see that below).
- zfs list shows the space used, and the compression ratio: 1.44x.
- df -h /: 21 percent of the root file system used. w and last tell you who is logged in now and who logged in before (the hardening page uses them for the review).
Layer 2: the nightly report
FreeBSD already runs a set of checks every night. The program is periodic, and it is started from cron:
root@practice:~ # grep -v "^#" /etc/crontab | grep . SHELL=/bin/sh PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin */11 * * * * operator /usr/libexec/save-entropy 0 * * * * root newsyslog 1 3 * * * root periodic daily 15 4 * * 6 root periodic weekly 30 5 1 * * root periodic monthly 1,31 0-5 * * * root adjkerntz -a root@practice:~ # grep -v "^#" /etc/newsyslog.conf | grep . | head -5 /var/log/all.log 600 7 * @T00 J /var/log/auth.log 600 7 1000 @0101T JC /var/log/console.log 600 5 1000 * J /var/log/cron 600 3 1000 * JC /var/log/daemon.log 644 5 1000 @0101T JC
The system crontab runs periodic daily at 03:01, periodic weekly on Saturdays at 04:15 and periodic monthly on the 1st at 05:30. newsyslog runs every hour to rotate logs: its configuration lists each log file, how many old copies to keep and when to rotate (for example /var/log/auth.log keeps 7 copies and rotates when it reaches 1000 KB, or on New Year's Day).
The checks are small scripts in /etc/periodic/daily (and weekly, monthly, security). What runs is switched by variables in /etc/defaults/periodic.conf:
root@practice:~ # grep -E "^[a-z_]*status_zfs_enable|^daily_status_(disks|network|security|uptime|ntpd)_enable|^daily_output|^security_status_pkgaudit|^daily_scrub_zfs|^daily_status_security_pkgaudit" /etc/defaults/periodic.conf daily_output="root" # user or /file daily_status_disks_enable="YES" # Check disk status daily_status_zfs_enable="NO" # Check ZFS daily_status_network_enable="YES" # Check network status daily_status_uptime_enable="YES" # Check system uptime daily_status_security_enable="YES" # Security check daily_status_ntpd_enable="NO" # Check NTP status daily_scrub_zfs_enable="NO" daily_scrub_zfs_pools="" # empty string selects all pools daily_scrub_zfs_default_threshold="35" # days between scrubs root@practice:~ # ls /usr/local/etc/periodic/daily /usr/local/etc/periodic/security /usr/local/etc/periodic/daily: 411.pkg-backup 490.status-pkg-changes /usr/local/etc/periodic/security: 405.pkg-base-audit 410.pkg-audit 460.pkg-checksum
Look at the defaults:
- daily_output="root": the report is mailed to root.
- daily_status_zfs_enable="NO": the ZFS health check is off by default. On a ZFS system, switch it on.
- daily_status_disks_enable, network, uptime and security are YES.
- daily_scrub_zfs_enable="NO" too: the nightly check for a scrub that is due (every 35 days by default, daily_scrub_zfs_default_threshold). Scrubs are explained on the ZFS page, and turning this on lets periodic run them for you (from the documentation, not run here).
- The package system adds its own checks in /usr/local/etc/periodic: 410.pkg-audit (known vulnerabilities in your packages) and a daily record of package changes.
We ran periodic daily to see what it does. It printed nothing. Where did the report go?
root@practice:~ # ls -l /var/mail/ total 9 -rw------- 1 bsd bsd 0 Oct 5 20:58 bsd -rw--w---- 1 root mail 18807 Oct 6 17:55 root root@practice:~ # mail -H 2>&1 | head -5 Mail version 8.1 6/6/93. Type ? for help. "/var/mail/root": 2 messages 2 new >N 1 root@practice Tue Oct 6 17:55 386/13564 "practice daily securi" N 2 root@practice Tue Oct 6 17:55 106/5243 "practice daily run ou"
Into root's mailbox (/var/mail/root, 18 KB): two messages, "daily security run output" and "daily run output". Nobody reads root's mail unless you set that up. So there are two good options: forward root's mail to a real address (needs a working mail setup: from the documentation, not run here), or write the report to a file, which is what we did next:
root@practice:~ # cat > /etc/periodic.conf <<'EOF' > daily_output="/var/log/daily.log" > daily_status_zfs_enable="YES" > EOF root@practice:~ # cat /etc/periodic.conf daily_output="/var/log/daily.log" daily_status_zfs_enable="YES" root@practice:~ # periodic daily root@practice:~ # ls -l /var/log/daily.log you have mail -rw-r--r-- 1 root wheel 5022 Oct 6 17:56 /var/log/daily.log root@practice:~ # grep -c "" /var/log/daily.log 97 root@practice:~ # sed -n "1,70p" /var/log/daily.log (excerpt) Removing stale files from /var/preserve: Cleaning out old system announcements: Removing stale files from /var/rwho: Backup passwd and group files: Verifying group file syntax: /etc/group is fine Backing up mail aliases: Dump of kern.geom.conftxt: Backup of partitions information for: vtbd0 Backup of boot partition content: vtbd0p1 Disk status: Filesystem Size Used Avail Capacity Mounted on zroot/ROOT/default 7.0G 1.4G 5.6G 21% / devfs 1.0K 0B 1.0K 0% /dev fdescfs 1.0K 0B 1.0K 0% /dev/fd zroot/var/audit 5.6G 120K 5.6G 0% /var/audit zroot/home 5.6G 132K 5.6G 0% /home [... the rest of the list of file systems is left out ...] Checking status of zfs pools: NAME SIZE ALLOC FREE CKPOINT EXPANDSZ FRAG CAP DEDUP HEALTH ALTROOT zroot 13.5G 7.48G 6.02G - - 13% 55% 1.00x ONLINE - all pools are healthy Network interface status: Name Mtu Network Address Ipkts Ierrs Idrop Opkts Oerrs Coll Drop vtnet0 1500 <Link#1> 52:54:00:12:34:56 3017 0 0 1934 0 0 0
- /etc/periodic.conf is where you override defaults (never edit /etc/defaults/periodic.conf). Two lines: daily_output="/var/log/daily.log" and daily_status_zfs_enable="YES".
- The run produced a 97 line report (5 KB) in /var/log/daily.log: stale files removed, the password and group files backed up and checked (/etc/group is fine), the disk partition tables backed up (the vtbd0 lines are gpart backups, useful for disaster recovery), then Disk status: every file system with its size and use.
- Checking status of zfs pools: a table of the pools, and all pools are healthy, the answer to the question we most want asked every night.
- Network interface status, the uptime, the mail queue, whether userland and kernel are in sync, and a backup of the package database.
- (The report is also kept by newsyslog: its daily.log entry rotates it, with 7 copies.)
The security half of the nightly run is a separate message. Here is the one that went to root's mailbox:
root@practice:~ # awk '/daily securi/,0' /var/mail/root | sed -n '1,45p' Subject: practice daily security run output Date: Tue, 06 Oct 2026 17:55:44 +0000 From: <root@practice> Checking setuid files and devices: No /var/log/setuid.today Checking negative group permissions: No /var/log/mount.today Checking for uids of 0: root 0 toor 0 Checking for passwordless accounts: Checking login.conf permissions: No /var/log/ipfw.today No /var/log/dmesg.today practice login failures: practice refused connections: Checking for security vulnerabilities in base (userland & kernel): Database fetched: 2026-10-06T09:15+00:00 FreeBSD-kernel-15.0 is vulnerable: FreeBSD -- IPv6 UDP sendto(2) bypasses jail loopback restriction CVE: CVE-2026-101303 WWW: https://vuxml.FreeBSD.org/freebsd/6d92b2a3-bcce-11f1-906f-bc241121aa0a.html FreeBSD -- Remote DoS via receive-side kernel TLS CVE: CVE-2026-101302 [... more findings follow ...]
It checks for set-user-ID files and devices that changed, uids of 0 (root and toor, as expected: anything else here is an alarm), accounts without a password, login failures and refused connections, and, from the package system, a vulnerability check of the base system itself. This lab machine runs a plain, unpatched 15.0-RELEASE, so the report listed known vulnerabilities in FreeBSD-kernel-15.0, with their CVE numbers and links. That is the report doing its job: it is the nudge to run freebsd-update (see the upgrade page) and pkg audit (see the hardening page).
Logs
Services write their messages through syslog, and a rule file, /etc/syslog.conf, decides which messages go where. One line matters for monitoring:
root@practice:~ # grep -n "notice" /etc/syslog.conf | head -3 7:*.err;kern.warning;auth.notice;mail.crit /dev/console 8:*.notice;authpriv.none;kern.debug;lpr.info;mail.crit;news.err /var/log/messages 28:# news.notice /var/log/news/news.notice
Line 8 sends everything of level notice and above to /var/log/messages. Messages at a lower level (info, debug) are dropped there. We learned this by testing the script in the next section: our first version logged its "all is well" line at the info level, and it never appeared in /var/log/messages, while the warnings did. Log the "ok" lines at notice, or they vanish.
To send a line to the log yourself, use logger: logger -p daemon.warning -t healthcheck "text" writes a line at that level, tagged with a name. Anything you can run in a shell can report this way. (To collect logs from other machines on one log server, syslogd can listen on the network. We switched that off on the hardening page, with syslogd_flags="-ss", so a log server would turn it back on and restrict who may send to it: from the documentation, not run here.)
Layer 3: your own checks
The nightly report is for humans. For things that must be noticed fast, write a small script, and let cron run it every few minutes. This is ours: it checks the root file system, the ZFS pools and two services, and logs one line per problem (or one "ok" line). Its exit status is the number of problems, so other tools can use it.
root@practice:~ # cat > /usr/local/bin/health-check.sh <<'EOF'
> #!/bin/sh
> # Log one line per problem, or a single "ok" line. Exit status = number of problems.
> MAX_DISK=${MAX_DISK:-80}
> problems=0
> use=$(df -P / | awk 'NR == 2 { sub("%", "", $5); print $5 }')
> if [ "$use" -ge "$MAX_DISK" ]; then
> logger -p daemon.warning -t healthcheck "root filesystem ${use}% full (limit ${MAX_DISK}%)"
> problems=$((problems + 1))
> fi
> pools=$(zpool status -x)
> if [ "$pools" != "all pools are healthy" ]; then
> logger -p daemon.warning -t healthcheck "ZFS problem: $(echo "$pools" | head -1)"
> problems=$((problems + 1))
> fi
> for svc in sshd cron; do
> if ! service $svc status > /dev/null 2>&1; then
> logger -p daemon.warning -t healthcheck "service $svc is not running"
> problems=$((problems + 1))
> fi
> done
> [ $problems -eq 0 ] && logger -p daemon.notice -t healthcheck "ok: disk ${use}%, pools healthy, services running"
> exit $problems
> EOF
root@practice:~ # chmod 755 /usr/local/bin/health-check.sh
root@practice:~ # sh -n /usr/local/bin/health-check.sh && echo syntax-ok
syntax-ok
- df -P / | awk ... extracts the percentage in use. zpool status -x is the ZFS check from before. service NAME status checks that a service is running.
- logger -p daemon.warning records each problem, and -t healthcheck tags it so you can find it with grep.
- sh -n checks the script's syntax without running it. Always.
First, healthy, and then with a deliberately silly limit (MAX_DISK=10, so that 21 percent counts as "too full"):
root@practice:~ # /usr/local/bin/health-check.sh; echo "exit status: $?" exit status: 0 root@practice:~ # tail -1 /var/log/messages Oct 6 17:57:08 practice healthcheck[10552]: ok: disk 21%, pools healthy, services running root@practice:~ # MAX_DISK=10 /usr/local/bin/health-check.sh; echo "exit status: $?" exit status: 1 root@practice:~ # tail -2 /var/log/messages Oct 6 17:57:08 practice healthcheck[10552]: ok: disk 21%, pools healthy, services running Oct 6 17:57:08 practice healthcheck[13849]: root filesystem 21% full (limit 10%)
Healthy: exit status 0 and one line, ok: disk 21%, pools healthy, services running. With the limit lowered: exit status 1 and the warning, root filesystem 21% full (limit 10%). The script works. But a test that cannot fail proves nothing, so now a real fault. We built a small ZFS mirror out of two files, took one of the "disks" offline, and ran the script:
root@practice:~ # mkdir -p /var/tmp/probe && truncate -s 64m /var/tmp/probe/d1 /var/tmp/probe/d2 root@practice:~ # zpool create probe mirror /var/tmp/probe/d1 /var/tmp/probe/d2 root@practice:~ # zpool status -x all pools are healthy root@practice:~ # zpool offline probe /var/tmp/probe/d2 root@practice:~ # zpool status -x pool: probe state: DEGRADED status: One or more devices has been taken offline by the administrator. Sufficient replicas exist for the pool to continue functioning in a degraded state. action: Online the device using 'zpool online' or replace the device with 'zpool replace'. config: NAME STATE READ WRITE CKSUM probe DEGRADED 0 0 0 mirror-0 DEGRADED 0 0 0 /var/tmp/probe/d1 ONLINE 0 0 0 /var/tmp/probe/d2 OFFLINE 0 0 0 errors: No known data errors root@practice:~ # /usr/local/bin/health-check.sh; echo "exit status: $?" exit status: 1 root@practice:~ # tail -2 /var/log/messages Oct 6 17:57:09 practice ZFS[44510]: vdev state changed, pool_guid=15432617247174042435 vdev_guid=11204619220799171741 Oct 6 17:57:10 practice healthcheck[49093]: ZFS problem: pool: probe root@practice:~ # zpool events | tail -4 Oct 6 2026 17:57:09.308968548 sysevent.fs.zfs.history_event Oct 6 2026 17:57:09.344983913 sysevent.fs.zfs.config_sync Oct 6 2026 17:57:09.694970656 resource.fs.zfs.statechange Oct 6 2026 17:57:09.712978393 sysevent.fs.zfs.config_sync root@practice:~ # zpool destroy probe; rm -r /var/tmp/probe; zpool status -x all pools are healthy
- Healthy mirror: all pools are healthy.
- After zpool offline, zpool status -x changes completely: it names the pool probe, says state: DEGRADED, explains it (One or more devices has been taken offline by the administrator. Sufficient replicas exist for the pool to continue functioning in a degraded state.), tells you the action to take, and shows the layout, with d2 OFFLINE.
- The script saw it, exit status 1, and logged ZFS problem: pool: probe (the first line of the report).
- FreeBSD also logged it by itself: the line ZFS[36374]: vdev state changed, pool_guid=... in /var/log/messages came from the system's device event handler, not from our script. zpool events lists the ZFS event history (resource.fs.zfs.statechange and so on).
- We destroyed the test pool, and zpool status -x says all pools are healthy again.
Now cron, which makes the check happen without you:
root@practice:~ # crontab -l 2>&1 | head -2 crontab: no crontab for root root@practice:~ # (crontab -l 2>/dev/null; echo '* * * * * /usr/local/bin/health-check.sh') | crontab - root@practice:~ # crontab -l * * * * * /usr/local/bin/health-check.sh root@practice:~ # grep healthcheck /var/log/messages | tail -4 Oct 6 17:57:08 practice healthcheck[13849]: root filesystem 21% full (limit 10%) Oct 6 17:57:10 practice healthcheck[49093]: ZFS problem: pool: probe Oct 6 17:58:00 practice healthcheck[18266]: ok: disk 21%, pools healthy, services running Oct 6 17:59:00 practice healthcheck[50720]: ok: disk 21%, pools healthy, services running root@practice:~ # (crontab -l | grep -v health-check; echo '*/5 * * * * /usr/local/bin/health-check.sh') | crontab - root@practice:~ # crontab -l */5 * * * * /usr/local/bin/health-check.sh root@practice:~ # grep -c "" /var/cron/log 2>/dev/null; grep health-check /var/log/cron | tail -2 Oct 6 17:58:00 practice /usr/sbin/cron[89236]: (root) CMD (/usr/local/bin/health-check.sh) Oct 6 17:59:00 practice /usr/sbin/cron[18638]: (root) CMD (/usr/local/bin/health-check.sh)
- crontab -l showed there was no crontab for root. We added the line * * * * * /usr/local/bin/health-check.sh (every minute, only for the test), and waited.
- The log lines appeared at 17:58:00 and 17:59:00, one a minute: ok: disk 21%, pools healthy, services running. And cron's own log, /var/log/cron, shows CMD (/usr/local/bin/health-check.sh).
- For real use we replaced it with */5 * * * *, every five minutes, and crontab -l confirms.
Where do the warnings go from here? Into /var/log/messages, which nobody reads at 3 a.m. The next step is to turn a warning into a message to a person: a mail to you, a message to a chat service, a call to an alerting service from a line in the script (mail, or a web request): from the documentation, not run here. Or use a monitoring system that does this for you, which is the next layer.
Layer 4: metrics and alerts with Prometheus
The checks above answer "is it OK right now?" They cannot answer "was the disk filling up slowly over the last month?" or "how does this week compare with last week?" For that you store numbers over time. The most common free tool for it is Prometheus, and FreeBSD has packages for the whole kit. It has two parts:
- An exporter on each machine, node_exporter: a small program that serves the machine's numbers as plain text on a web port. Not a database, just a window.
- The Prometheus server: it scrapes (reads) the exporters every few seconds, stores the numbers, answers questions about them in a query language (PromQL), and evaluates alert rules.
⚠️ Newer package repository (checked October 2026): the package repository now targets FreeBSD 15.1, while 15.0-RELEASE is end of life. On a 15.0 machine pkg may stop with Newer FreeBSD version for package ... [y/N], and the version numbers in the output below may differ from yours. To carry on, run export IGNORE_OSVERSION=yes first, or better, move to a supported release (see the upgrading guide).
root@practice:~ # pkg install -y node_exporter prometheus 2>&1 | grep -E "Number|download|space|Installing node|Installing prom"
Number of packages to be installed: 2
The process will require 212 MiB more space.
45 MiB to be downloaded.
[1/2] Installing node_exporter-1.11.0_9...
[2/2] Installing prometheus-2.55.1_20...
root@practice:~ # grep -nE '_enable|_args|listen|_config|data|rcvar' /usr/local/etc/rc.d/node_exporter | head -12
10:# node_exporter_enable (bool): Set to NO by default.
16:# node_exporter_args (string): Set extra arguments to pass to node_exporter
18:# node_exporter_listen_address (string):Set ip:port that node_exporter will listen on
28:rcvar=node_exporter_enable
30:load_rc_config $name
32:: ${node_exporter_enable:="NO"}
35:: ${node_exporter_args:=""}
36:: ${node_exporter_listen_address:=":9100"}
43:command_args="-f -p ${node_exporter_pidfile} -T ${name} \
45: --web.listen-address=${node_exporter_listen_address} \
47: ${node_exporter_args}"
69:load_rc_config $name
root@practice:~ # grep -nE '_enable|_args|listen|_config|data|storage|rcvar' /usr/local/etc/rc.d/prometheus | head -14
10:# prometheus_enable (bool)
19:# prometheus_config (string)
25:# prometheus_syslog_output_enable (bool)
46:# prometheus_data_dir (string)
58:# prometheus_args (string)
65:rcvar=prometheus_enable
67:load_rc_config $name
69:: ${prometheus_enable:="NO"}
72:: ${prometheus_config:="/usr/local/etc/prometheus.yml"}
74:: ${prometheus_syslog_output_enable:="YES"}
78:: ${prometheus_data_dir:="/var/db/prometheus"}
82:if checkyesno prometheus_syslog_output_enable; then
97: prometheus_storage_flags="--enable-feature=agent --storage.agent.path=${prometheus_data_dir}"
99: prometheus_storage_flags="--storage.tsdb.path=${prometheus_data_dir}"
root@practice:~ # ls /usr/local/etc/prometheus* 2>&1 | head
/usr/local/etc/prometheus.yml
/usr/local/etc/prometheus.yml.sample
root@practice:~ # which promtool
/usr/local/bin/promtool
Two packages, 45 MiB to download (212 MiB installed). The rc scripts show their settings: node_exporter_enable, node_exporter_listen_address (default :9100, every address), and for Prometheus prometheus_enable, prometheus_config (default /usr/local/etc/prometheus.yml), prometheus_data_dir (/var/db/prometheus) and prometheus_args.
root@practice:~ # sysrc node_exporter_enable=YES
node_exporter_enable: -> YES
root@practice:~ # service node_exporter start
Starting node_exporter.
root@practice:~ # sockstat -4l -6l | grep node_exp
nobody node_expor 39040 3 tcp46 *:9100 *:*
root@practice:~ # fetch -qo - http://127.0.0.1:9100/metrics | grep -c '^node_'
435
root@practice:~ # fetch -qo - http://127.0.0.1:9100/metrics | grep -E '^node_(load1|load5|boot_time_seconds|memory_size_bytes|memory_free_bytes|zfs_arc_size|uname_info)' | cut -c1-150
node_boot_time_seconds 1.791309240211743e+09
node_load1 0.2353515625
node_load15 0.1005859375
node_load5 0.21142578125
node_memory_free_bytes 6.23734784e+08
node_memory_size_bytes 2.056421376e+09
node_uname_info{domainname="(none)",machine="amd64",nodename="practice",release="15.0-RELEASE",sysname="FreeBSD",version="FreeBSD 15.0-RELEASE releng/
root@practice:~ # fetch -qo - http://127.0.0.1:9100/metrics | grep -E '^node_filesystem_(avail|size)_bytes.mountpoint="/"' | cut -c1-150
root@practice:~ # fetch -qo - http://127.0.0.1:9100/metrics | grep -E '^node_network_receive_bytes_total.device="vtnet0"'
node_network_receive_bytes_total{device="vtnet0"} 4.9856989e+07
root@practice:~ # sysrc node_exporter_listen_address="127.0.0.1:9100"
node_exporter_listen_address: -> 127.0.0.1:9100
root@practice:~ # service node_exporter restart
Stopping node_exporter.
Waiting for PIDS: 39040.
Starting node_exporter.
root@practice:~ # sockstat -4l -6l | grep node_exp
nobody node_expor 78725 3 tcp4 127.0.0.1:9100 *:*
- Started with sysrc node_exporter_enable=YES and service node_exporter start.
- sockstat shows what it listens on: *:9100, so every address of the machine: anybody who can reach the machine can read your numbers: process counts, the version of FreeBSD, host name, disk sizes, network counters. That is information an attacker likes. Set node_exporter_listen_address="127.0.0.1:9100" (or the address of a private monitoring network), and it is now 127.0.0.1:9100 (the last sockstat). For a remote Prometheus server, listen on the private address and allow only that server through the firewall (pf or ipfw).
- fetch can read the page: 435 metric lines. Examples seen above: node_load1 (the 1-minute load), node_boot_time_seconds, node_memory_size_bytes and node_memory_free_bytes, node_uname_info (which carries the host name and release="15.0-RELEASE" as labels), and the byte counter of the network card, node_network_receive_bytes_total{device="vtnet0"}. On FreeBSD there are also families for ZFS (node_zfs_..., for example node_zfs_abdstats_...) and for each file system, with labels that say which one: node_filesystem_avail_bytes{mountpoint="/"}. The names with a label in braces are how Prometheus tells things apart.
Now Prometheus, with a scrape job for the exporter and an alert rule file:
root@practice:~ # cp /usr/local/etc/prometheus.yml /usr/local/etc/prometheus.yml.orig
root@practice:~ # cat > /usr/local/etc/prometheus.yml <<'EOF'
> global:
> scrape_interval: 5s
> evaluation_interval: 5s
> rule_files:
> - /usr/local/etc/prometheus-rules.yml
> scrape_configs:
> - job_name: node
> static_configs:
> - targets: ['127.0.0.1:9100']
> EOF
root@practice:~ # cat > /usr/local/etc/prometheus-rules.yml <<'EOF'
> groups:
> - name: lab
> rules:
> - alert: HighLoad
> expr: node_load1 > 4
> for: 1m
> - alert: RootDiskLow
> expr: node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} < 0.9
> for: 10s
> annotations:
> summary: "root file system has less than 90 percent free (a demo threshold, deliberately strict)"
> EOF
root@practice:~ # promtool check config /usr/local/etc/prometheus.yml
Checking /usr/local/etc/prometheus.yml
SUCCESS: 1 rule files found
SUCCESS: /usr/local/etc/prometheus.yml is valid prometheus config file syntax
Checking /usr/local/etc/prometheus-rules.yml
SUCCESS: 2 rules found
- scrape_interval: 5s is very frequent, for the demonstration. A normal value is 15 to 60 seconds.
- The HighLoad rule says: alert if the 1-minute load is above 4 for a minute. (Not true here: 2 CPUs and a quiet system.)
- The RootDiskLow rule divides the free space by the size of the root file system and alerts if less than 90 percent is free, for 10 seconds. That threshold is deliberately too strict (the real value on this machine is about 79 percent free), so that the alert fires and we can look at it. A real rule would use something like 10 or 20 percent.
- promtool check config checks both files without starting anything: SUCCESS: 1 rule files found, SUCCESS: 2 rules found. Use it before every reload.
root@practice:~ # sysrc prometheus_enable=YES prometheus_enable: -> YES root@practice:~ # sysrc prometheus_args="--web.listen-address=127.0.0.1:9090" prometheus_args: -> --web.listen-address=127.0.0.1:9090 root@practice:~ # service prometheus start Starting prometheus. root@practice:~ # sockstat -4l -6l | grep -E "prometheus|node_exp" prometheus prometheus 37431 5 tcp4 127.0.0.1:9090 *:* nobody node_expor 78725 3 tcp4 127.0.0.1:9100 *:* root@practice:~ # fetch -qo - http://127.0.0.1:9090/-/healthy Prometheus Server is Healthy.
We started it with prometheus_args="--web.listen-address=127.0.0.1:9090": its own web port is also open to everyone by default, so we bound it to the loopback address too. sockstat shows both programs on 127.0.0.1, and the health check answers Prometheus Server is Healthy.
root@practice:~ # fetch -qo - 'http://127.0.0.1:9090/api/v1/query?query=up' | cut -c1-300
{"status":"success","data":{"resultType":"vector","result":[{"metric":{"__name__":"up","instance":"127.0.0.1:9100","job":"node"},"value":[1791309670.490,"1"]}]}}
root@practice:~ # fetch -qo - 'http://127.0.0.1:9090/api/v1/query?query=node_load1' | cut -c1-300
{"status":"success","data":{"resultType":"vector","result":[{"metric":{"__name__":"node_load1","instance":"127.0.0.1:9100","job":"node"},"value":[1791309670.695,"0.26416015625"]}]}}
root@practice:~ # fetch -qo - 'http://127.0.0.1:9090/api/v1/alerts' | cut -c1-420
{"status":"success","data":{"alerts":[{"labels":{"alertname":"RootDiskLow","device":"zroot/ROOT/default","fstype":"zfs","instance":"127.0.0.1:9100","job":"node","mountpoint":"/"},"annotations":{"summary":"root file system has less than 90 percent free (a demo threshold, deliberately strict)"},"state":"firing","activeAt":"2026-10-06T18:00:56.424967785Z","value":"7.945111440277886e-01"}]}}
Prometheus has an HTTP API. We asked it questions with fetch:
- query=up returns the value 1 for the job node at 127.0.0.1:9100. up is a metric Prometheus makes by itself for every target it scrapes: 1 means "the last scrape worked", 0 means it did not.
- query=node_load1 returns the current load.
- /api/v1/alerts lists the alerts that are active: the RootDiskLow alert, with its labels (device, file system type, mount point).
root@practice:~ # fetch -qo - 'http://127.0.0.1:9090/api/v1/alerts' | grep -o '"alertname":"[A-Za-z]*"\|"state":"[a-z]*"' "alertname":"RootDiskLow" "state":"firing" root@practice:~ # fetch -qo - 'http://127.0.0.1:9090/api/v1/rules' | grep -o '"name":"[A-Za-z]*","query"\|"health":"[a-z]*"' | head -6 "name":"HighLoad","query" "health":"ok" "name":"RootDiskLow","query" "health":"ok" root@practice:~ # fetch -qo - 'http://127.0.0.1:9090/api/v1/query?query=node_memory_free_bytes/node_memory_size_bytes' | grep -o '"value":\[[^]]*\]' "value":[1791309682.188,"0.24175390793058943"]
The alert's state is firing, as intended, and /api/v1/rules shows both rules with health: ok (they are valid and evaluating). And a calculation: node_memory_free_bytes/node_memory_size_bytes is 0.24: about a quarter of the memory is free. The query language can compute with metrics.
And the most important test of any monitoring system: what happens when the monitored thing dies?
root@practice:~ # service node_exporter stop Stopping node_exporter. Waiting for PIDS: 78725. root@practice:~ # fetch -qo - 'http://127.0.0.1:9090/api/v1/query?query=up' | grep -o '"value":\[[^]]*\]' "value":[1791309697.587,"0"] root@practice:~ # service node_exporter start Starting node_exporter. root@practice:~ # fetch -qo - 'http://127.0.0.1:9090/api/v1/query?query=up' | grep -o '"value":\[[^]]*\]' "value":[1791309709.989,"1"]
We stopped the exporter. Fifteen seconds later up was 0. We started it again and up was 1 again twelve seconds after that. An alert rule such as up == 0 for 1m is the standard "the machine or the exporter is down" alarm, and the best single alert there is, because it also catches the case where the whole monitoring path is broken.
Prometheus also has a web page of its own (a graph browser), at the same port: from the documentation, not run here. Two more packages complete the picture, and both are in the package list: Alertmanager, which takes the firing alerts and sends them to mail or chat (with grouping, silencing and so on), and Grafana, for dashboards. We did not run either. To watch other machines, add more targets to the scrape job, and install node_exporter on each (and open its port to the Prometheus server only): from the documentation, not run here.
A short look at SNMP
Network equipment (routers, switches, printers) mostly speaks SNMP, and FreeBSD's base system has an SNMP agent, bsnmpd:
root@practice:~ # grep -nE 'read :=|write :=|community|^begemotSnmpdPortStatus|:= *"' /etc/snmpd.config | head -8 8:location := "Room 200" 9:contact := "sysmeister@example.com" 24:read := "public" 25:# Uncomment begemotSnmpdCommunityString.0.2 below that sets the community 27:write := "geheim" 28:trap := "mytrap" 70:#user1 := "bsnmp" 83:# The default value of the community strings is NULL (note, that this is root@practice:~ # sysrc bsnmpd_enable=YES bsnmpd_enable: NO -> YES root@practice:~ # service bsnmpd start Starting bsnmpd. root@practice:~ # sockstat -4l -6l | grep bsnmpd root bsnmpd 13193 5 udp4 *:* *:* root bsnmpd 13193 6 udp4 *:161 *:* root bsnmpd 13193 7 udp6 *:161 *:* root@practice:~ # bsnmpget -s public@127.0.0.1 sysDescr.0 sysUpTime.0 2>&1 | cut -c1-150 sysDescr.0 = practice 3019700627 FreeBSD 15.0-RELEASE sysUpTime.0 = 34 root@practice:~ # bsnmpwalk -s public@127.0.0.1 ifInOctets 2>&1 | head -4 No Such Instance ifInOctets[0] = root@practice:~ # bsnmpwalk -s public@127.0.0.1 begemotSnmpdModuleSection 2>&1 | head -4 No Such Object begemotSnmpdModuleSection[] = root@practice:~ # grep -n "^begemotSnmpdModulePath" /etc/snmpd.config | head -6 143:begemotSnmpdModulePath."mibII" = "/usr/lib/snmp_mibII.so" root@practice:~ # service bsnmpd stop Stopping bsnmpd. Waiting for PIDS: 13193. root@practice:~ # sysrc bsnmpd_enable=NO bsnmpd_enable: YES -> NO root@practice:~ # sockstat -4l -6l | grep -c bsnmpd 0
- The default /etc/snmpd.config has the read community public and the write community geheim: SNMP's "passwords", and the same on every default installation. It also has a placeholder location and contact.
- After service bsnmpd start, sockstat shows it on UDP port 161 on every address, IPv4 and IPv6.
- bsnmpget -s public@127.0.0.1 sysDescr.0 sysUpTime.0 works: practice ... FreeBSD 15.0-RELEASE and the agent's uptime.
- Our attempts to read the interface counters (ifInOctets) returned No Such Instance, and hrSystemUptime is unknown ("No entry for hrSystemUptime in mapping lists"). More data needs more modules (the configuration file lists the ones that are loaded: by default only mibII showed up in our check, and others, such as the host resources module, have to be added: from the documentation, not run here) and a proper walk of the tables. We did not go further, so treat SNMP here as "it starts and answers", not as a tested monitoring setup.
- We then stopped it and disabled it again (sockstat finds nothing): an SNMP agent with default communities is not something to leave open. If you use it, change both community strings (or use SNMPv3), and allow only the monitoring server in the firewall.
What to alert on
Do not alert on every number. Alert on conditions that need a person to act, and on the absence of the monitoring itself. A good first list, every item of which you have seen above:
- A machine or an exporter is down: up == 0.
- Disk space: the free space of each important file system below a limit (df, or node_filesystem_avail_bytes).
- ZFS health: zpool status -x anything but all pools are healthy. And a scrub that has not run for a long time.
- Memory pressure: swap in use and growing (swapinfo, vmstat po).
- Load much higher than the number of processors for a long time.
- A service that should run is not running: service NAME status, or a Prometheus rule on the exporter's service metrics.
- Vulnerabilities: the nightly security report and pkg audit -F.
- Failed logins and refused connections: the nightly security report, and /var/log/auth.log.
Tune the thresholds so that every alert means something. If an alert goes off every day and is ignored, it is worse than no alert.
Monitoring the rest of the corner
- Jails and containers: jls, ps -J NAME, sockstat per jail, and the J flag in ps. A jail is a set of ordinary processes, so the whole-machine metrics include them.
- Virtual machines: vm list and bhyvectl --vm=NAME --get-stats (see the bhyve and vm-bhyve pages).
- Firewalls: the rule counters (pfctl -sr -v, ipfw -a list) and the logs (pflog, /var/log/security).
- Boot environments and snapshots: bectl list, zfs list -t snapshot.
What we did not do
All from the documentation, not run here: Grafana dashboards, Alertmanager, other monitoring systems (Nagios-style checks with plug-ins for ZFS and pf, Zabbix, netdata: all in the package list), smartmontools for disk health (a virtual disk has no SMART data, so there was nothing to test), zfsd, FreeBSD's daemon that reacts to disk faults (it can replace a failed disk with a spare), and DTrace for deep tracing. Also remote log collection.
⚠️ Do not leave monitoring ports open to the world. node_exporter (9100) and Prometheus (9090) listen on every address by default, and bsnmpd (161) arrives with well-known passwords. Bind them to a private address or 127.0.0.1, and use the firewall to allow only your monitoring server. A monitoring system should not be the way in.
So that it would give him a full 30 seconds<br>
of warning before the weekend.
🧪 Try it yourself
On a FreeBSD virtual machine:
1. Run uptime, top -b, vmstat 1 3, iostat -x -c 2 -w 1 DISK and zpool iostat 1 3. How many processors do you have, and is the load below that? 2. Run zpool status -x. Then make a mirror of two files, take one offline, and run it again. What changed? 3. Create /etc/periodic.conf with daily_output set to a file and daily_status_zfs_enable="YES". Run periodic daily and read the report. 4. Write /usr/local/bin/health-check.sh (copy ours), check it with sh -n, run it, and force a failure with MAX_DISK=10. 5. Log a line with logger -p daemon.info and another with daemon.notice. Which one reaches /var/log/messages? 6. Put the script in cron, and watch the log for two minutes. 7. Install node_exporter and prometheus. Bind both to 127.0.0.1. Check the config with promtool check config. 8. Ask the API for up. Stop the exporter. What does up say 15 seconds later? 9. Write an alert rule for free disk space with a threshold that fires. Look at /api/v1/alerts.
The cheat sheet
- Live: uptime, top -b, vmstat 1 3, swapinfo -h, iostat -x -c 2 -w 1 DISK, zpool iostat POOL 1 3, netstat -i -n, netstat -w 1 -q 3 -I IF. Interactive: gstat, systat.
- Quick health: zpool status -x, df -h, w, last.
- Nightly report: periodic daily|weekly|monthly. Defaults in /etc/defaults/periodic.conf, your changes in /etc/periodic.conf (daily_output, daily_status_zfs_enable, daily_scrub_zfs_enable). Default output goes to root's mail (mail, /var/mail/root).
- Logs: /var/log/messages keeps notice and above. logger -p daemon.notice -t TAG "text". Rotation by newsyslog (/etc/newsyslog.conf).
- Own checks: a script with logger, an exit status, sh -n, and a cron line (crontab -e, crontab -l, log in /var/log/cron).
- Prometheus: pkg install node_exporter prometheus, sysrc node_exporter_enable=YES node_exporter_listen_address="127.0.0.1:9100", sysrc prometheus_enable=YES prometheus_args="--web.listen-address=127.0.0.1:9090", promtool check config FILE, API: /api/v1/query?query=NAME, /api/v1/alerts, /api/v1/rules.
- Alert ideas: up == 0, disk space, zpool status -x, swap use, load, services, pkg audit.
- SNMP: sysrc bsnmpd_enable=YES, bsnmpget -s COMMUNITY@HOST OID. Change the default communities, and do not leave it open.
- Exposure: sockstat -4l -6l after installing any monitoring tool.
← FreeBSD in the Cloud: First-Boot Provisioning with nuageinit · ↑ FreeBSD Corner · ZFS Backups to Another Machine: Send, Receive, Restore →