Looks like cron spawns a shell which in turn spawns a script; how can I get rid of the intermediate shell?

What am I doing wrong?

Nothing. What you are doing is not wrong, it is at most sub-optimal. The described situation is normal. It can be optimized though. Keep reading.


Preliminary note

There are different implementations of cron and different implementations of sh. I could run a scratch CentOS 7 and pretty accurately deduce your cron and your sh from it, I think. But I won't. This answer is going to be more generic. Therefore don't be surprised if I write something like "sh could do this and this, but apparently your sh doesn't".


Analysis

When the time comes your cron runs /bin/sh (or another shell) and passes the /root/script.sh > /dev/null 2>&1 string as an option-argument for -c. So it's like

/bin/sh -c '/root/script.sh > /dev/null 2>&1'

except the above line is what you would type in a shell, while cron builds (or at least should build) the argument array directly, where /bin/sh, -c and /root/script.sh > /dev/null 2>&1 are separate elements and there is no need for additional single-quotes (so you can use single-quotes in the command and they will not break anything).

There is at least one thing cron does to the specified command prior to passing it to sh: it interprets % (see man 5 crontab). Your command does not contain %, so this mechanism is not relevant. Cron could do more:

  • It could detect $, |, &&, ; and such and decide it should certainly run a shell that will handle these.
  • In your case it could detect the shell would not be needed, if only cron itself handled the redirections; so it would handle the redirections and run /root/script.sh directly.
  • It could detect really simple commands (e.g. /usr/bin/beep) and run them directly.

It could but it shouldn't. It's not its job to optimize and act like a shell. Its job is to run a command interpreter (shell) which takes it from here.

In your case sh is spawned. It interprets /root/script.sh > /dev/null 2>&1, handles the redirections and runs /root/script.sh as a child process.

In general a shell can optimize this step, your sh apparently doesn't. Some shells do. It's possible for a shell to reliably tell if it can "disappear". I will elaborate below.


Solutions

  1. (inferior) Run the command asynchronously. If your command is

    /root/script.sh > /dev/null 2>&1 &
    

    then cron will run sh and the sh will exit without waiting for script.sh to finish. This has side effects though:

    • cron will see its child process (i.e. sh) exited. There will be no direct connection between cron and the script.sh process (unless cron sets itself up as a "subreaper", your cron probably doesn't).
    • cron will see its child process (i.e. sh) exited with exit status 0. Normally (without &) the exit status of sh would depend on the exit status of script.sh; but for this to happen sh must wait for script.sh to finish.

    By manually killing sh you inadvertently introduced these side effects. In particular cron doesn't care much, I think (it can be configured to log failed jobs though). But in general the parent process may care, so users should not "randomly" kill intermediate shells; and shells should not arbitrarily decide they can exit without waiting for the last command. As far as I know no shell does this kind of "optimization".

  2. (suprior) Explicitly exec to script.sh:

    exec /root/script.sh > /dev/null 2>&1
    

    exec makes the shell replace itself with the command without creating a new process. In this case script.sh will replace sh under the same process ID. In the context of the side effects mentioned above, this is very nice:

    • The parent process won't notice when sh "disappears". It will see script.sh instead of sh; and it will consider script.sh the child. Without exec the parent process would be notified when its child (sh) exits just after script.sh exits. With exec the parent process will be notified when script.sh exits, because this is the child now. Effectively in both cases it's when script.sh exits.
    • The parent process will get the exit status directly from script.sh when it terminates. The fact sh no longer exists is irrelevant. Without exec the parent process would get the exit status relayed by sh from the last command it runs, i.e. from script.sh. Effectively in both cases it's the exit status from script.sh.

    This method is almost transparent, even if the parent process cares about when the child exits or what exit status it returns. This is the optimization shells can do. Bash does it. In some cases it may be quite confusing if you don't know about it; compare this answer of mine where there is sshd instead of cron.

    If you tell cron to use bash and you don't use exec then the optimization may kick in, as if you used exec; or it may not, it depends on the command. So if you want the shell to exec for sure then explicitly tell it to exec; do not rely on optimization capabilities of the shell.

    Now your cron uses sh which most likely never optimizes like this, so use exec explicitly in the command.

    Side note: exec command1; command2 or exec command1 && command2 will never get to command2. Keep this in mind, especially if you happen to have exec command1 and decide to add command2 to the job. These should work: command1; exec command2 or command1 && exec command2. But then expect sh process to exist when command1 runs; you need it to run command2 later.