Published

Scheduling Automatic ZFS Snapshots

A quick note with some helpul scripts.

It's easy to snapshot a ZFS dataset with zfs-snapshot(8) but remembering to do this manually is error-prone. A better solution is to use systemd timers.

Create a Single Systemd Unit and Timer

Snapshots must have a unique name, and we'll need to generate that dynamically. Creating a simple script for this is helpul. The following is a simplified version of the script I use.

/usr/local/sbin/znap:

#!/bin/bash

# List all snapshots if no args are provided
if [[ $# -eq 0 ]]; then
    /usr/bin/zfs list -t snapshot
    exit
fi

# Treat each arg as a dataset and snapshot it
for dataset in "$@"; do
    snapname="$dataset@$(date --iso=s | head -c 19)"
    /usr/bin/zfs snapshot "$snapname"
done

Now create a unit file to trigger snapshots. Let's assume the dataset my-data is in a pool called tank.

/etc/systemd/system/snapshot-my-data.service:

[Unit]
Description=Snapshot ZFS dataset "my-data"

[Service]
ExecStart=/usr/local/sbin/znap tank/my-data

Add a timer file.

/etc/systemd/system/snapshot-my-data.timer:

[Unit]
Description=Daily snapshot for my-data

[Timer]
OnCalendar=daily
AccuracySec=1h
Persistent=true

[Install]
WantedBy=timers.target

Now we can activate the timer with systemctl daemon-reload && systemctl start snapshot-my-data.timer and confirm that the timer has started with systemctl list-timers.

Using an @ Service

ZFS dataset names can't be used directly in an @ service because they contain / characters. The workaround I use is giving datasets aliases in a tab-separated configuration file. This is also helpul to avoid needing to remember the full paths to your important data sets when running znap manually!

/etc/zfs_alias:

my-data  mypool/my-data

Now update the znap script to parse the alias file.

/usr/local/bin/znap

#!/bin/bash

if [[ $# -eq 0 ]]; then
    /usr/bin/zfs list -t snapshot
    exic
fi

declare -A aliases

while read line; do
    if [[ -z "line" || "$line" = "\n" ]]; then
        continue
    fi
    line="$(sed 's/\t\t*/\t/' <<<"$line")"
    key="$(cut -f 1 <<<"$line")"
    val="$(cut -f 2 <<<"$line")"
    aliases["$key"]="$val"
done </etc/zfs_alias

for dataset in "$@"; do
    if ! /usr/bin/zfs list "$dataset"; then
        if [[ -z "${aliases["$dataset"]}" ]]; then
            echo "Neither dataset nor alias: $dataset"
            continue
        fi
        dataest="${aliases["$dataset"]}"
    fi
    snapname="$dataset@$(date --iso=s | head -c 19)"
    /usr/bin/zfs snapshot "$snapname"
done

The @ service is a simple modification to the unit service described above

/etc/systemd/system/znap@.service:

[Unit]
Description=Snapshot ZFS dataset "my-data"

[Service]
ExecStart=/usr/local/sbin/znap %i

Create a symlink to instantiate the @ service:

ln -s /etc/systemd/system/znap@.service /etc/systemd/system/znap@my-data.service

Now create a timer for the service:

/etc/systemd/system/znap@my-data.timer

[Unit]
Description=Daily snapshot for my-data

[Timer]
OnCalendar=daily
AccuracySec=1h
Persistent=true

[Install]
WantedBy=timers.target

Finally activate the timer as above: systemctl daemon-reload && systemctl start znap@my-data.service.

Conclusion

I hope you find the znap script and systemd tips helpful. Message me if you see any glaring errors or have suggestions!