运维知识
悠悠
2025年10月18日

从JumpServer安装脚本看Shell编程:一个运维人员的实战解析

最近在部署JumpServer的时候,仔细研究了一下它的安装脚本,发现这个脚本写得还挺有意思的。作为一个天天和Shell打交道的运维,我觉得这个脚本很适合用来讲解Shell编程的一些实用技巧。

说实话,很多人觉得Shell脚本就是简单的命令堆砌,但实际上一个好的Shell脚本包含了很多编程思想和最佳实践。今天就通过这个JumpServer的jmsctl.sh脚本,来聊聊Shell编程中那些值得学习的地方。

脚本结构设计:模块化思维很重要

看这个脚本的整体结构,我第一眼就被它的组织方式吸引了。作者把整个脚本分成了几个清晰的部分:

#!/usr/bin/env bash
export SHELLOPTS

PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
cd "${PROJECT_DIR}" || exit 1
. "${PROJECT_DIR}/scripts/utils.sh"

这几行代码看似简单,但包含了很多门道。#!/usr/bin/env bash这个shebang比直接写#!/bin/bash要好,因为它会在PATH中查找bash,兼容性更强。

export SHELLOPTS这行我之前还真没怎么用过,查了一下发现它是用来导出Shell选项的,这样在调用子脚本时能保持一致的行为。

获取脚本所在目录的那行代码写得很巧妙:

PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"

${BASH_SOURCE[0]}$0更可靠,特别是在脚本被source的时候。&>/dev/null把错误输出也重定向了,避免在某些环境下出现奇怪的错误信息。

参数处理:简单但实用

action=${1-}
target=${2-}
args=("$@")

这种参数处理方式很实用。${1-}的写法比直接用$1安全,当没有参数时不会报错。把所有参数存到数组里也是个好习惯,后面需要传递给其他命令时很方便。

我以前写脚本经常直接用$1$2,但在一些边界情况下容易出问题。现在我也开始用这种写法了。

配置文件检查:防御性编程

function check_config_file() {
  if [[ ! -f "${CONFIG_FILE}" ]]; then
    echo "$(gettext 'Configuration file not found'): ${CONFIG_FILE}"
    echo "$(gettext 'If you are upgrading from v1.5.x, please copy the config.txt To') ${CONFIG_FILE}"
    return 3
  fi
  # ...
}

这个函数体现了防御性编程的思想。不仅检查配置文件是否存在,还给出了具体的解决建议。返回值用3而不是1,这样可以区分不同类型的错误。

软链接的处理也很细致:

if [[ -f .env ]]; then
  if ! ls -l .env | grep "${CONFIG_FILE}" &>/dev/null; then
    echo ".env $(gettext 'There is a problem with the soft connection, Please update it again')"
    rm -f .env
  fi
fi

检查软链接是否正确指向目标文件,如果不对就重新创建。这种处理方式在实际运维中很有用,避免了因为软链接问题导致的各种奇怪错误。

国际化支持:细节决定成败

脚本中大量使用了$(gettext '...')来支持多语言,这个细节让我印象深刻。虽然很多人写脚本时不会考虑国际化,但对于要分发给全球用户的开源项目来说,这确实很重要。

echo "$(gettext 'JumpServer Deployment Management Script')"

gettext是GNU的国际化工具,通过它可以根据系统语言环境显示对应的文本。虽然在内部项目中可能用不到,但这种思维方式值得学习。

帮助信息:用户体验很关键

function usage() {
  echo "$(gettext 'JumpServer Deployment Management Script')"
  echo
  echo "Usage: "
  echo "  ./jmsctl.sh [COMMAND] [ARGS...]"
  echo "  ./jmsctl.sh --help"
  echo
  echo "Installation Commands: "
  echo "  install           $(gettext 'Install JumpServer')"
  echo "  upgrade           $(gettext 'Upgrade JumpServer')"
  # ...
}

这个帮助函数写得很规范,命令分类清晰,每个命令都有说明。我见过太多脚本要么没有帮助信息,要么帮助信息写得很随意。一个好的帮助信息能大大提升用户体验。

服务管理:Docker Compose的封装

function start() {
  ${EXE} up -d
}

