This post shares some ideas about working with cronjobs, to help make common tasks more easy for both junior and senior sysadmins.
What’s in your environment?
Almost every question I see about cron includes something like “it works fine when I run it from the command line, but…"
This script will tell you exactly what your program will see when run via cron:
#!/bin/ksh
#<showcron: show me what cron uses
tag=${0##*/}
exec > /tmp/$tag.$$
echo "Generated by $tag on $(date)
Environment:"
/bin/env | /bin/sort
echo
echo '----------------------------------------------'
echo Shell settings:
set
exit 0
I put it under $HOME/cron/showcron
, and I (briefly) uncomment this
line to run it:
* * * * * $HOME/cron/showcron
Results in /tmp/showcron.7294
:
Generated by showcron on Mon May 11 03:09:01 EDT 2020
Environment:
CRON=yes
HOME=/home/me
LANG=en_US.UTF-8
LOGNAME=me
PATH=/usr/bin:/bin:/usr/sbin:/usr/local/sbin
PWD=/home/me
SHELL=/bin/sh
SHLVL=2
USER=me
----------------------------------------------
Shell settings:
_=settings:
CRON=yes
ENV=/home/me/.kshrc
FCEDIT=/bin/ed
HISTCMD=0
HOME=/home/me
IFS=$' \t\n'
JOBMAX=0
KSH_VERSION=.sh.version
LANG=en_US.UTF-8
LINENO=15
LOGNAME=me
MAILCHECK=600
OPTIND=1
PATH=/usr/bin:/bin:/usr/sbin:/usr/local/sbin
PPID=7290
PS2='> '
PS3='#? '
PS4='+ '
PWD=/home/me
RANDOM=21060
SECONDS=0.005
SHELL=/bin/sh
SHLVL=2
tag=showcron
TMOUT=0
USER=me
If you’re using Vixie cron or something close to it, you can specify environment variables at the top of your crontab file:
CRON=yes
LOGNAME=me
PATH=/usr/bin:/bin:/usr/sbin:/usr/local/sbin
SHELL=/bin/sh
USER=me
I like to set a variable called CRON so scripts can do things like sending output to syslog instead of using echo, etc:
case "$CRON" in
yes) logmsg () { logger -t $tag "$@"; } ;;
*) logmsg () { echo "$(date '+%F %T') $tag: $@"; } ;;
esac
warn () { logmsg "WARN: $@" ; }
die () { logmsg "FATAL: $@"; exit 1 ; }
...
Put a header in your crontab file
It’s much easier to fix or create an entry when you don’t have to remember syntax. I put this at the top of all my crontab files:
# Everything on a line is separated by blanks or tabs.
# Ie:
#+--------------------------- Minute (0-59)
#| +---------------------- Hour (0-23)
#| | +---------------- Day (1-31)
#| | | +------------ Month (1-12)
#| | | | +-------- Day of week (0-6, 0=Sunday)
#| | | | | +--- Command to be run
#| | | | | |
#v v v v v v
#====================================================================
# Uncomment for testing
#* * * * * $HOME/cron/showcron
Use run-parts to run scripts in sequence
Most versions of Linux come with a program called run-parts, which will run all the executable scripts or programs in a directory in lexical-sort order.
I have all sorts of things set up this way; it makes the main crontab file way simpler:
#+--------------------------- Minute (0-59)
#| +---------------------- Hour (0-23)
#| | +---------------- Day (1-31)
#| | | +------------ Month (1-12)
#| | | | +-------- Day of week (0-6, 0=Sunday)
#| | | | | +--- Command to be run
#| | | | | |
#v v v v v v
#====================================================================
00 * * * * run-parts /etc/periodic/hourly
03,33 * * * * run-parts /etc/periodic/backups
02 4 * * * run-parts /etc/periodic/daily
22 4 * * 0 run-parts /etc/periodic/weekly
42 4 1 * * run-parts /etc/periodic/monthly
01 0 * * * run-parts /etc/periodic/daystart
53 23 * * * run-parts /etc/periodic/dayend
42 5 18 12 * run-parts /etc/periodic/yearly
Red Hat and CentOS have a compiled version with a few extra tweaks,
like a --new-session
option which runs each program in a separate
process session; that’s come in handy for me several times.
If your system doesn’t include run-parts, this script is a good start. I’ve used it on FreeBSD and Solaris with minor tweaks, and it’ll run under bash or KSH:
#!/bin/bash
# run-parts - concept taken from Debian
# keep going when something fails
set +e
if [ $# -lt 1 ]; then
echo "Usage: run-parts <dir>"
exit 1
fi
if [ ! -d $1 ]; then
echo "Not a directory: $1"
exit 1
fi
# Ignore *~ and *, scripts
for i in $(LC_ALL=C; echo $1/*[^~,]) ; do
[ -d $i ] && continue
# Don't run *.{rpmsave,rpmorig,rpmnew,swp,cfsaved} scripts
[ "${i%.cfsaved}" != "${i}" ] && continue
[ "${i%.rpmsave}" != "${i}" ] && continue
[ "${i%.rpmorig}" != "${i}" ] && continue
[ "${i%.rpmnew}" != "${i}" ] && continue
[ "${i%.swp}" != "${i}" ] && continue
[ "${i%,v}" != "${i}" ] && continue
# jobs.deny prevents specific files from being executed
# jobs.allow prohibits all non-named jobs from being run.
# can be used in conjunction but there's no reason to do so.
if [ -r $1/jobs.deny ]; then
grep -q "^$(basename $i)$" $1/jobs.deny && continue
fi
if [ -r $1/jobs.allow ]; then
grep -q "^$(basename $i)$" $1/jobs.allow || continue
fi
if [ -x $i ]; then
if [ -r $1/whitelist ]; then
grep -q "^$(basename $i)$" $1/whitelist && continue
fi
logger -p cron.notice -t "run-parts($1)[$$]" "starting $(basename $i)"
$i 2>&1 | awk -v "progname=$i" \
'progname {
print progname ":\n"
progname="";
}
{ print; }'
logger -i -p cron.notice -t "run-parts($1)" "finished $(basename $i)"
fi
done
exit 0
Use a SETUP file for shared code
When your scripts have code in common, put it in a separate file. I do incremental backups every 30 minutes, and /etc/periodic/backups looks like this:
/etc
| +--periodic
| | +--backups
| | | +--100.incbkup << stores a local incremental backup
| | | +--200.remote << copies that backup to a remote system
| | | +--SETUP
I use a common set of logging and error functions:
#<SETUP: functions used by all backup routines
# Identifier.
case "$tag" in
"") tag=${0##*/} ;;
*) ;;
esac
# Logging.
top="/backup"
logfile="$top/$(date +%Y)"
# Timestamp.
tstamp () {
echo $(date '+%F %T.%N') $tag: $@ | cut -c1-23,30-
}
if test -t 2 # write to stdout...
then
logmsg () {
case "$#" in
0) ;;
*) tstamp $@ ;;
esac
}
elif test -w "$top" # ... or a writable logfile...
then
logmsg () {
case "$#" in
0) ;;
*) tstamp $@ >> $logfile ;;
esac
}
else # ... or the system log.
logmsg () {
case "$#" in
0) ;;
*) logger -t "$tag" "$@" ;;
esac
}
fi
# Error/exit.
warn () { logmsg "WARN: $@"; }
die () { logmsg "FATAL: $@"; exit 1; }
I put the following at the top of any scripts in that directory:
# Common settings, logging.
setup="$(dirname $0)/SETUP"
test -f $setup || { logger -t backups "NO SETUP"; exit 1; }
. $setup
Special days - last day of the month
Occasionally, I want to schedule something to run on the last day of the month, and I don’t feel like dorking around with date or coming up with some other weird incantation.
This script (lastdom) abuses the hell out of the TZ variable to figure out if tomorrow is the first of the month, exits true if so, and exits false if not. I wrote it this way because I didn’t want to depend on the GNU version of date being installed.
If you pass any arguments to the script, they’ll be treated as something to be run if tomorrow is the first:
#!/bin/ksh
#<lastdom: run something on the last day of the month
# If no arguments, exit true if it's the last day of the month.
export PATH=/usr/local/bin:/bin:/usr/bin
export TZ=$(date +%Z)
# Print the current version.
version () {
lsedscr='s/RCSfile: //
s/.Date: //
s/,v . .Revision: / v/
s/\$//g'
lrevno='$RCSfile: lastdom,v $ $Revision: 1.4 $'
lrevdate='$Date: 2010-10-29 13:38:17-04 $'
echo "$lrevno $lrevdate" | sed -e "$lsedscr"
}
# Handle command line arguments.
while getopts vx c
do
case $c in
v) version; exit 0 ;;
x) set -x ;;
\?) exit 0 ;;
esac
done
shift $(($OPTIND - 1))
# Real work starts here. We could put "$(TZ...)" in the case
# statement, but this way you see the value when running with "-x".
tomorrow=$(TZ=$TZ-24 date +%e)
case "$tomorrow" in
1) lastday=true ;;
*) lastday= ;;
esac
case "$#" in
0) test $lastday && exit 0 ;;
*) test $lastday && exec ${1+"$@"} ;;
esac
exit 1
Special days - last workday of the month
This script is similar to lastdom but it checks if
- it’s Monday-Thursday and the following day is the first of the month, or
- it’s a Friday and the following Sunday is either the first or the second.
and will exit false unless one of those conditions is true. The same rules about arguments apply here as well:
#!/bin/ksh
#<lastwkmon: run something on the last workday of the month
# If no arguments, exit true if it's the last workday of the month.
export PATH=/usr/local/bin:/bin:/usr/sbin:/usr/bin
export TZ=$(date +%Z)
# Print the current version.
version () {
lsedscr='s/RCSfile: //
s/.Date: //
s/,v . .Revision: / v/
s/\$//g'
lrevno='$RCSfile: lastwkdom,v $ $Revision: 1.6 $'
lrevdate='$Date: 2010-10-29 13:39:42-04 $'
echo "$lrevno $lrevdate" | sed -e "$lsedscr"
}
# Handle command line arguments.
while getopts vx c
do
case $c in
v) version; exit 0 ;;
x) set -x ;;
\?) exit 0 ;;
esac
done
shift $(($OPTIND - 1))
# Real work starts here.
DoW=$(date +%w) # day of week
tomorrow=$(TZ=$TZ-24 date +%e) # tomorrow's date
aft3days=$(TZ=$TZ-72 date +%e) # three days after today
case $DoW in
[1-4]) test $tomorrow == 1 && lastworkday=true ;;
5) test $aft3days -le 3 && lastworkday=true ;;
esac
# If we have a program to run on the last workday, run it.
case "$#" in
0) test $lastworkday && exit 0 ;;
*) test $lastworkday && exec ${1+"$@"} ;;
esac
exit 1
It’s handy to be able to do
1 6 28-31 * * lastdom run-parts $HOME/etc/periodic/lastdom
and run a bunch of scripts on the last day of the month without having to think about getting the day wrong or waiting for one script to finish before another one starts.
Feedback
Feel free to send comments.