Drücke „Enter”, um zum Inhalt zu springen.

ZFS disk spin down

FWorkx 2

Ich betreibe einen Homeserver mit Proxmox als Virtualisierungslösung. In diesem Server laufen eine SSD für Proxmox und die Container und VMs und drei 2TB Platten in einem Raid-Z1 (eine Raid 5 ähnliche Implementierung in ZFS). Nun steht auf Grund von Platzmangel der Server bei uns im Schlafzimmer und zusätzlich bin ich ziemlich geräuschempfindlich. Gerade wenn es nachts leise ist, habe ich die Platten doch immer wieder gehört. Ich habe mich also nach einer Möglichkeit umgesehen, diese „schlafen zu legen“ sofern sie nicht benötigt werden.

Hierzu sei angemerkt: Die Meinungen im Netz die Platten in den Standby zu schicken gehen deutlich auseinander. Die eine Seite führt den niedrigeren Energieverbrauch und Lautstärke ins Feld. Die andere Seite argumentiert hingegen, dass die Belastung für die Platte beim Anlaufen am größten ist und häufiges stoppen und wieder anlaufen die Lebensdauer deutlich reduzieren kann.
Der Energieverbrauch war für mich eher nebensächlich. Laut Datenblatt verbraucht die Platte im Betrieb 4,x W. Bei drei Platten im System ist das also noch sehr überschaubar. Die Argumentation der Belastung beim Anlaufen kann ich durchaus nachvollziehen, daher galt es einen Weg zu finden, die Platten möglichst selten, jedoch zuverlässig in den standby zu schicken.

Der erste Treffer war die Verwendung von hdparm um den Platten ein Timeout mitzugeben. Wurden in der eingestellten Zeit keine Zugriffe auf die Platte durchgeführt, so soll diese in den standby gehen. Die man-Page sagt zu dem Parameter -S folgendes:

-S
Put the drive into idle (low-power) mode, and also set the standby (spindown) timeout for the drive.  This timeout value is used by the drive to determine how long to wait (with no disk activity) before turning off the spindle motor to save power. Under such circumstances, the drive may take as long as 30 seconds to respond to a subsequent disk access, though most drives are much quicker. The encoding of the timeout value is somewhat peculiar. A value of zero means "timeouts are disabled": the device will not automatically enter standby mode. Values from 1 to 240 specify multiples of 5 seconds, yielding timeouts from 5 seconds to 20 minutes. Values
from 241 to 251 specify from 1 to 11 units of 30 minutes, yielding timeouts from 30 minutes to 5.5 hours. A value of 252 signifies a timeout of 21 minutes. A value of 253 sets a vendor-defined timeout period between 8 and 12 hours, and the value 254 is reserved. 255 is interpreted as 21 minutes plus 15 seconds.  Note that some older drives may have very different interpretations of these values.

Um die Platten nicht zu oft in den standby zu schicken, habe ich mich also für ein Timeout von 30 Minuten entschieden

# hdparm -S 241 /dev/sdb

Den aktuellen Status habe ich dann alle 15 Minuten durch ein Skript protokollieren lassen das über einen Cronjob aufgerufen wurde.

#!/bin/bash

OUTFILE=/root/diskstate.out
/bin/date >> $OUTFILE
/sbin/hdparm -C /dev/sdb | awk '{getline; disk=$1; getline; print disk,$NF}' >> $OUTFILE
/sbin/hdparm -C /dev/sdc | awk '{getline; disk=$1; getline; print disk,$NF}' >> $OUTFILE
/sbin/hdparm -C /dev/sdd | awk '{getline; disk=$1; getline; print disk,$NF}' >> $OUTFILE
echo "===================" >> $OUTFILE

Nichts aufregendes, aber für die angedachte Aufgabe ausreichend. Leider musste ich dann feststellen, dass die Platten die gesamte Nacht über nicht in den standby gehen. Die Vermutung lag also nahe, dass immer wieder auf die Platten zugegriffen und dadurch das Timeout nicht erreicht wird. Also habe ich angefangen den Plattendurchsatz zu protokollieren. ZFS bringt da glücklicherweise ein eigenes iostat mit.
Die Statistiken sahen jedoch für die gesamte Nacht wie folgt aus:

# zpool iostat -yP datapool 5 1  
             capacity     operations     bandwidth  
pool        alloc   free   read  write   read  write
----------  -----  -----  -----  -----  -----  -----
datapool    4.74T   712G      0      0      0      0

Es wurde also keinerlei IO-Aktivität festgestellt. Weshalb die Platten nicht in den standby gehen, weiß ich bis jetzt nicht. Ein manuelles aktivieren des standby mittels „hdparm -y /dev/sdb“ funktioniert einwandfrei und die Platten bleiben dann auch die meiste Zeit der Nach in diesem Status. Es scheinen also tatsächlich keine Aktivitäten auf den Platten statt zu finden.

Nach weiterer Suche im Netz habe ich dann den hdparm-Ansatz aufgegeben und mich an eine manuelle Lösung gewagt. Entstanden ist dabei eine Python-Anwendung, die in konfigurierbaren Abständen die IO-Statistiken der angegeben Pools überprüft. Ist ein definierter Zeitraum ohne Aktivität festgestellt worden, so wird die Platte in den standby geschickt (den Quellcode habe ich am Ende angefügt).

Um die Platten nicht tagsüber in den standby zu schicken, habe ich die Anwendung bei mir so konfiguriert, dass nur zwischen 22:00 und 08:00 überwacht wird. Ist keine Aktivität auf dem System, gehen die Platten so um 22:15 in den standby. Gegen 09:00 laufen dann die ersten Jobs, die die Platten potentiell aufwecken und meine Schlafenszeit ist auch vorbei, so dass dort kein Monitoring mehr notwendig ist.

Nun musste ich im Log der Anwendung feststellen, dass die Platten jeden Morgen um 6:12 aufgeweckt und 15 min später wieder in den standby geschickt wurden. Die Daten haben gezeigt, dass um 6:12 IO-Aktivität statt findet, jedoch noch nicht wodurch diese ausgelöst wird. Also ist das nächste Skript entstanden, dass die IO-Aktivität des Systems protokolliert. Den fraglichen Zeitraum wusste ich nun ja schon ziemlich genau.

#!/bin/bash
OUTFILE="/root/diskActivityLog/diskActivityLog.out"
INTERVAL=10
COUNT=24

# reset output file
echo "" > $OUTFILE

# clear message log
/bin/dmesg -c > /dev/null

# activate io logging
echo 1 > /proc/sys/vm/block_dump

i=0
while [[ $i -lt $COUNT ]]
do
    # wait for INTERVAL
    sleep $INTERVAL

    # save message log
    /bin/dmesg -cT >> /root/diskActivityLog/diskActivityLog.out

    # increase counter
    (( i=$i +1 ))
done

# disable io logging
echo 0 > /proc/sys/vm/block_dump

Wieder über einen cronjob gestartet, konnte ich feststellen, dass updatedb.mlocate auf die Platten zugegriffen hat. Nach ein paar Versuchen dies zu vermeiden, habe ich dann schlussendlich in einem Container einen entsprechenden cronjob in /etc/cron.daily gefunden. Laut /etc/crontab wurden die Aufgaben in diesem Ordner genau zum fraglichen Zeitpunkt gestartet:

# cat /etc/crontab
# /etc/crontab: system-wide crontab
# Unlike any other crontab you don't have to run the `crontab'
# command to install the new version when you edit this file
# and files in /etc/cron.d. These files also have username fields,
# that none of the other crontabs do.

SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

# m h dom mon dow user  command
53 *    * * *   root    cd / && run-parts --report /etc/cron.hourly
12 6    * * *   root    test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.daily )
44 6    * * 7   root    test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.weekly )
28 6    1 * *   root    test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.monthly )

Diese Einträge habe ich dann alle auf 9:xx verschoben. Am nächsten Tag habe ich dann im Log meiner Anwendung feststellen können, dass die Platten tatsächlich nicht mehr um 06:12 aufgeweckt wurden. Ein erster Teilerfolg.