function stop() {
  if [[ -n "${target}" ]]; then
    ${EXE} stop "${target}" && ${EXE} rm -f "${target}"
    return
  fi
  ${EXE} down -v
}

这里的设计很巧妙,${EXE}变量存储的是docker-compose的完整命令行,包括配置文件路径等参数。这样在各个函数中就可以直接使用,避免了重复代码。

stop函数还考虑了单个服务和全部服务的不同处理方式,这种灵活性在实际使用中很有价值。

错误处理:让脚本更健壮

cd "${PROJECT_DIR}" || exit 1

这种写法比单独的cd命令要安全得多。如果目录切换失败,脚本会立即退出,避免在错误的目录下执行后续命令。

pre_check || return 3

在主要操作前进行预检查,失败时返回特定的错误码。这样调用者可以根据返回值判断具体的错误类型。

版本检查:自动化运维的体现

function check_update() {
  current_version=$(get_current_version)
  latest_version=$(get_latest_version)
  if [[ "${current_version}" == "${latest_version}" ]]; then
    echo_green "$(gettext 'The current version is up to date'): ${latest_version}"
    echo
    return
  fi
  # ...
}

自动检查版本更新的功能很实用,特别是对于需要经常更新的系统。这种自动化的思维在运维工作中很重要。

平台兼容性:考虑周全

if [[ "${OS}" == 'Darwin' ]]; then
  echo
  echo "$(gettext 'Unsupported Operating System Error')"
  echo "$(gettext 'macOS installer please see'): https://github.com/jumpserver/Dockerfile"
  exit 0
fi

检查操作系统类型并给出相应的提示,这种处理方式很专业。不是简单地报错退出,而是告诉用户应该怎么办。

Case语句:清晰的命令分发

case "${action}" in
install)
  bash "${SCRIPT_DIR}/4_install_jumpserver.sh"
  ;;
upgrade)
  bash "${SCRIPT_DIR}/7_upgrade.sh" "$target"
  ;;
# ...
esac

用case语句来处理不同的命令,比一堆if-elif要清晰得多。每个命令对应一个具体的操作,有些调用其他脚本,有些执行内部函数,分工明确。

日志查看:实用的调试功能

tail)
  if [[ -z "${target}" ]]; then
    ${EXE} logs --tail 100 -f
  else
    docker_name=$(service_to_docker_name "${target}")
    docker logs -f "${docker_name}" --tail 100
  fi
  ;;

这个tail命令的实现很贴心,既可以查看所有服务的日志,也可以查看单个服务的日志。在调试问题时这个功能特别有用。

一些值得学习的编程技巧

看完这个脚本,我总结了几个值得学习的地方:

变量命名要有意义。像PROJECT_DIRCONFIG_FILE这样的变量名一看就知道是什么,比dirfile要好得多。

善用数组args=("$@")把所有参数存到数组里,后面使用时很方便。

返回值要有意义。不同的错误情况返回不同的值,这样调用者可以做相应的处理。

输出重定向要合理&>/dev/null2>/dev/null这些用法要根据实际需要选择。

字符串比较用双括号[[ ]][ ]功能更强,支持模式匹配等高级特性。

说到这里,我想起之前写过一个类似的部署脚本,当时图省事很多地方都没考虑周全,后来在生产环境中遇到了不少问题。现在回头看,确实应该多花点时间在错误处理和边界情况上。

实际应用中的思考

这个脚本还有一个很好的地方就是它的扩展性。通过raw命令可以直接执行docker-compose的原生命令:

raw)
  ${EXE} "${args[@]:1}"
  ;;

这样既提供了便捷的封装命令,又保留了原生命令的灵活性。这种设计思路在很多场景下都适用。

另外,脚本中的模块化设计也很值得借鉴。主脚本负责参数解析和命令分发,具体的功能实现放在单独的脚本文件中。这样既保持了主脚本的简洁,又便于维护和测试。

不过这个脚本也不是完美的,比如错误信息的处理还可以更统一一些,某些函数的命名可能不够直观。但总体来说,这是一个很好的Shell脚本实践案例。

源码

#!/usr/bin/env bash
#
export SHELLOPTS

PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"

