#!/bin/bash # do a parallel backup and restore of specified files # # Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved. # # This file is part of Lustre, http://www.lustre.org. # # Lustre is free software; you can redistribute it and/or # modify it under the terms of version 2 of the GNU General Public # License as published by the Free Software Foundation. # # Lustre is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with Lustre; if not, write to the Free Software # Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. # # Author: Andreas Dilger # VERSION=1.1.0 export LC_ALL=C RSH=${RSH:-"ssh"} # use "bash -c" for local testing SPLITMB=${SPLITMB:-8192} # target chunk size (uncompressed) SPLITCOUNT=${SPLITCOUNT:-200} # number of files to give each client TAR=${TAR:-"tar"} # also htar in theory works (untested) #PROGPATH=$(which $0 2> /dev/null) || PROGPATH=$PWD/$0 case $0 in .*) PROGPATH=$PWD/$0 ;; *) PROGPATH=$0 ;; esac PROGNAME="$(basename $PROGPATH)" LOGPREFIX="$PROGNAME" log() { echo "$LOGPREFIX: $*" 1>&2 } fatal() { log "ERROR: $*" exit 1 } usage() { log "$*" echo "usage: $PROGNAME [-chjvxz] [-C directory] [-e rsh] [-i inputlist]" echo -e "\t\t[-l logdir] [-n nodes] [-s splitmb] [-T tar] -f ${FTYPE}base" echo -e "\t-c create archive" echo -e "\t-C directory: relative directory for filenames (default PWD)" echo -e "\t-e rsh: specify the passwordless remote shell (default $RSH)" if [ "$OP" = "backup" ]; then echo -e "\t-f outputfile: specify base output filename for backup" else echo -e "\t-f ${OP}filelist: specify list of files to $OP" fi echo -e "\t-h: print help message and exit (use -x -h for restore help)" if [ "$OP" = "backup" ]; then echo -e "\t-i inputfile: list of files to backup (default stdin)" fi echo -e "\t-j: use bzip2 compression on $FTYPE file(s)" echo -e "\t-l logdir: directory for output logs" echo -e "\t-n nodes: comma-separated list of client nodes to run ${OP}s" if [ "$OP" = "backup" ]; then echo -e "\t-s splitmb: target size for backup chunks " \ "(default ${SPLITMB}MiB)" echo -e "\t-S splitcount: number of files sent to each client "\ "(default ${SPLITCOUNT})" fi echo -e "\t-t: list table of contents of tarfile" echo -e "\t-T tar: specify the backup command (default $TAR)" echo -e "\t-v: be verbose - list all files being processed" echo -e "\t-V: print version number and exit" echo -e "\t-x: extract files instead of backing them up" echo -e "\t-z: use gzip compression on $FTYPE file(s)" exit 1 } usage_inactive() { usage "inactive argument '$1 $2' in '$3' mode" } set_op_type() { case $1 in *backup*) OP=backup; FTYPE=output; TAROP="-c" ;; *list*) OP=list; FTYPE=input; TAROP="-t"; SPLITCOUNT=1 ;; *restore*) OP=restore; FTYPE=input; TAROP="-x"; SPLITCOUNT=1 ;; *) FTYPE="output"; usage "unknown archive operation '$1'";; esac } #echo ARGV: "$@" # --fileonly, --remote are internal-use-only options TEMPARGS=$(getopt -n $LOGPREFIX -o cC:e:f:hi:jl:n:ps:S:tT:vVxz --long create,extract,list,restore,directory:,rsh:,outputbase:,help,inputfile:,bzip2,logdir:,nodes:,permissions:splitmb,splitcount,tar:,verbose,version,gzip,fileonly,remote: -- "$@") eval set -- "$TEMPARGS" set_op_type $PROGNAME # parse input arguments, and accumulate the client-specific args while true; do case "$1" in -c|--create) [ "$OP" != "backup" ] && usage "can't use $1 $TAROP at the same time" OP="backup"; ARGS="$ARGS $1"; shift ;; -C|--directory) GOTODIR="$2"; cd "$2" || usage "error cd to -C $2"; ARGS="$ARGS $1 \"$2\""; shift 2 ;; -e|--rsh) RSH="$2"; shift 2;; -f|--outputbase)OUTPUTBASE="$2";ARGS="$ARGS $1 \"$2\""; shift 2 ;; -h|--help) ARGS=""; break;; -i|--inputfile) INPUT="$2"; shift 2;; -j|--bzip2) TARCOMP="-j"; ARGS="$ARGS $1"; shift ;; -l|--logdir) LOGDIR="$2"; ARGS="$ARGS $1 \"$2\""; shift 2 ;; -n|--nodes) NODELIST="$NODELIST,$2"; ARGS="$ARGS $1 \"$2\""; shift 2 ;; -p|--permissions) PERM="-p"; ARGS="$ARGS $1"; shift ;; -s|--splitmb) [ "$OP" != "backup" ] && usage_inactive $1 $2 $OP SPLITMB=$2; ARGS="$ARGS $1 \"$2\""; shift 2 ;; -S|--splitcount)[ "$OP" != "backup" ] && usage_inactive $1 $2 $OP SPLITCOUNT=$2; ARGS="$ARGS $1 \"$2\""; shift 2 ;; -t|--list) [ "$OP" != "backup" -a "$OP" != "list" ] && usage "can't use $1 $TAROP at the same time" OP="list"; ARGS="$ARGS $1"; shift ;; -T|--tar) TAR="$2"; ARGS="$ARGS $1 \"$2\""; shift 2 ;; -v|--vebose) [ "$VERBOSE" = "-v" ] && set -vx # be extra verbose VERBOSE="-v"; ARGS="$ARGS $1"; shift ;; -V|--version) echo "$LOGPREFIX: version $VERSION"; exit 0;; -x|--extract|--restore) [ "$OP" != "backup" -a "$OP" != "restore" ] && usage "can't use $1 $TAROP at the same time" OP="restore"; ARGS="$ARGS $1"; shift ;; -z|--gzip) TARCOMP="-z"; ARGS="$ARGS $1"; shift ;; # these commands are for internal use only --remote) NODENUM="$2"; LOGPREFIX="$(hostname).$2"; shift 2;; --fileonly) FILEONLY="yes"; shift;; --) shift; break;; *) usage "unknown argument '$1'" 1>&2 ;; esac done set_op_type $OP #log "ARGS: $ARGS" [ -z "$ARGS" ] && usage "$OP a list of files, running on multiple nodes" # we should be able to use any backup tool that can accept filenames # from an input file instead of just pathnames on the command-line. # Unset TARCOMP for htar, as it doesn't support on-the-fly compression. TAREXT= case "$(basename $TAR)" in htar*) TARARG="-L"; TAROUT="-f"; TARCOMP=""; MINKB=0 ;; tar*|gnutar*|gtar*) TARARG="-T"; TAROUT="-b 2048 -f"; TAREXT=.tar ;; *) fatal "unknown archiver '$TAR'" ;; esac if [ "$OP" = "backup" ]; then [ -z "$OUTPUTBASE" ] && usage "'-f ${FTYPE}base' must be given for $OP" # Make sure we leave some margin free in the output filesystem for the # chunks. If we are dumping to a network filesystem (denoted by having # a ':' in the name, not sure how else to check) then we assume this # filesystem is shared among all clients and expect the other nodes # to also consume space there. OUTPUTFS=$(dirname $OUTPUTBASE) NETFS=$(df -P $OUTPUTFS | awk '/^[[:alnum:]]*:/ { print $1 }') MINKB=${MINKB:-$((SPLITMB * 2 * 1024))} [ "$NETFS" ] && MINKB=$(($(echo $NODELIST | tr ',' ' ' | wc -w) * $MINKB)) # Compress the output files as we go. case "$TARCOMP" in -z) TAREXT="$TAREXT.gz";; -j) TAREXT="$TAREXT.bz2";; esac else [ -z "$OUTPUTBASE" ] && usage "-f ${OP}filelist must be specified for $OP" # we want to be able to use this for a list of files to restore # but it is convenient to use $INPUT for reading the pathnames # of the tar files during restore/list operations to handle stdin [ "$INPUT" ] && usage "-i inputbase unsupported for $OP" INPUT=$OUTPUTBASE TARARG="" fi [ -z "$NODELIST" ] && NODELIST="localhost" # If we are writing to a char or block device (e.g. tape) don't add any suffix # We can't currently specify a different target device per client... if [ -b "$OUTPUTBASE" -o -c "$OUTPUTBASE" ]; then MINKB=0 [ -z "$LOGDIR" ] && LOGDIR="/var/log" LOGBASE="$LOGDIR/$PROGNAME" elif [ -d "$OUTPUTBASE" ]; then usage "-f $OUTPUTBASE must be a pathname, not a directory" else [ -z "$LOGDIR" ] && LOGBASE="$OUTPUTBASE" || LOGBASE="$LOGDIR/$PROGNAME" fi LOGBASE="$LOGBASE.$(date +%Y%m%d%H%M)" # tar up a sinle list of files into a chunk. We don't exit if there is an # error returned, since that might happen frequently with e.g. files moving # and no longer being available for backup. # usage: run_one_tar {file_list} {chunk_nr} {chunkbytes} DONE_MSG="FINISH_THIS_PROGRAM_NOW_I_TELL_YOU" KILL_MSG="EXIT_THIS_PROGRAM_NOW_I_TELL_YOU" run_one_backup() { #set -vx TMPLIST="$1" CHUNK="$2" CHUNKMB="$(($3 / 1048576))" if [ -b "$OUTPUTBASE" -o -c "$OUTPUTBASE" ]; then OUTFILE="$OUTPUTBASE" else OUTFILE="$OUTPUTBASE.$NODENUM.$CHUNK$TAREXT" fi CHUNKBASE="$LOGBASE.$NODENUM.$CHUNK" LISTFILE="$CHUNKBASE.list" LOG="$CHUNKBASE.log" cp "$TMPLIST" "$LISTFILE" SLEPT=0 FREEKB=$(df -P $OUTPUTFS 2> /dev/null | tail -n 1 | awk '{print $4}') while [ $FREEKB -lt $MINKB ]; do sleep 5 SLEPT=$((SLEPT + 5)) if [ $((SLEPT % 60)) -eq 10 ]; then log "waiting ${SLEPT}s for ${MINKB}kB free in $OUTPUTFS" fi FREEKB=$(df -P $OUTPUTFS | tail -n 1 | awk '{print $4}') done [ $SLEPT -gt 0 ] && log "waited ${SLEPT}s for space in ${OUTPUTFS}" log "$LISTFILE started - est. ${CHUNKMB}MB" START=$(date +%s) eval $TAR $TAROP $PERM $TARARG "$TMPLIST" -v $TARCOMP $TAROUT "$OUTFILE" \ 2>&1 >>"$LOG" | tee -a $LOG | grep -v "Removing leading" RC=${PIPESTATUS[0]} ELAPSE=$(($(date +%s) - START)) if [ $RC -eq 0 ]; then if [ -f "$OUTFILE" ]; then BYTES=$(stat -c '%s' "$OUTFILE") CHUNKMB=$((BYTES / 1048576)) log "$LISTFILE finished - act. ${CHUNKMB}MB/${ELAPSE}s" else log "$LISTFILE finished OK - ${ELAPSE}s" fi echo "OK" > $CHUNKBASE.done else echo "ERROR=$RC" > $CHUNKBASE.done log "ERROR: $LISTFILE exited with rc=$RC" fi rm $TMPLIST return $RC } run_one_restore_or_list() { #set -vx INPUTFILE="$1" LOG="$LOGBASE.$(basename $INPUTFILE).restore.log" SLEPT=0 while [ $MINKB != 0 -a ! -r "$INPUTFILE" ]; do SLEPT=$((SLEPT + 5)) if [ $((SLEPT % 60)) -eq 10 ]; then log "waiting ${SLEPT}s for $INPUTFILE staging" fi sleep 5 done [ $SLEPT -gt 0 ] && log "waited ${SLEPT}s for $INPUTFILE staging" log "$OP of $INPUTFILE started" START=$(date +%s) eval $TAR $TAROP -v $TARCOMP $TAROUT "$INPUTFILE" 2>&1 >>"$LOG" | tee -a "$LOG" | grep -v "Removing leading" RC=${PIPESTATUS[0]} ELAPSE=$(($(date +%s) - START)) [ "$OP" = "list" ] && cat $LOG if [ $RC -eq 0 ]; then log "$INPUTFILE finished OK - ${ELAPSE}s" echo "OK" > $INPUTFILE.restore.done else echo "ERROR=$RC" > $INPUTFILE.restore.done log "ERROR: $OP of $INPUTFILE exited with rc=$RC" fi return $RC } # Run as a remote command and read input filenames from stdin and create tar # output files of the requested size. The input filenames can either be: # "bytes filename" or "filename" depending on whether FILEONLY is set. # # Read filenames until we have either a large enough list of small files, # or we get a very large single file that is backed up by itself. run_remotely() { #set -vx log "started thread" RCMAX=0 [ "$FILEONLY" ] && PARAMS="FILENAME" || PARAMS="BYTES FILENAME" if [ "$OP" = "backup" ]; then TMPBASE=$PROGNAME.$LOGPREFIX.temp TMPFILE="$(mktemp -t $TMPBASE.$(date +%s).XXXXXXX)" OUTPUTFILENUM=0 SUMBYTES=0 fi BYTES="" while read $PARAMS; do [ "$FILENAME" = "$DONE_MSG" -o "$BYTES" = "$DONE_MSG" ] && break if [ "$FILENAME" = "$KILL_MSG" -o "$BYTES" = "$KILL_MSG" ]; then log "exiting $OP on request" [ "$TARPID" ] && kill -9 $TARPID 2> /dev/null exit 9 fi case "$OP" in list|restore) run_one_restore_or_list $FILENAME; RC=$? ;; backup) STAT=($(stat -c '%s %F' "$FILENAME")) [ "$FILEONLY" ] && BYTES=${STAT[0]} # if this is a directory that has files in it, it will # be backed up as part of this (or some other) backup. # Only include it in the backup if empty, otherwise # the files therein will be backed up multiple times if [ "${STAT[1]}" = "directory" ]; then NUM=`find "$FILENAME" -maxdepth 1|head -2|wc -l` [ "$NUM" -gt 1 ] && continue fi [ "$VERBOSE" ] && log "$FILENAME" # if a file is > 3/4 of chunk size, archive by itself # avoid shell math: 1024 * 1024 / (3/4) = 1398101 if [ $((BYTES / 1398101)) -gt $SPLITMB ]; then # create a very temp list for just this file TARLIST=$(mktemp -t $TMPBASE.$(date +%s).XXXXXX) echo "$FILENAME" > "$TARLIST" TARBYTES=$BYTES else SUMBYTES=$((SUMBYTES + BYTES)) echo "$FILENAME" >> $TMPFILE # not large enough input list, keep collecting [ $((SUMBYTES >> 20)) -lt $SPLITMB ] && continue TARBYTES=$SUMBYTES SUMBYTES=0 TARLIST="$TMPFILE" TMPFILE=$(mktemp -t $TMPBASE.$(date +%s).XXXXXXX) fi wait $TARPID RC=$? run_one_backup "$TARLIST" "$OUTPUTFILENUM" $TARBYTES & TARPID=$! OUTPUTFILENUM=$((OUTPUTFILENUM + 1)) ;; esac [ $RC -gt $RCMAX ] && RCMAX=$RC done if [ "$TARPID" ]; then wait $TARPID RC=$? [ $RC -gt $RCMAX ] && RCMAX=$RC fi if [ -s "$TMPFILE" ]; then run_one_backup "$TMPFILE" "$OUTPUTFILENUM" $SUMBYTES RC=$? [ $RC -gt $RCMAX ] && RCMAX=$RC fi exit $RCMAX } # If we are a client then just run that subroutine and exit [ "$NODENUM" ] && run_remotely && exit 0 # Tell the clients to exit. Their input pipes might be busy so it may # take a while for them to consume the files and finish. CLEANING=no cleanup() { log "cleaning up remote processes" for FD in $(seq $BASEFD $((BASEFD + NUMCLI - 1))); do echo "$DONE_MSG" >&$FD done CLEANING=yes SLEPT=0 RUN=$(ps auxww | egrep -v "grep|bash" | grep -c "$PROGNAME.*remote") while [ $RUN -gt 0 ]; do set +vx #ps auxww | grep "$PROGNAME.*remote" | egrep -v "grep|bash" sleep 1 SLEPT=$((SLEPT + 1)) [ $((SLEPT % 30)) -eq 0 ] && log "wait for $RUN processes to finish" [ $((SLEPT % 300)) -eq 0 ] && ps auxww |grep "$PROGNAME.*remote" |egrep -v "grep|bash" RUN=$(ps auxww|egrep -v "grep|bash"|grep -c "$PROGNAME.*remote") done trap 0 } do_cleanup() { if [ "$CLEANING" = "yes" ]; then log "killing all remote processes - may not stop immediately" for FD in $(seq $BASEFD $((BASEFD + NUMCLI - 1))); do echo "$KILL_MSG" >&$FD done sleep 1 PROCS=$(ps auxww|awk '/$PROGNAME.*remote/ { print $2 }') [ "$PROCS" ] && kill -9 $PROCS trap 0 fi cleanup } # values that only need to be determined on the master # always read from stdin, even if it is a file, to be more consistent case "$INPUT" in -|"") INPUT="standard input";; *) if [ ! -r "$INPUT" ]; then [ "$VERBOSE" ] && ls -l "$INPUT" usage "can't read input file '$INPUT'" fi exec <$INPUT ;; esac # if unspecified, run remote clients in the current PWD to get correct paths [ -z "$GOTODIR" ] && ARGS="$ARGS -C \"$PWD\"" # main() BASEFD=100 NUMCLI=0 # Check if the input list has the file size specified or not. Input # lines should be of the form "{bytes} {filename}" or "{filename}". # If no size is given then the file sizes are determined by the clients # to do the chunking (useful for making a full backup, but not as good # at evenly distributing the data among clients). In rare cases the first # file specified may have a blank line and no size - check that as well. if [ "$OP" = "backup" ]; then read BYTES FILENAME if [ -z "$FILENAME" -a -e "$BYTES" ]; then FILENAME="$BYTES" BYTES="" FILEONLY="yes" && ARGS="$ARGS --fileonly" elif [ -e "$BYTES $FILENAME" ]; then FILENAME="$BYTES $FILENAME" BYTES="" FILEONLY="yes" && ARGS="$ARGS --fileonly" elif [ ! -e "$FILENAME" ]; then log "input was '$BYTES $FILENAME'" fatal "first line of '$INPUT' is not a file" fi else FILEONLY="yes" && ARGS="$ARGS --fileonly" fi # kill the $RSH processes if we get a signal trap do_cleanup INT EXIT # start up the remote processes, each one with its stdin attached to a # different output file descriptor, so that we can communicate with them # individually when sending files to back up. We generate a remote log # file and also return output to this process. for CLIENT in $(echo $NODELIST | tr ',' ' '); do FD=$((BASEFD+NUMCLI)) LOG=$OUTPUTBASE.$CLIENT.$FD.log eval "exec $FD> >($RSH $CLIENT '$PROGPATH --remote=$NUMCLI $ARGS')" RC=$? if [ $RC -eq 0 ]; then log "starting $0.$NUMCLI on $CLIENT" NUMCLI=$((NUMCLI + 1)) else log "ERROR: failed '$RSH $CLIENT $PROGPATH': RC=$?" fi done if [ $NUMCLI -eq 0 ]; then fatal "unable to start any threads" fi CURRCLI=0 # We don't want to use "BYTES FILENAME" if the input doesn't include the # size, as this might cause problems with files with whitespace in them. # Instead we just have two different loops depending on whether the size # is in the input file or not. We dish out the files either by size # (to fill a chunk), or just round-robin and hope for the best. if [ "$FILEONLY" ]; then if [ "$FILENAME" ]; then [ "$VERBOSE" ] && log "$FILENAME" echo "$FILENAME" 1>&$BASEFD # rewrite initial line fi # if we don't know the size, just round-robin among the clients while read FILENAME; do FD=$((BASEFD+CURRCLI)) [ -n "$VERBOSE" -a "$OP" != "backup" ] && log "$OP $FILENAME" echo "$FILENAME" 1>&$FD COUNT=$((COUNT + 1)) if [ $COUNT -ge $SPLITCOUNT ]; then CURRCLI=$(((CURRCLI + 1) % NUMCLI)) COUNT=0 fi done else [ "$VERBOSE" ] && log "$FILENAME" echo $BYTES "$FILENAME" 1>&$BASEFD # rewrite initial line # if we know the size, then give each client enough to start a chunk while read BYTES FILENAME; do FD=$((BASEFD+CURRCLI)) [ "$VERBOSE" ] && log "$FILENAME" echo $BYTES "$FILENAME" >&$FD # take tar blocking factor into account [ $BYTES -lt 10240 ] && BYTES=10240 SUMBYTES=$((SUMBYTES + BYTES)) if [ $((SUMBYTES / 1048576)) -ge $SPLITMB ]; then CURRCLI=$(((CURRCLI + 1) % NUMCLI)) SUMBYTES=0 fi done fi # Once all of the files have been given out, wait for the remote processes # to complete. That might take a while depending on the size of the backup. cleanup