如何通过打印堆栈跟踪来修复Bash脚本中的错误

487 阅读3分钟

没有人愿意写坏代码,但不可避免地会产生bug。大多数现代语言,如Java、JavaScript、Python等,在遇到未处理的异常时都会自动打印出堆栈跟踪,但shell脚本却没有。如果你能打印出堆栈跟踪,那么发现和修复shell脚本中的bug就会容易得多,而且,只要稍加努力,你就能做到。

Shell脚本可以跨越多个文件,而且写得很好的代码会进一步分解成函数。当shell脚本出错时,追踪问题在这些脚本变得足够大时是很困难的。堆栈跟踪可以将代码从错误处往回走到开始处,可以显示你的代码在哪里出了问题,并让你更好地了解原因,以便你能正确地修复它。

为了实现堆栈跟踪,我在脚本的开头以如下方式使用陷阱

set -E

trap 'ERRO_LINENO=$LINENO' ERR
trap '_failure' EXIT

这个例子完成了几件事,但我先讲第二个,即陷阱'ERRO_LINENO=$LINENO' ERR。这一行确保脚本捕获所有以非零退出代码(即错误)退出的命令,并在文件中保存发出错误信号的命令的行号。这在退出时不会被捕获。

上面的第一行(set -E)确保错误陷阱在整个脚本中被继承下来。如果不这样做,每当你进入一个ifuntil块时,你就会失去正确的行号的追踪。

第二个陷阱捕捉来自脚本的退出信号,并将其发送到_failure函数中,我将在稍后定义这个函数。但是,如果你想对脚本进行调试,为什么是在退出时而不是在出错时?在bash脚本中,命令失败经常被用于控制逻辑中,或者在设计上可以直接忽略,因为不重要。例如,在你的脚本开始时,你在询问用户是否愿意为他们安装某个程序之前,要看看这个程序是否已经安装。

if [[ ! -z $(command -v some_command) ]]
then
   # CAPTURE LOCATION OF some_command
   SOME_COMMAND_EXEC=$(which some_command)
else
   # echo $? would give us a non-zero value here; i.e. an error code
   # IGNORE ERR: ASK USER IF THEY WANT TO INSTALL some_command
fi

如果你在每次出错时都停止处理,而some_command又没有安装,这就会过早地结束脚本,这显然不是你想在这里做的,所以一般来说,你只想在脚本因为错误而意外退出时记录错误和堆栈跟踪。

要强制你的脚本在出现意外错误时退出,可以使用set -e选项。

set -e
# SCRIPT WILL EXIT IF ANY COMMAND RETURNS A NON-ZERO CODE
# WHILE set -e IS IN FORCE
set +e
# COMMANDS WITH ERRORS WILL NOT CAUSE THE SCRIPT TO EXIT HERE

下一个问题是,有哪些例子你可能希望你的脚本退出并突出显示一个失败?常见的例子包括以下几种。

  1. 一个无法到达的远程系统
  2. 对远程系统的认证失败
  3. 配置文件或脚本文件中的语法错误被源化
  4. Docker镜像的构建
  5. 编译器错误

在一个脚本完成后,梳理许多页的日志,寻找任何可能的、难以发现的错误,这可能是非常令人沮丧的。更令人沮丧的是,当你在运行脚本后很久才发现有问题,现在不得不梳理多套日志来寻找可能出错的地方。最糟糕的是当错误已经存在了一段时间,而你却在最糟糕的时候才发现它。在任何情况下,尽快找出问题并解决它始终是首要任务。

# Sample code for generating a stack trace on catastrophic failure

set -E

trap 'ERRO_LINENO=$LINENO' ERR
trap '_failure' EXIT