cd "${PROJECT_DIR}" || exit 1

. "${PROJECT_DIR}/scripts/utils.sh"

action=${1-}
target=${2-}
args=("$@")

function check_config_file() {
  if [[ ! -f "${CONFIG_FILE}" ]]; then
    echo "$(gettext 'Configuration file not found'): ${CONFIG_FILE}"
    echo "$(gettext 'If you are upgrading from v1.5.x, please copy the config.txt To') ${CONFIG_FILE}"
    return 3
  fi
  if [[ -f .env ]]; then
    if ! ls -l .env | grep "${CONFIG_FILE}" &>/dev/null; then
      echo ".env $(gettext 'There is a problem with the soft connection, Please update it again')"
      rm -f .env
    fi
  fi

  if [[ ! -f ".env" ]]; then
    ln -s "${CONFIG_FILE}" .env
  fi

  if [[ ! -f "./compose/.env" ]]; then
    ln -s "${CONFIG_FILE}" ./compose/.env
  fi
}

function pre_check() {
  check_config_file || return 3
}

function usage() {
  echo "$(gettext 'JumpServer Deployment Management Script')"
  echo
  echo "Usage: "
  echo "  ./jmsctl.sh [COMMAND] [ARGS...]"
  echo "  ./jmsctl.sh --help"
  echo
  echo "Installation Commands: "
  echo "  install           $(gettext 'Install JumpServer')"
  echo "  upgrade           $(gettext 'Upgrade JumpServer')"
  echo
  echo "Management Commands: "
  echo "  config            $(gettext 'Configuration  Tools')"
  echo "  start             $(gettext 'Start     JumpServer')"
  echo "  stop              $(gettext 'Stop      JumpServer')"
  echo "  restart           $(gettext 'Restart   JumpServer')"
  echo "  status            $(gettext 'Check     JumpServer')"
  echo "  down              $(gettext 'Offline   JumpServer')"
  echo "  uninstall         $(gettext 'Uninstall JumpServer')"
  echo
  echo "More Commands: "
  echo "  load_image        $(gettext 'Loading docker image')"
  echo "  backup_db         $(gettext 'Backup database')"
  echo "  restore_db [file] $(gettext 'Data recovery through database backup file')"
  echo "  raw               $(gettext 'Execute the original docker compose command')"
  echo "  tail [service]    $(gettext 'View log')"
  echo
}

function service_to_docker_name() {
  service=$1
  if [[ "${service:0:3}" != "jms" ]]; then
    service=jms_${service}
  fi
  echo "${service}"
}

EXE=""

function start() {
  ${EXE} up -d
}

function stop() {
  if [[ -n "${target}" ]]; then
    ${EXE} stop "${target}" && ${EXE} rm -f "${target}"
    return
  fi
  ${EXE} down -v
}

function close() {
  if [[ -n "${target}" ]]; then
    ${EXE} stop "${target}"
    return
  fi
  services=$(get_docker_compose_services ignore_db)
  for i in ${services}; do
    ${EXE} stop "${i}"
  done
}

function pull() {
   if [[ -n "${target}" ]]; then
    ${EXE} pull "${target}"
    return
  fi
  ${EXE} pull
}

function restart() {
  stop
  echo -e "\n"
  start
}

function check_update() {
  current_version=$(get_current_version)
  latest_version=$(get_latest_version)
  if [[ "${current_version}" == "${latest_version}" ]]; then
    echo_green "$(gettext 'The current version is up to date'): ${latest_version}"
    echo
    return
  fi
  if [[ -n "${latest_version}" ]] && [[ ${latest_version} =~ v.* ]]; then
    echo -e "\033[32m$(gettext 'The latest version is'): ${latest_version}\033[0m"
  else
    exit 1
  fi
}

function video-worker() {
  EXE=$(get_video_worker_cmd_line)
  if [[ ! "${EXE}" ]]; then
    return
  fi
  if [[ "${target}" == "start" ]]; then
    ${EXE} up -d
  fi
  if [[ "${target}" == "stop" ]]; then
    ${EXE} down -v
  fi
  if [[ "${target}" == "restart" ]]; then
    ${EXE} down -v
    ${EXE} up -d
  fi
  if [[ "${target}" == "status" ]]; then
    ${EXE} ps
  fi
}

