Storage Path Canary: NFS and SMB, Timed End to End
Data path covered by an automated test suite; the mount and unmount phases need a lab shakedown before production use.
The problem
The connectivity validator answers a binary question: does the storage answer at all. This tool answers the question that actually generates tickets: is the storage behaving the way it did last Tuesday. "The share is slow" is one of the most common and least tractable complaints in storage support, and it is intractable for exactly one reason: nobody has a baseline. Without a record of what mount, write, read, and delete latencies looked like when everyone was happy, "slow" is unfalsifiable, and the investigation starts from zero every single time.
The exerciser closes that gap by doing, on a schedule, precisely what a client does: mount the export or share, write a file, read it back, verify the bytes, delete it, unmount. Every phase is timed in milliseconds and the numbers go to a CSV. Six weeks later, "is it slow" has an answer with history behind it.
What this is not, on purpose
This is a canary, not a benchmark. It moves one small file through one code path and measures the wall-clock experience of a real client, which deliberately includes everything: name resolution, authentication, protocol negotiation, network round trips, server-side processing, and the disks underneath. That inclusiveness is useless for isolating a single component and exactly right for detecting that the end-to-end experience changed, which is the question users are actually asking. If you need controlled I/O characterization with queue depths and block size sweeps, that tool already exists and it is called fio. Building a worse fio in bash would be a waste of everyone's evening. Detecting drift in what a client feels, cheaply, forever, on a schedule: that is the niche, and it is a real one.
The write safety contract
Any tool that writes to production storage on a cron schedule needs a mechanism, not a convention, standing between it and the wrong directory. The contract here: the exerciser will only write inside a directory containing a marker file named .mount_exerciser_scratch_area, placed there once, by a human, on purpose. No marker, no writes, hard exit, alert logged. A typo in the scratch path configuration therefore fails closed instead of scattering test files through a production tree. This is the same reasoning as the dry-run defaults in Break/Fix Taught Me to Write Safer Automation: assume the configuration will eventually be wrong, and decide now what wrong costs.
Design decisions
Timing includes a flush, or the write number is fiction. A plain write into a mounted filesystem can land in the client page cache and return in microseconds, telling you nothing about the storage. The write phase here forces data to the server with dd conv=fsync, so the measured time includes the round trip that matters. Tradeoff: this measures durable-write latency, which is the honest number and also the slower one, so do not compare it against cached-write figures from other tools.
The payload is generated once, locally, before the clock starts. Reading /dev/urandom during a timed phase would bill entropy generation to the storage. The payload is staged in a local temporary directory first, and the timed phases measure only movement and verification of bytes.
Verification is cmp, not checksum comparison. cmp against the local payload proves the bytes that came back are the bytes that went in, byte for byte, and stops at the first difference. A silent mismatch here is a five-alarm finding that outranks any latency number, and the exit code treats it that way.
Soft mount with bounded timeouts, because a hung canary is worse than a dead one. Production data wants hard mounts, as argued in Anatomy of a Failover. A monitoring tool wants the opposite: it must always terminate, report, and release its mount point. Soft, short timeo, low retrans, and a timeout wrapper on every phase.
CSV, not a dashboard. One append-only file with a stable column order. It graphs in anything, diffs in anything, and survives tool churn.
The script
#!/usr/bin/env bash
#
# ---------------------------------------------------------------------
# Path: storage_mount_exerciser.sh
# Filename: storage_mount_exerciser.sh
# Project: storage_mount_exerciser
# Description: Data-path canary for NFS exports and SMB shares. Mounts,
# writes, reads back, verifies, deletes, and unmounts,
# timing every phase in milliseconds into a CSV history.
# Status: production
# Revision: 2
# Updated: 2026-08-05
# Requires: Bash 4.2 or newer, GNU coreutils, util-linux (flock,
# mountpoint, findmnt), mount.nfs or mount.cifs
# Included by: standalone command line tool; sourceable for testing
# Provides: current_time_milliseconds, verify_scratch_marker,
# build_csv_row, build_report_line, run_timed_phase,
# append_history_row, exercise_data_path
# ---------------------------------------------------------------------
#
# Portability
# Target shells: bash 4.2+ (associative arrays require 4.0+).
# Tested on: bash 5.2 on Ubuntu 24.04. Data-path phases are
# exercised against a local directory in the test
# suite; the mount phases require real storage and
# must be shaken down in a lab before production use.
# date +%s%N is GNU coreutils. BSD date does not support %N.
#
# Exit codes:
# 0 every phase passed
# 1 a phase failed (mount, write, read, delete, unmount)
# 2 configuration, dependency, lock, or safety contract error
# 3 INTEGRITY MISMATCH: bytes read back differ from bytes written
#
# Exit 3 is deliberately distinct. A latency problem and a corruption
# problem are not the same finding and must not share an exit code,
# because the alerting that routes them should not be the same either.
#
# Environment (all optional; defaults shown):
# EXERCISER_PROTOCOL nfs
# EXERCISER_NFS_SERVER storage01.example.net
# EXERCISER_NFS_EXPORT /ifs/data/export
# EXERCISER_SMB_SERVER storage01.example.net
# EXERCISER_SMB_SHARE data
# EXERCISER_SMB_CREDENTIALS /etc/storage_mount_exerciser/smb_credentials
# EXERCISER_SCRATCH_RELATIVE monitoring/exerciser_scratch
# EXERCISER_PAYLOAD_KB 1024
# EXERCISER_WARN_MS 2000
# EXERCISER_HISTORY_FILE /var/lib/storage_mount_exerciser/history.csv
# EXERCISER_LOG_FILE /var/log/storage_mount_exerciser.log
# EXERCISER_LOCK_FILE /var/lock/storage_mount_exerciser.lock
# EXERCISER_PHASE_TIMEOUT 30
# EXERCISER_LIB_ONLY set to 1 to source functions for testing
#
set -u
# ---------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------
storage_protocol="${EXERCISER_PROTOCOL:-nfs}"
nfs_server="${EXERCISER_NFS_SERVER:-storage01.example.net}"
nfs_export_path="${EXERCISER_NFS_EXPORT:-/ifs/data/export}"
smb_server="${EXERCISER_SMB_SERVER:-storage01.example.net}"
smb_share_name="${EXERCISER_SMB_SHARE:-data}"
smb_credentials_file="${EXERCISER_SMB_CREDENTIALS:-/etc/storage_mount_exerciser/smb_credentials}"
scratch_relative_path="${EXERCISER_SCRATCH_RELATIVE:-monitoring/exerciser_scratch}"
scratch_marker_filename=".mount_exerciser_scratch_area"
payload_size_kilobytes="${EXERCISER_PAYLOAD_KB:-1024}"
latency_warning_milliseconds="${EXERCISER_WARN_MS:-2000}"
csv_history_file="${EXERCISER_HISTORY_FILE:-/var/lib/storage_mount_exerciser/history.csv}"
log_file="${EXERCISER_LOG_FILE:-/var/log/storage_mount_exerciser.log}"
lock_file="${EXERCISER_LOCK_FILE:-/var/lock/storage_mount_exerciser.lock}"
phase_timeout_seconds="${EXERCISER_PHASE_TIMEOUT:-30}"
exerciser_phase_names=(mount write read verify delete unmount)
csv_header_line="timestamp,protocol,outcome,failed_phase,mount_ms,write_ms,read_ms,verify_ms,delete_ms,unmount_ms"
# Declared global so the script can be sourced from inside a shell
# function (which is how bats loads it) without the array becoming
# function-local and vanishing before any test runs.
declare -gA measured_milliseconds=()
mount_point_directory=""
local_staging_directory=""
# ---------------------------------------------------------------------
# Infrastructure
# ---------------------------------------------------------------------
log_message() {
local log_directory
log_directory="$(dirname "${log_file}")"
[ -d "${log_directory}" ] || mkdir -p "${log_directory}" 2>/dev/null || return 0
printf '%s %s\n' "$(date --iso-8601=seconds)" "${1}" >> "${log_file}" 2>/dev/null || true
}
current_time_milliseconds() {
local nanosecond_timestamp
nanosecond_timestamp="$(date +%s%N)"
printf '%s' "$((nanosecond_timestamp / 1000000))"
}
cleanup_on_exit() {
if [ -n "${mount_point_directory}" ] \
&& mountpoint -q "${mount_point_directory}" 2>/dev/null; then
umount "${mount_point_directory}" 2>/dev/null \
|| umount -l "${mount_point_directory}" 2>/dev/null
fi
[ -n "${mount_point_directory}" ] && rmdir "${mount_point_directory}" 2>/dev/null
[ -n "${local_staging_directory}" ] && rm -rf "${local_staging_directory}"
return 0
}
# ---------------------------------------------------------------------
# Reporting helpers. Kept pure so they can be tested without storage.
# ---------------------------------------------------------------------
# build_csv_row <timestamp> <protocol> <outcome> <failed_phase>
# A failed run still produces a row. A history that only records
# successes cannot answer "how often does this break," which is half
# the question the CSV exists to answer. Unmeasured phases are empty
# fields, never zero, because zero is a latency claim and empty is not.
build_csv_row() {
local row="${1},${2},${3},${4}"
local phase_name
for phase_name in "${exerciser_phase_names[@]}"; do
row+=",${measured_milliseconds[${phase_name}]:-}"
done
printf '%s' "${row}"
}
build_report_line() {
local outcome="${1}"
local line="${outcome} ${storage_protocol}"
local phase_name
for phase_name in "${exerciser_phase_names[@]}"; do
if [ -n "${measured_milliseconds[${phase_name}]:-}" ]; then
line+=" ${phase_name}=${measured_milliseconds[${phase_name}]}ms"
fi
done
printf '%s' "${line}"
}
append_history_row() {
local row="${1}"
local history_directory
history_directory="$(dirname "${csv_history_file}")"
mkdir -p "${history_directory}" 2>/dev/null || return 2
if [ ! -f "${csv_history_file}" ]; then
printf '%s\n' "${csv_header_line}" > "${csv_history_file}" || return 2
fi
printf '%s\n' "${row}" >> "${csv_history_file}" || return 2
return 0
}
# finish_run <exit_code> <outcome> <failed_phase>
# Every exit path records a row and a log line, so the history is a
# complete record of what was attempted rather than a highlight reel.
finish_run() {
local exit_code="${1}"
local outcome="${2}"
local failed_phase="${3:-}"
local row
row="$(build_csv_row "$(date --iso-8601=seconds)" "${storage_protocol}" \
"${outcome}" "${failed_phase}")"
append_history_row "${row}" || log_message "WARN: could not append to ${csv_history_file}"
local report_line
report_line="$(build_report_line "${outcome}")"
log_message "${report_line}"
if [ "${exit_code}" -eq 0 ]; then
printf '%s\n' "${report_line}"
else
printf '%s%s\n' "${report_line}" \
"${failed_phase:+ failed_phase=${failed_phase}}" >&2
fi
return "${exit_code}"
}
# ---------------------------------------------------------------------
# Safety contract
# ---------------------------------------------------------------------
# verify_scratch_marker <scratch_directory>
# Writes happen only inside a directory a human deliberately blessed by
# placing a marker file. A typo in the scratch path fails closed rather
# than scattering test files through a production tree.
verify_scratch_marker() {
local scratch_directory="${1}"
[ -f "${scratch_directory}/${scratch_marker_filename}" ]
}
# ---------------------------------------------------------------------
# Timed phases
# ---------------------------------------------------------------------
exerciser_failed_phase=""
# run_timed_phase <phase_name> <command...>
# Records the duration and returns nonzero on failure. It does not exit,
# so the caller owns the exit path and every failure still gets a row.
run_timed_phase() {
local phase_name="${1}"
shift
local phase_start_milliseconds
local phase_end_milliseconds
phase_start_milliseconds="$(current_time_milliseconds)"
if ! timeout "${phase_timeout_seconds}" "$@" > /dev/null 2>&1; then
exerciser_failed_phase="${phase_name}"
log_message "FAIL: phase ${phase_name} failed or exceeded ${phase_timeout_seconds}s"
return 1
fi
phase_end_milliseconds="$(current_time_milliseconds)"
measured_milliseconds["${phase_name}"]=$((phase_end_milliseconds - phase_start_milliseconds))
if [ "${measured_milliseconds[${phase_name}]}" -gt "${latency_warning_milliseconds}" ]; then
log_message "WARN: phase ${phase_name} took ${measured_milliseconds[${phase_name}]}ms (threshold ${latency_warning_milliseconds}ms)"
fi
return 0
}
# exercise_data_path <scratch_directory> <payload_file> <readback_file>
# The write, read, verify, and delete phases against an already-mounted
# path. Separated from mounting so the data-path logic is testable
# against a local directory without any storage at all.
#
# Returns: 0 success, 1 phase failure, 3 integrity mismatch.
exercise_data_path() {
local scratch_directory="${1}"
local payload_file="${2}"
local readback_file="${3}"
local short_host_name
short_host_name="$(hostname -s 2>/dev/null || printf 'host')"
local remote_test_file="${scratch_directory}/exerciser_${short_host_name}_$$.bin"
run_timed_phase "write" dd if="${payload_file}" of="${remote_test_file}" \
bs=1024 conv=fsync status=none || return 1
run_timed_phase "read" dd if="${remote_test_file}" of="${readback_file}" \
bs=1024 status=none || return 1
# An integrity mismatch outranks every latency number in this run and
# gets its own exit code so alert routing can treat it differently.
if ! run_timed_phase "verify" cmp "${payload_file}" "${readback_file}"; then
log_message "INTEGRITY MISMATCH: bytes read back differ from bytes written"
rm -f "${remote_test_file}" 2>/dev/null
return 3
fi
run_timed_phase "delete" rm -f "${remote_test_file}" || return 1
return 0
}
# ---------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------
main() {
trap cleanup_on_exit EXIT
local required_command
local missing_commands=()
for required_command in mount umount mountpoint dd cmp timeout flock mktemp date; do
command -v "${required_command}" > /dev/null 2>&1 \
|| missing_commands+=("${required_command}")
done
if [ "${#missing_commands[@]}" -gt 0 ]; then
printf 'FAIL: missing required command(s): %s\n' "${missing_commands[*]}" >&2
return 2
fi
# A lock file that cannot be opened is a configuration error, not a
# phase failure, and must not be reported as one.
mkdir -p "$(dirname "${lock_file}")" 2>/dev/null
if ! exec 200>"${lock_file}"; then
printf 'FAIL: cannot open lock file %s\n' "${lock_file}" >&2
return 2
fi
if ! flock -n 200; then
log_message "Previous run still holds the lock, exiting."
return 0
fi
mount_point_directory="$(mktemp -d)" || { printf 'FAIL: cannot create mount point\n' >&2; return 2; }
local_staging_directory="$(mktemp -d)" || { printf 'FAIL: cannot create staging directory\n' >&2; return 2; }
# Stage the payload locally before any clock starts, so entropy
# generation is never billed to the storage.
local local_payload_file="${local_staging_directory}/payload.bin"
dd if=/dev/urandom of="${local_payload_file}" bs=1024 \
count="${payload_size_kilobytes}" status=none \
|| { printf 'FAIL: payload staging failed\n' >&2; return 2; }
local mount_status=0
case "${storage_protocol}" in
nfs)
run_timed_phase "mount" mount -t nfs -o rw,soft,timeo=30,retrans=2 \
"${nfs_server}:${nfs_export_path}" "${mount_point_directory}" \
|| mount_status=1
;;
smb)
if [ ! -r "${smb_credentials_file}" ]; then
printf 'FAIL: SMB credentials file unreadable: %s\n' "${smb_credentials_file}" >&2
return 2
fi
run_timed_phase "mount" mount -t cifs \
-o "credentials=${smb_credentials_file},soft" \
"//${smb_server}/${smb_share_name}" "${mount_point_directory}" \
|| mount_status=1
;;
*)
printf 'FAIL: unknown storage protocol: %s\n' "${storage_protocol}" >&2
return 2
;;
esac
if [ "${mount_status}" -ne 0 ]; then
finish_run 1 FAIL mount
return 1
fi
local scratch_directory="${mount_point_directory}/${scratch_relative_path}"
if ! verify_scratch_marker "${scratch_directory}"; then
printf 'FAIL: scratch marker %s not found in %s; refusing to write\n' \
"${scratch_marker_filename}" "${scratch_relative_path}" >&2
log_message "SAFETY: scratch marker absent, refused to write"
return 2
fi
local data_path_status=0
exercise_data_path "${scratch_directory}" "${local_payload_file}" \
"${local_staging_directory}/readback.bin" || data_path_status=$?
if [ "${data_path_status}" -eq 3 ]; then
finish_run 3 INTEGRITY_MISMATCH verify
return 3
fi
if [ "${data_path_status}" -ne 0 ]; then
finish_run 1 FAIL "${exerciser_failed_phase}"
return 1
fi
run_timed_phase "unmount" umount "${mount_point_directory}" || {
finish_run 1 FAIL unmount
return 1
}
finish_run 0 PASS ""
return 0
}
if [ "${EXERCISER_LIB_ONLY:-0}" != "1" ]; then
main "$@"
exit $?
fi
Deployment
# One-time: bless the scratch area, deliberately, by hand
mkdir -p /ifs/data/export/monitoring/exerciser_scratch
touch /ifs/data/export/monitoring/exerciser_scratch/.mount_exerciser_scratch_area
# Cron, every fifteen minutes; requires root for mount operations
*/15 * * * * root /usr/local/sbin/storage_mount_exerciser.sh
Alerting on the numbers is deliberately not this script's job. The validator alerts on availability; the exerciser records experience. Bolting a second alerting pipeline into a canary is how you end up maintaining two half-alerting systems. When the CSV shows a phase trending against its baseline, that finding feeds whatever alerting already exists.
Known limitations
The mount and unmount phases require real storage and are not covered by the test suite; everything from the safety contract through byte verification is exercised against a local directory, and the mount path wants a lab shakedown before production. Root required for mount operations; an unprivileged smbclient-based mode is a plausible future variant with different measurement semantics. One protocol per run, so NFS and SMB coverage means two cron entries and two config copies until external config lands. The first weeks of CSV are just data; it becomes a baseline only after it spans normal load variation, including backup windows. Latencies include client-side effects by design, so a busy monitoring host shows up in the numbers, which argues for running it from a quiet box.
Roadmap
External configuration file shared in format with the validator. A small companion script that reads the CSV and reports each phase against its trailing 30-day percentiles, which is where the baseline starts paying rent. Multi-target support once the config file exists.