_failure() {
  ERR_CODE=$? # capture last command exit code
  set +xv # turns off debug logging, just in case
  if [[  $- =~ e && ${ERR_CODE} != 0 ]]
  then
      # only log stack trace if requested (set -e)
      # and last command failed
      echo
      echo "========= CATASTROPHIC COMMAND FAIL ========="
      echo
      echo "SCRIPT EXITED ON ERROR CODE: ${ERR_CODE}"
      echo
      LEN=${#BASH_LINENO[@]}
      for (( INDEX=0; INDEX<$LEN-1; INDEX++ ))
      do
          echo '---'
          echo "FILE: $(basename ${BASH_SOURCE[${INDEX}+1]})"
          echo "  FUNCTION: ${FUNCNAME[${INDEX}+1]}"
          if [[ ${INDEX} > 0 ]]
          then
           # commands in stack trace
              echo "  COMMAND: ${FUNCNAME[${INDEX}]}"
              echo "  LINE: ${BASH_LINENO[${INDEX}]}"
          else
              # command that failed
              echo "  COMMAND: ${BASH_COMMAND}"
              echo "  LINE: ${ERRO_LINENO}"
          fi
      done
      echo
      echo "======= END CATASTROPHIC COMMAND FAIL ======="
      echo
  fi
}

# set working directory to this directory for duration of this test
cd "$(dirname ${0})"

echo 'Beginning stacktrace test'

set -e
source ./testfile1.sh
source ./testfile2.sh
set +e

_file1_function1

在上面的stacktrace.sh_failure函数做的第一件事是使用内置的shell值?捕获最后一条命令的退出代码。然后,它通过检查?**捕获最后一条命令的退出代码。然后,它通过检查**-的输出来检查退出是否出乎意料,$-是一个内置的shell值,用于保存当前的bash shell设置,看看set -e是否生效。如果脚本因错误而退出,并且错误是意外的,那么堆栈跟踪就会输出到控制台。

以下内置的shell值被用来建立堆栈跟踪。

  1. BASH_SOURCE:文件名的数组,其中每个命令都被调用回主脚本。
  2. FUNCNAME:匹配BASH_SOURCE中每个文件的行号数组。
  3. BASH_LINENO: 匹配BASH_SOURCE的每个文件的行号数组。
  4. BASH_COMMAND:最后执行的带有标志和参数的命令。

如果脚本以一种意外的方式出错,它就会在上述变量上循环,并按顺序输出每个变量,这样就可以建立一个堆栈跟踪。失败命令的行号并没有被保存在上述数组中,但这就是为什么每次命令失败时,你都会用上面的第一个陷阱语句捕获行号。

把它全部放在一起

创建以下两个文件来支持测试,所以你可以看到信息是如何在多个文件中收集的。首先,testfile1.sh

_file1_function1() {
   echo
   echo "executing in _file1_function1"
   echo

   _file2_function1
}

# adsfadfaf

_file1_function2() {
   echo
   echo "executing in _file1_function2"
   echo
  
   set -e
   curl this_curl_will_fail_and_CAUSE_A_STACK_TRACE

   # function never called
   _file2_does_not_exist
}

而接下来,testfile2.sh

_file2_function1() {
   echo
   echo "executing in _file2_function1"
   echo

   curl this_curl_will_simply_fail

   _file1_function2
}

注意:如果你自己创建这些文件,确保使stacktrace.sh 文件可执行。

执行stacktrace.sh ,将输出以下内容。

~/shell-stack-trace-example$./stracktrace.sh
Beginning stacktrace test

executing in _file1_function1

executing in _file2_function1
curl: (6) Could not resolve host: this_curl_will_simply_fail

executing in _file1_function2
curl: (6) Could not resolve host: this_curl_will_fail_and_CAUSE_A_STACK_TRACE

========= CATASTROPHIC COMMAND FAIL =========

SCRIPT EXITED ON ERROR CODE: 6

---
FILE: testfile1.sh
  FUNCTION: _file1_function2
  COMMAND: curl this_curl_will_fail_and_CAUSE_A_STACK_TRACE
  LINE: 15
---
FILE: testfile2.sh
  FUNCTION: _file2_function1
  COMMAND: _file1_function2
  LINE: 7
---
FILE: testfile1.sh
  FUNCTION: _file1_function1
  COMMAND: _file2_function1
  LINE: 5
---
FILE: stracktrace.sh
  FUNCTION: main
  COMMAND: _file1_function1
  LINE: 53

======= END CATASTROPHIC COMMAND FAIL =======

为了获得加分,请尝试取消对testfile1.sh 中的这一行的注释,并再次执行stacktrace.sh

# adsfadfaf

然后重新注释该行,而不是注释testfile1.sh 中导致堆栈跟踪的下面一行,并最后一次运行stacktrace.sh

curl this_curl_will_fail_and_CAUSE_A_STACK_TRACE

这个练习应该可以让你了解到,如果你的脚本中有错别字,会有什么输出和什么时候出现。