Measurements
Benchmarks
What csi-s3-archiver costs to run, measured rather than estimated, and an explanation of why the benchmark people usually ask for, against goofys and other FUSE filesystems, does not apply to it.
Method#
The workload is synthetic and the harness drives the real engine: The same watcher, quiescence tracking, upload pipeline and object store a published volume gets. The S3 endpoint is SeaweedFS in a container on the same host, so the network is a loopback and the CPU figures are a floor, not a forecast for your endpoint.
| Detail | Value |
|---|---|
| Host | 12-core x86-64, Linux |
| Build under test | The native binary, statically linked with musl. Not the JVM build, whose memory profile is unrelated. |
| S3 endpoint | SeaweedFS 4.40 in a container, loopback network |
| Sampling | RSS and CPU from /proc every 200 ms; peak taken from VmHWM, the kernel's own high-water mark |
Resource use under load#
| Workload shape | Peak RSS | CPU | Data |
|---|---|---|---|
| Idle (1 volume, 1 MiB) | 69 MiB | 0.02 s | 1 MiB |
| Typical (4 volumes x 25 files x 1 MiB) | 128 MiB | 0.67 s | 100 MiB |
| Many volumes (32 x 10 x 1 MiB) | 152 MiB | 4.96 s | 320 MiB |
| One large file (2 x 512 MiB) | 156 MiB | 13.7 s | 1 GiB |
| With gzip (4 x 25 x 1 MiB) | 93 MiB | 0.58 s | 100 MiB |
| Segments strategy (4 x 25 x 4 MiB) | 147 MiB | 7.47 s | 400 MiB |
| Upload concurrency 16 | 138 MiB | 1.53 s | 100 MiB |
Memory is flat in file size
A 512 MiB file peaks no higher than thirty-two 1 MiB ones. Uploads stream from disk in fixed buffers and switch to multipart above a threshold, so there is no point at which the driver holds a file in memory. This is the property that lets a 200 GiB heapdump be archived by a container with a 256 MiB limit.
What does move memory is concurrency, and S3A_NODE_CONCURRENCY bounds it. That is the knob
to turn if you need a smaller limit, and the one to watch if you raise it.
Compression is cheaper than it looks
The gzip row uses less peak memory than the uncompressed one, because the compressed object is
smaller and the multipart threshold is reached later. It costs roughly the same CPU. For
log-shaped data, compression: gzip is close to free.
Head to head against s3fs#
Same host, same SeaweedFS, same writer script, three arms. local is a plain directory with nothing uploaded and exists as the control: Without it, "the archiver beats s3fs on writes" cannot be falsified, because the archiver writes to local disk and of course it does.
| Workload | Arm | Write | Durable |
|---|---|---|---|
| 200 x 256 KiB | local | 133 ms | n/a |
| s3fs | 2027 ms | 2027 ms | |
| archiver | 136 ms | 1143 ms | |
| 1 x 256 MiB | local | 61 ms | n/a |
| s3fs | 723 ms | 723 ms | |
| archiver | 59 ms | 2895 ms | |
| 64 MiB appended | local | 64 ms | n/a |
| s3fs | 2197 ms | 2197 ms | |
| archiver | 63 ms | 1287 ms | |
| 64 MiB, then exit | local | 19 ms | n/a |
| s3fs | 234 ms | 234 ms | |
| archiver | 22 ms | 1212 ms |
Write time
What the workload itself waits for.
The archiver arm finishes writing within a few milliseconds of the control in every shape, which is the structural claim of the design: The driver is not in the write path. s3fs costs the writer roughly 12 to 34 times the control, most at the append-heavy shape, which is what a log-producing pod looks like.
Time until the bytes are in the bucket
What you have left if the node dies. The control never uploads, so it does not appear.
s3fs reaches durability sooner for one large file and for a single quick write. The cause is
a floor rather than throughput: This driver waits for a file to go quiescent, then sweeps at pod
termination, so roughly a second passes before anything is durable no matter how small the
workload. s3fs streams throughout and is finished when the writer is. Lowering
quiescenceSeconds drops the floor, at the cost of uploading files that are still being written.
The benchmark script#
The harness that produced every number above. It starts the S3 endpoint, builds an s3fs image,
runs three arms over four workloads and writes the report. It installs nothing on the host and
skips with a reason if /dev/fuse is unavailable.
./build-native.sh # measure the shipped binary
bench/head-to-head/run.sh --binary target/csi-s3-archiverOne writer serves every arm, so the workload is not a variable:
#!/usr/bin/env bash
#
# The workload. ONE writer, used by every arm of the comparison, so the only thing that differs
# between arms is what sits between this script and the bucket.
#
# writer.sh <shape> <target-dir> <files> <file-bytes>
#
# It prints one line to stdout when it is done:
#
# WRITER wall_ms=<n> bytes=<n> files=<n>
#
# Nothing here is clever on purpose. dd with a fixed block size, no parallelism, no fsync games:
# a benchmark whose writer is doing something subtle measures the writer.
set -euo pipefail
SHAPE="${1:?shape}"
TARGET="${2:?target directory}"
FILES="${3:?file count}"
FILE_BYTES="${4:?bytes per file}"
BLOCK=65536
COUNT=$(( FILE_BYTES / BLOCK ))
[[ "$COUNT" -lt 1 ]] && COUNT=1
mkdir -p "$TARGET"
start_ns() { date +%s%N; }
START="$(start_ns)"
WRITTEN=0
case "$SHAPE" in
many-small|one-large)
for i in $(seq 1 "$FILES"); do
dd if=/dev/zero of="$TARGET/payload-$i.bin" bs="$BLOCK" count="$COUNT" \
conv=notrunc status=none
WRITTEN=$(( WRITTEN + COUNT * BLOCK ))
done
;;
append-log)
# A growing file, the shape this driver's appendOnly mode exists for. Appended in chunks
# rather than one write, so a FUSE mount pays per flush the way a real logger makes it.
for i in $(seq 1 "$FILES"); do
dd if=/dev/zero bs="$BLOCK" count="$COUNT" status=none >> "$TARGET/app.log"
WRITTEN=$(( WRITTEN + COUNT * BLOCK ))
done
;;
write-then-exit)
# One file, written and then the writer is gone immediately. This is the case a sweep-based
# design either handles or does not, and the one a preStop hook races with.
dd if=/dev/zero of="$TARGET/dump.bin" bs="$BLOCK" count="$COUNT" status=none
WRITTEN=$(( COUNT * BLOCK ))
;;
*)
echo "unknown shape: $SHAPE" >&2
exit 2
;;
esac
# sync so the comparison is not measuring who left more in the page cache. For s3fs this is
# where the upload actually happens, which is the point: its cost belongs to the writer.
sync
END="$(start_ns)"
echo "WRITER wall_ms=$(( (END - START) / 1000000 )) bytes=$WRITTEN files=$FILES"And the harness itself:
#!/usr/bin/env bash
#
# Head-to-head: csi-s3-archiver against s3fs, on one machine, against one S3 endpoint, with
# one writer.
#
# bench/head-to-head/run.sh --binary target/csi-s3-archiver
#
# WHAT IS HELD CONSTANT. Every arm runs in a container on the same Docker network, writes with
# the same writer.sh, and targets the same SeaweedFS. The local and archiver arms write to the
# same bind-mounted host directory, so the disk underneath them is identical. The only variable
# is what sits between the writer and the bucket.
#
# local a plain directory. Nothing is uploaded. The control.
# s3fs an s3fs-fuse mount of the bucket. The writer's writes go to S3.
# archiver a plain directory that csi-s3-archiver is archiving concurrently.
#
# WHY THE CONTROL ARM EXISTS. Without it, "the archiver is faster than s3fs" is unfalsifiable:
# the archiver writes to local disk, so of course it is. The control says how much of the
# difference is the disk and how much is the design. The archiver's write-path number should
# land on top of the control's, and if it does not, the driver is in the write path somewhere
# it should not be.
#
# TWO COLUMNS, NOT ONE. "Write time" and "time until every byte is in the bucket" are different
# questions and the arms win different ones. Reporting only the first flatters this project;
# reporting only the second flatters s3fs. Both are printed for every arm.
#
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$REPO_ROOT"
BINARY=""
SEAWEED_IMAGE="${SEAWEED_IMAGE:-chrislusf/seaweedfs:4.40}"
S3FS_IMAGE="csi-s3-archiver-bench-s3fs"
S3FS_VERSION="" # read from the built image; see below
BUCKET="headtohead"
ACCESS_KEY="bench-key"
SECRET_KEY="bench-secret"
RESULTS="$HERE/results.md"
KEEP=0
usage() {
cat <<'EOF'
Usage: bench/head-to-head/run.sh [options]
Compares csi-s3-archiver against s3fs and against a plain local directory, on one machine,
against one S3 endpoint, with one writer.
Options:
--binary PATH the native driver binary (default: target/csi-s3-archiver)
--results FILE where to write the report (default: bench/head-to-head/results.md)
--keep leave the containers running for inspection
-h, --help this help
Requires Docker and /dev/fuse. Nothing is installed on the host: s3fs runs in a pinned
container image this script builds.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--binary) BINARY="$2"; shift 2 ;;
--results) RESULTS="$2"; shift 2 ;;
--keep) KEEP=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
BINARY="${BINARY:-$REPO_ROOT/target/csi-s3-archiver}"
# --- preconditions ------------------------------------------------------------------------
command -v docker >/dev/null 2>&1 || { echo "error: docker is required" >&2; exit 1; }
if [[ ! -x "$BINARY" ]]; then
echo "error: $BINARY is not executable. Run ./build-native.sh first." >&2
echo " The comparison measures the binary that ships, not the JVM build." >&2
exit 1
fi
# FUSE is the one thing that cannot be worked around from inside a container. Skip with a
# reason rather than fail, the same way the e2e suite handles a kernel without vxlan.
if [[ ! -c /dev/fuse ]]; then
echo ">> SKIPPING: /dev/fuse is not available on this host, so s3fs cannot mount." >&2
echo ">> The archiver and local arms would still run, but a one-armed comparison is not" >&2
echo ">> a comparison. Run this on a host with the fuse module loaded." >&2
exit 0
fi
NETWORK="h2h-net-$$"
SEAWEED="h2h-seaweed-$$"
WORK_DIR="$(mktemp -d /tmp/h2h-XXXXXX)"
chmod 777 "$WORK_DIR"
cleanup() {
local status=$?
if [[ "$KEEP" != 1 ]]; then
docker rm -f "$SEAWEED" >/dev/null 2>&1 || true
docker network rm "$NETWORK" >/dev/null 2>&1 || true
rm -rf "$WORK_DIR"
else
echo ">> --keep: network $NETWORK, seaweed $SEAWEED, work dir $WORK_DIR"
fi
return $status
}
trap cleanup EXIT
# --- the S3 endpoint ----------------------------------------------------------------------
# SeaweedFS answers any SigV4-signed request with 400 unless an identity is configured, and
# both the AWS SDK and s3fs sign. Same fixture as the rest of this project's harnesses.
cat >"$WORK_DIR/s3.json" <<EOF
{
"identities": [
{
"name": "bench",
"credentials": [ { "accessKey": "$ACCESS_KEY", "secretKey": "$SECRET_KEY" } ],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
}
]
}
EOF
echo ">> building the s3fs image"
docker build -q -f "$HERE/Dockerfile.s3fs" -t "$S3FS_IMAGE" "$HERE" >/dev/null
# Reported rather than asserted: apt installs whatever the base image ships, so the honest
# thing is to read the version back out of the image that will actually run.
S3FS_VERSION="$(docker run --rm "$S3FS_IMAGE" 's3fs --version 2>&1 | head -1' \
| sed -n 's/.*V\([0-9.]*\).*/\1/p')"
echo ">> s3fs-fuse $S3FS_VERSION"
echo ">> starting SeaweedFS ($SEAWEED_IMAGE)"
docker network create "$NETWORK" >/dev/null
docker run -d --name "$SEAWEED" --network "$NETWORK" -P \
-v "$WORK_DIR/s3.json":/etc/s3.json:ro \
"$SEAWEED_IMAGE" server -s3 -dir=/data -ip=0.0.0.0 -s3.config=/etc/s3.json >/dev/null
S3_PORT="$(docker port "$SEAWEED" 8333/tcp | head -1 | sed 's/.*://')"
S3_HOST_URL="http://127.0.0.1:$S3_PORT"
S3_NET_URL="http://$SEAWEED:8333"
for _ in $(seq 1 120); do
curl -s -o /dev/null --max-time 2 "$S3_HOST_URL/" 2>/dev/null && break
sleep 1
done
echo ">> endpoint: $S3_HOST_URL (in-network: $S3_NET_URL)"
# s3fs refuses to mount a bucket that does not exist, and SeaweedFS creates one on the first
# PUT. The driver's own upload check is the shortest way to make that PUT, and it doubles as a
# proof that the endpoint and credentials work before any arm is timed.
echo ">> priming the bucket"
S3A_ENDPOINT="$S3_HOST_URL" S3A_BUCKET="$BUCKET" S3A_PATH_STYLE=true \
S3A_PREFIX="prime/" \
S3A_ACCESS_KEY_ID="$ACCESS_KEY" S3A_SECRET_ACCESS_KEY="$SECRET_KEY" \
"$BINARY" check-upload >"$WORK_DIR/prime.log" 2>&1 || {
echo "error: could not reach the S3 endpoint with the driver:" >&2
sed 's/^/ | /' "$WORK_DIR/prime.log" >&2
exit 1
}
# --- helpers ------------------------------------------------------------------------------
now_ms() { echo $(( $(date +%s%N) / 1000000 )); }
# Every result row lands here as: shape arm write_ms durable_ms bytes peak_rss_kb
: >"$WORK_DIR/rows.txt"
record() {
echo "$1 $2 $3 $4 $5 $6" >>"$WORK_DIR/rows.txt"
printf ' %-16s %-9s write %7s ms durable %8s ms %6s MiB\n' \
"$1" "$2" "$3" "$4" "$(( $5 / 1048576 ))"
}
# Peak RSS of a pid, sampled until it exits. VmHWM is the kernel's own high-water mark, so
# the peak is exact even between samples; the loop exists only to read it before the process
# is gone, which matters because the sweep is the expensive part.
sample_peak_rss() {
local pid="$1" out="$2"
echo 0 >"$out"
while kill -0 "$pid" 2>/dev/null; do
local hwm
hwm="$(awk '/^VmHWM:/ {print $2}' "/proc/$pid/status" 2>/dev/null || true)"
[[ -n "$hwm" ]] && echo "$hwm" >"$out"
sleep 0.1
done
}
# --- the arms -----------------------------------------------------------------------------
# local: a plain directory. No upload, so "durable" is not applicable and is reported as -1.
run_local() {
local shape="$1" files="$2" bytes="$3"
local dir="$WORK_DIR/local-$shape"
mkdir -p "$dir"
local out
out="$(docker run --rm --network "$NETWORK" -v "$dir":/data "$S3FS_IMAGE" \
"/usr/local/bin/writer.sh $shape /data $files $bytes")"
local write_ms bytes_written
write_ms="$(sed -n 's/.*wall_ms=\([0-9]*\).*/\1/p' <<<"$out")"
bytes_written="$(sed -n 's/.*bytes=\([0-9]*\).*/\1/p' <<<"$out")"
record "$shape" "local" "$write_ms" "-1" "$bytes_written" "0"
}
# s3fs: the writer writes straight into the mount, so the upload is inside its wall time.
run_s3fs() {
local shape="$1" files="$2" bytes="$3"
local prefix="s3fs-$shape"
# -o use_path_request_style for a container-hosted endpoint with no per-bucket DNS, and
# nocopyapi/nomultipart off: the defaults are what a user would get.
local script="
echo '$ACCESS_KEY:$SECRET_KEY' > /etc/passwd-s3fs && chmod 600 /etc/passwd-s3fs
mkdir -p /mnt/s3
s3fs $BUCKET /mnt/s3 \
-o url=$S3_NET_URL \
-o use_path_request_style \
-o passwd_file=/etc/passwd-s3fs \
-o dbglevel=crit \
-o allow_other
mkdir -p /mnt/s3/$prefix
/usr/local/bin/writer.sh $shape /mnt/s3/$prefix $files $bytes
umount /mnt/s3
"
local start out
start="$(now_ms)"
out="$(docker run --rm --network "$NETWORK" \
--device /dev/fuse --cap-add SYS_ADMIN --security-opt apparmor:unconfined \
"$S3FS_IMAGE" "$script")"
local write_ms bytes_written
write_ms="$(sed -n 's/.*wall_ms=\([0-9]*\).*/\1/p' <<<"$out")"
bytes_written="$(sed -n 's/.*bytes=\([0-9]*\).*/\1/p' <<<"$out")"
# For s3fs the bytes are in the bucket when the writer returns (the unmount above forces
# any remainder), so durability costs nothing beyond the write.
record "$shape" "s3fs" "$write_ms" "$write_ms" "$bytes_written" "0"
}
# archiver: the writer writes to a plain directory that the driver is archiving CONCURRENTLY,
# which is what happens in a pod. The sweep at the end is pod termination.
run_archiver() {
local shape="$1" files="$2" bytes="$3"
local dir="$WORK_DIR/arch-$shape"
local sentinel="$WORK_DIR/done-$shape"
local prefix="archiver-$shape/"
mkdir -p "$dir"
rm -f "$sentinel"
local append_only="false" strategy="rewrite"
if [[ "$shape" == "append-log" ]]; then
append_only="true"
strategy="segments"
fi
local start
start="$(now_ms)"
# The driver first, so archiving overlaps the writing exactly as it would in a pod.
S3A_ENDPOINT="$S3_HOST_URL" \
S3A_BUCKET="$BUCKET" \
S3A_PATH_STYLE=true \
S3A_PREFIX="$prefix" \
S3A_ACCESS_KEY_ID="$ACCESS_KEY" \
S3A_SECRET_ACCESS_KEY="$SECRET_KEY" \
S3A_QUIESCENCE_SECONDS=1 \
S3A_BENCH_SOURCE_DIR="$dir" \
S3A_BENCH_UNTIL_FILE="$sentinel" \
S3A_BENCH_APPEND_ONLY="$append_only" \
S3A_BENCH_APPEND_STRATEGY="$strategy" \
"$BINARY" bench >"$WORK_DIR/arch-$shape.log" 2>&1 &
local driver_pid=$!
sample_peak_rss "$driver_pid" "$WORK_DIR/rss-$shape" &
local sampler_pid=$!
sleep 0.5
local out
out="$(docker run --rm --network "$NETWORK" -v "$dir":/data "$S3FS_IMAGE" \
"/usr/local/bin/writer.sh $shape /data $files $bytes")"
local write_ms bytes_written
write_ms="$(sed -n 's/.*wall_ms=\([0-9]*\).*/\1/p' <<<"$out")"
bytes_written="$(sed -n 's/.*bytes=\([0-9]*\).*/\1/p' <<<"$out")"
# The writer is gone: this is the pod terminating, and the sweep is what unpublish does.
touch "$sentinel"
wait "$driver_pid" || {
echo "error: the archiver arm failed for $shape:" >&2
sed 's/^/ | /' "$WORK_DIR/arch-$shape.log" >&2
exit 1
}
local durable_ms=$(( $(now_ms) - start ))
wait "$sampler_pid" 2>/dev/null || true
local rss
rss="$(cat "$WORK_DIR/rss-$shape" 2>/dev/null || echo 0)"
record "$shape" "archiver" "$write_ms" "$durable_ms" "$bytes_written" "$rss"
}
# --- run ----------------------------------------------------------------------------------
# Shapes chosen to cover what the two designs actually differ on: per-file overhead, one big
# transfer, an append pattern, and a writer that exits immediately.
# shape files bytes-each
SHAPES=(
"many-small 200 262144"
"one-large 1 268435456"
"append-log 64 1048576"
"write-then-exit 1 67108864"
)
echo
echo ">> running (one writer, three arms, same endpoint)"
echo
for spec in "${SHAPES[@]}"; do
read -r shape files bytes <<<"$spec"
echo " --- $shape ($files x $(( bytes / 1024 )) KiB) ---"
run_local "$shape" "$files" "$bytes"
run_s3fs "$shape" "$files" "$bytes"
run_archiver "$shape" "$files" "$bytes"
echo
done
# --- report -------------------------------------------------------------------------------
# The raw rows are kept beside the report: regenerating the prose should never require
# re-measuring, and a reader can check the arithmetic.
cp "$WORK_DIR/rows.txt" "$HERE/results.tsv"
"$HERE/report.sh" "$HERE/results.tsv" "$S3FS_VERSION" "$SEAWEED_IMAGE" >"$RESULTS"
echo ">> wrote $RESULTS (raw rows in $HERE/results.tsv)"Why the goofys benchmark does not transfer#
The goofys benchmark is the one people ask about,
and it measures a FUSE filesystem: ls of a large directory, sequential read throughput, random
read latency, file creation rate. Those are the right measurements for goofys, s3fs and
Mountpoint for Amazon S3, and most of them measure something csi-s3-archiver does not do.
| That benchmark measures | What this driver does |
|---|---|
| Read throughput from S3 | Nothing. An ephemeral volume never reads from S3. A durable one reads once, at pod start. |
| Random-read latency | Nothing. There is no read path to be random about. |
ls on a large directory | A local directory listing: A syscall, not a request. |
| File creation rate | The node's filesystem. The driver is not in the write path, as the table above shows. |
| Sequential write to the mount | The one that does transfer, and it is the write column above. |
goofys is also archived upstream, which is why the head-to-head above uses s3fs: It is maintained, packaged, and the mount people actually encounter. Worth knowing when reading the write column, though: s3fs deliberately trades speed for POSIX completeness, doing consistency work per operation that goofys skips. Part of that 12-to-34x is a win against that choice rather than against FUSE as such.
What reaches the bucket#
Since throughput is not the differentiator, the honest axis is reliability of delivery: Given a pod that writes a file and then terminates, does the file arrive?
| Approach | Survives a pod that exits immediately | Survives a node crash | Costs the write path |
|---|---|---|---|
| csi-s3-archiver | Yes: Unpublish blocks on a final sweep | Partly: Whatever was archived, plus anything recovered at the next boot | Nothing |
preStop hook | Racy: Bounded by the termination grace period | No | Nothing |
Sidecar + emptyDir | Racy: The sidecar may be killed first | No | Nothing |
| S3 SDK in the application | Yes, if the application handles it | No | Yes, and the application owns the retries |
| FUSE mount | Yes: The write already went to S3 | Yes | A round trip per write |
csi-s3-archiver's row is deliberately not all-green. A node that loses power between the last upload and the sweep loses whatever had not been archived yet; the driver re-archives what is on disk when the node comes back, but a destroyed node destroys unarchived data. That is the trade for keeping the write path local, and it is why the semantics section states the contract as at-least-once and eventual rather than synchronous.
Reproduce it#
The script is in the repository and takes one argument that matters.
./build-native.sh # measure what ships, not the JVM build
hack/measure-resources.sh \
--binary target/csi-s3-archiver \
--sweep # all seven shapes; omit for one
# or a single shape of your own
hack/measure-resources.sh --binary target/csi-s3-archiver \
--volumes 8 --files 50 --file-bytes 4194304 --concurrency 8It starts a SeaweedFS container, runs the driver's bench subcommand against it, samples the
process while it works, and prints a suggested resources block derived from the measured peak.
Re-run it against your own workload shape before trusting the defaults: The numbers above
describe one host and one endpoint, and your S3 latency moves the CPU figures more than the code
does.