In den darauffolgenden Tagen wurden die Platten teilweise nicht mehr aufgeweckt, teilweise aber zwei mal am Morgen. Leider konnte ich zunächst kein Muster feststellen um die Ursache einzugrenzen. Daraufhin habe ich die obigen beiden Anwendungen in eine zusammengefasst, so dass automatisch die I/O-Aktivität der letzten Minute geloggt wird, wenn eine Platte aus dem Standby aktiviert wird.

#!/usr/bin/python3
## Application to set disks in ZFS pool to sleep after configurable time of inactivity
## For activity monitoring "zpool iostat" is used
import sys
import subprocess
import io
import time 

########################
### VARIABLES
########################
VERSION = 1.3

# Define the ZFS pools to monitor. 
## Multiple Pools can be specified by extending the array
ZFS_POOLS=["datapool"]

# Select timeframe for monitoring (e.g. only between 10pm and 8am to put disks in standby over night)
## Only the hour is specified
CHECK_AFTER=22
CHECK_BEFORE=8

# Set the required time of inactivity for the disks to be set to standby
## The time frame is specified via INTERVAL (in seconds) * COUNT
INTERVAL=60
COUNT=15

# Set LOGGING to 1 to enable some logging. Setting parameter to 0 while run the application silently
LOGGING = 1
LOG_DISKS_BEFORE=8
LOG_DISKS_AFTER=6

########################
### FUNCTIONS
########################
def log(message):
    if LOGGING == 1:
        f = open("/root/diskStandby/disk_standby.out", "a")
        f.write("{}: {}".format(time.strftime("%Y-%m-%d %H:%M"), message))
        f.close()

########################
### MAIN
########################
# Create data structure for zfs pools, disks and counter
# empty dictionary to contain pools
ZPOOLS={}
for pool in ZFS_POOLS:
    # add an empty dictionary for every pool to be filled with disk and active interval count
    ZPOOLS[pool] = {}

# get disks for selected pools
for pool in ZPOOLS:
    # create empty array
    disks = []
    # get the information of the pool via zpool command
    zpool_out = subprocess.run(["/sbin/zpool", "status", "-LP", pool], stdout=subprocess.PIPE, encoding="UTF-8")
    # split output into lines
    lines = zpool_out.stdout.split("\n")
    for line in lines:
        # break up line on blanks. strip before to remove leading blanks
        parts = line.strip().split(" ")
        # if the first column contains "/dev/" it should be a disk descriptor
        if "/dev/" in parts[0]:
            # add the disk name to the array omitting the last character indicating the partition
            disks.append(parts[0][:-1])
    # add created array to dictionary with the pool name as key
    ZPOOLS[pool]["disks"] = disks
    # add counter for active intervals set to 0
    ZPOOLS[pool]["counter"] = 0

# disk logging disabled (0) or enabled (1)
disklogging = 0

# variable used to log io activity only when disks are woken up
allDisksSleeping = 0