function main() {
  if [[ "${OS}" == 'Darwin' ]]; then
    echo
    echo "$(gettext 'Unsupported Operating System Error')"
    echo "$(gettext 'macOS installer please see'): https://github.com/jumpserver/Dockerfile"
    exit 0
  fi
  if [[ "${OS}" =~ MINGW.* ]]; then
    echo
    echo "$(gettext 'Unsupported Operating System Error')"
    echo "$(gettext 'Windows installer please see'): https://github.com/jumpserver/Dockerfile"
    exit 0
  fi

  if [[ "${action}" == "help" || "${action}" == "h" || "${action}" == "-h" || "${action}" == "--help" ]]; then
    echo ""
  elif [[ "${action}" == "install" || "${action}" == "config" || "${action}" == "reconfig" ]]; then
    echo ""
  else
    pre_check || return 3
    EXE=$(get_docker_compose_cmd_line)
  fi
  case "${action}" in
  install)
    bash "${SCRIPT_DIR}/4_install_jumpserver.sh"
    ;;
  upgrade)
    bash "${SCRIPT_DIR}/7_upgrade.sh" "$target"
    ;;
  check_update)
    check_update
    ;;
  config)
    bash "${SCRIPT_DIR}/config.sh" "$target"
    ;;
  reconfig)
    ${EXE} down -v
    bash "${SCRIPT_DIR}/1_config_jumpserver.sh"
    ;;
  start)
    start
    ;;
  restart)
    restart
    ;;
  stop)
    stop
    ;;
  pull)
    pull
    ;;
  close)
    close
    ;;
  status)
    ${EXE} ps
    ;;
  down)
    if [[ -z "${target}" ]]; then
      ${EXE} down -v
    else
      ${EXE} stop "${target}" && ${EXE} rm -f "${target}"
    fi
    ;;
  uninstall)
    bash "${SCRIPT_DIR}/8_uninstall.sh"
    ;;
  backup_db)
    bash "${SCRIPT_DIR}/5_db_backup.sh"
    ;;
  restore_db)
    bash "${SCRIPT_DIR}/6_db_restore.sh" "$target"
    ;;
  load_image)
    bash "${SCRIPT_DIR}/3_load_images.sh"
    ;;
  pull_images)
    pull_images
    ;;
  cmd)
    echo "${EXE}"
    ;;
  tail)
    if [[ -z "${target}" ]]; then
      ${EXE} logs --tail 100 -f
    else
      docker_name=$(service_to_docker_name "${target}")
      docker logs -f "${docker_name}" --tail 100
    fi
    ;;
  show_services)
    get_docker_compose_services
    ;;
  init_db)
    perform_db_migrations
    ;;
  video-worker)
    video-worker
    ;;
  raw)
    ${EXE} "${args[@]:1}"
    ;;
  version)
    get_current_version
    ;;
  help)
    usage
    ;;
  --help)
    usage
    ;;
  -h)
    usage
    ;;
  *)
    echo "No such command: ${action}"
    usage
    ;;
  esac
}

main "$@"

写在最后

通过分析这个JumpServer的安装脚本,我们可以看到一个好的Shell脚本应该具备的特质:结构清晰、错误处理完善、用户体验友好、扩展性良好。

Shell编程虽然看起来简单,但要写好并不容易。需要考虑各种边界情况,处理不同的错误场景,还要保证代码的可读性和可维护性。

我觉得每个运维人员都应该认真对待Shell脚本的编写,毕竟这是我们日常工作中最常用的工具之一。一个好的脚本不仅能提高工作效率,还能减少出错的可能性。

希望这次的分析对大家有所帮助。如果你也有类似的脚本编写经验或者问题,欢迎在评论区交流讨论。


如果这篇文章对你有帮助,别忘了点赞转发支持一下!想了解更多运维实战经验和技术干货,记得关注微信公众号@运维躬行录,领取学习大礼包!!!我会持续分享更多接地气的运维知识和踩坑经验。让我们一起在运维这条路上互相学习,共同进步!

公众号:运维躬行录

个人博客:躬行笔记

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码