# Start endless loop to monitor disks
log("Starting Monitoring for pools {}\n".format(ZPOOLS.keys()))
while True:
    # get the current time
    now=time.localtime()
    # check if disk logging needs to be enabled
    if now.tm_hour < LOG_DISKS_BEFORE and now.tm_hour > LOG_DISKS_AFTER:
        # check if disk logging is enabled
        if disklogging == 0:
            # not enabled but inside monitoring timeframe, so enable it
            f = open("/proc/sys/vm/block_dump", "w")
            f.write("1")
            f.close()
            disklogging = 1
    else:
        # check if disk logging is enabled
        if disklogging == 1:
            # it is enabled but outside monitoring timeframe, so disable it
            f = open("/proc/sys/vm/block_dump", "w")
            f.write("0")
            f.close()
            disklogging = 0
            
    # check if inside specified monitoring timeframe or not
    if now.tm_hour < CHECK_BEFORE or now.tm_hour >= CHECK_AFTER:
        # inside monitoring timeframe
        log("Inside monitoring timeframe\n")
        # run for every pool
        for pool in ZPOOLS:
            # check status of disks
            disks_active = 0
            for disk in ZPOOLS[pool]["disks"]:
                # run smartctl and check return code
                smartctl_out = subprocess.run(["/usr/sbin/smartctl", "-n", "standby,3", disk], stdout=subprocess.PIPE)
                # A return code of 0 indicates the disk being active. 
                # A return code of 3 indicates the disk being in standby
                if smartctl_out.returncode != 3:
                    disks_active = 1
                    log("Disk {} of pool {} - active\n".format(disk, pool))
                else:
                    log("Disk {} of pool {} - standby\n".format(disk, pool))
            if disks_active:
                log("Pool {} contains acitve disks\n".format(pool))

                if disklogging == 1:
                    if allDisksSleeping == 1:
                        # the last intervall all disks were sleeping. So activity has to be happened in the last intervall
                        # write log data to disk in file /root/dmesg.out/YEAR-MONTH-DAY_HOUR:MINUTE.out
                        f = open("/root/dmesg.out/{}-{}-{}_{}:{}.out".format(now.tm_year, now.tm_month, now.tm_day, now.tm_hour, now.tm_min), "w")
                        subprocess.run(["/bin/dmesg", "-cT"], stdout=f, encoding="UTF-8")
                        f.close()
                        allDisksSleeping = 0
                    else:
                        # the last intervall disks were already active. So no interest in the data in the log. Just clear it
                        subprocess.run(["/bin/dmesg", "-c"])

                # The pool contains active disks
                # Run zpool iostat to monitor activity
                zpool_out = subprocess.run(["/sbin/zpool", "iostat", "-yHp", pool, str(INTERVAL), "1"], stdout=subprocess.PIPE, encoding="UTF-8")
                parts = zpool_out.stdout.strip().split("\t")

                # Check columns 3 and 4 for values greater 0
                if int(parts[3]) > 0 or int(parts[4]) > 0:
                    # The pool was active so reset the counter to 0
                    log("Pool {} had activity - Resetting counter\n".format(pool))
                    ZPOOLS[pool]["counter"] = 0
                else:
                    log("Pool {} had no I/O Activity - Increasing counter\n".format(pool))
                    # No activity has been spotted so increase the counter by 1
                    ZPOOLS[pool]["counter"] += 1
                    # Check value of counter. If it has reached COUNT the disks in the pool can be set to standby
                    if ZPOOLS[pool]["counter"] >= COUNT:
                        # Set disks in pool to standby
                        for disk in ZPOOLS[pool]["disks"]:
                            log("Sending disk {} of pool {} to standby due to inactivity\n".format(disk, pool))
                            subprocess.run(["/sbin/hdparm", "-y", disk])
                        # Reset counter to 0 to make sure the disks don't get suspended again directly after waking up
                        ZPOOLS[pool]["counter"] = 0
            else:
                log("Disks in pool {} are in standby\n".format(pool))
                allDisksSleeping = 1
                # no active disks. So no interest in the data in the log. Just clear it
                subprocess.run(["/bin/dmesg", "-c"])
                time.sleep(INTERVAL)

                
    else:
        # outside monitoring timeframe
        log("Outside monitoring timeframe\n")
        # check if disk logging is enabled
        if disklogging == 1:
            # it is enabled but outside monitoring timeframe, so disable it
            f = open("/proc/sys/vm/block_dump", "w")
            f.write("0")
            f.close()
            disklogging = 0
        # Since only hours can be specified as timeframe for monitoring, sleep till next complete hour
        time.sleep((60-now.tm_min)*60) 

Mit dieser Anwendung konnte ich dann weitere cronjobs auf verschiedenen Containern finden, die ich alle in den Tag verschoben habe. Damit habe ich mein Ziel erreicht und die Platten bleiben die Nacht über ruhig, ohne tagsüber eingeschränkt zu werden.

Mission accomplished!

  1. Flo Flo

    Hallo,
    seit einer der neueren Versionen wurde „/proc/sys/vm/block_dump“ entfernt.
    Vor dem Hintergrund stürzt das Script ab. Hast du schon Alternative für den block_dump gefunden?
    Gruß
    Flo

    • Hi, zu meiner Schande muss ich gestehen, dass ich den spin down nicht mehr nutze. Da der Platz knapp wurde und ich nicht zu viel in neue Platten investiere wollte, habe ich meine Daten auf ein snapraid verschoben und dort kümmert sich snapraid um den spinn down. ZFS wird nur noch für PVE verwendet und der Pool besteht lediglich aus einer SSD.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert