Shell一键部署方案及实现

前言

当我们在本地电脑上写了个后端服务,想将其部署到xx云上时,可选的部署方式至少有5种,如下:

  1. 原始部署方式
  2. docker部署方式
  3. 宝塔部署方式(免费,方便)
  4. Jenkins自动化部署方式
  5. 容器平台部署方式(花钱)
    ...

在上述部署方式中,原始部署无疑最麻烦的方式,主要是依赖安装、环境配置都需要一步步来操作,而且遇到多台服务器时,还得考虑如何保证环境的一致性。docker部署能省去一部分依赖、配置,从而保证环境统一的问题。后三种可能更偏向于 企业级应用部署,当然也适用于个人,不过最终还得看个人需求选择。

针对我个人来讲,想要的部署功能是这样的:

  • 不想直接上传源码至服务器
  • 最好是本地执行直接服务器部署
  • 要是能支持版本管理就更好了. 等等

那么这些部署功能需求如何实现呢?上述方式能否满足呢?
显然答案是否定的。仅使用上述罗列的部署方式,对于自定义的部署功能来说,可能并不能直接完全满足需求,还需进行改造才能符合。那么接下来看看如何从需求到实现吧!

明确需求

在实现业务功能前,我们应该先来对需求进行分析,然后再进行实现。这样才能更好全面的思考以明确要实现怎么样的业务逻辑及功能。

部署流程

首先来看下部署的流程(也就是业务逻辑)

功能需求

  1. 需支持指定不同的环境(测试、线上等)
  2. 需支持指定不同的服务器IP,且IP/密码可记忆
  3. 需支持构建版本管理, 能指定选择版本
  4. 需支持自动构建打包、登录、上传功能
  5. 需支持自动依赖安装和自动部署功能
  6. 需支持一键部署和单独执行部署
  7. 需支持部署版本回滚功能
  8. 需支持指定不同版本部署/回滚
  9. 需支持应用启动、重启、暂停、删除应用功能

非功能需求

  • 界面美观
  • 提示友好
  • 健壮性强
  • 维护性好

方案设计

由于本地系统环境为 MacOS,远程服务为某云ESC的Alibaba Cloud Linux3环境,所以可通过 shell 脚本将每个部署流程中的步骤进行串联,从而到达一键部署的效果。在应用部署时进行容器化,这样可减少部分依赖配置。在技术选择方面, 本方案尽量选择系统自带的工具进行实现,减少不必要外部工具依赖关联。
PS:方案中使用shell实现目的主要是通过实践应用捡起学过且遗忘的shell

技术选型

罗列主要节点的一些实现工具

  • 采用 mvn 实现构建打包
  • 采用 xpath 获取项目信息(macOS自带)
  • 采用 scp 实现文件上传(macOS自带)
  • 采用 expect 实现ssh密码自动登录(macOS自带)或使用ssh密钥实现免密登录
  • 采用 wget 进行资源下载(远程服务自带)
  • 采用 yum 资源安装管理(远程服务自带)
  • 采用 docker 方式实现应用部署
  • 采用 curl 进行应用访问(远程服务自带)

详细设计

先将整体流程分为两大阶段,可实现一键部署或独立部署。一键部署通过参数控制,在上传脚本中加上服务器部署命令即可。


1、 本地自动生成部署产物上传服务器(上传脚本放在项目根目录下)

脚本参数帮助信息部分:直接通过echo展示参数及其含义。参数1为环境参数;参数2为动作参数(用于指定IP/密码等)。还需进行参数个数、字符限制判断等。

预处理业务参数校验部分:展示当前环境信息,校验脚本目录与项目目录(从脚本目录下的pom文件中获取)在同层。通过查找项目启动文件(按xxApplication.java命名)获取构建的jar包路径。IP/密码参数格式校验及记忆存储(写入用户目录下的隐藏文件中)。

项目指定环境构建部分:在pom文件中配置环境标并在应用配置文件中指定属性激活环境。通过 mvn clean package -Dmaven.test.skip=true -Pxxx 实现指定环境构建部署jar包。

部署产物版本管理部分:服务器应用部署脚本只需一个即可,而jar包版本与docker配置文件一起通过日期时间文件管理。新建自定义构建目录,存放部署脚本、构建的版本jar包以及 dockerfile 文件。

部署产物上传部分:为实现上传以及快捷部署,需先对远程服务器进行目录创建、权限设置以及部署快捷命令实现。然后通过expect自动输入密码 scp -r $UPLOAD_SOURCES $LOGIN_USER@$IP:~/$BUILD_ROOT_DIR" 上传。由于存在多个不同路径的文件需要使用 bash -c 在加上 scp 命令。


2、登录服务器,根据据上传产物实现部署(部署脚本放在项目根目录下的APP_META目录中)

自动登录服务器部分:为了能够停留与服务器交互则需要将 expect 自动登录脚本写入文件中。 然后在收到脚本退出信号时自动删除文件即可。

部署环境预处理部分:依赖环境 和 镜像容器环境

  • 依赖环境检查:产物目录校验。而后通过docker部署,先检查docker环境,若没有则使用 yum 下载安装(若没有docker源,则需wget下载源)。然后设置开机自启并启动docker服务systemctl reenable docker.service && systemctl start docker.
  • 镜像容器初始化:先检查指定的名称(myapp)的镜像容器是否存在,存在则进行删除

镜像构建容器创建启动部分:依据上传的产物,通过 docker build 构建镜像 ,docker run 创建并启动容器

应用状态校验部分:项目暴露接口/api/get/env且返回当前环境信息(项目配置的环境信息),然后脚本curl 命令请求循环等待判断应用是否启动成功,并解析接口响应判断部署环境是否一致。

其他功能:通过对docker命令的封装实现即可

方案实现

⚠️脚本长度预警(两份脚本行数平均500+行),为了方便操作,采用了防御性编程方式。哈哈~~

第一阶段脚本实现 packupload.sh

#!/bin/bash

# debug 开关. 0-关, 1-开
DEBUG_SWITCH=0
# 脚本名称
PROG_NAME=$(basename "$0")
# 当前脚本目录
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
# 环境参数
ENV_ARG=$1
# 执行动作参数
ACTION_ARG=$2
# IP/密码参数
IP_PASSWORD_ARG=$3
# 自定义全局退出码
M_ERROR_CODE=110
readonly ACTION_ARG
# 登录的用户名
LOGIN_USER=root
WAIT_SYMBOL="*]#"

usage() {
  echo "Usage: $PROG_NAME [options] [actions] [ip@password]"
  echo "   指定环境打包当前项目工程的jar文件并上传服务器执行部署,构建产物路径[./mBuild*]"
  echo "options:"
  echo "  -d: 指定环境为日常环境 Daily"
  echo "  -p: 指定环境为生产环境 Prod"
  echo "actions:"
  echo "  deploy:     一键完成构建->打包->上传->部署"
  echo "  upload:     一键完成构建->打包->上传"
  echo "  build:      一键完成构建->打包"
  echo "  sdeploy:    选择已有构建版本->上传->部署"
  echo "  supload:    选择已有构建版本->上传"
  echo "  login:      登录服务器"
  echo "ip@password"
  echo "  指定服务器IP和密码(root用户).初次指定即可. 格式: 192.168.1.20@1234567"
  echo && echo "description: 使用该工具时需注意项目名称和jar包打包名称是否与脚本逻辑匹配" && exit 1
}

# 参数个数校验
[ $# -lt 2 ] && usage

# 判断元素是否在数组。在数组内返回1,不在返回0
inArray() {
  # 第1参数为需判断的字符
  local value="$1"
  # 第2参数为数组数据。传参时使用 "$array[*]}" 将数组所有值当作一个参数传递,然后函数内解构
  local -a local_array
  read -r -a local_array <<<"$2"

  local flag=0
  for i in "${local_array[@]}"; do
    [ "$i" == "$value" ] && flag=1
  done
  echo $flag
}

# 获取当前时间. 年月日时分秒格式
getCurTime() {
  # 获取当前时间秒数。获取当前时间:date "+%Y-%m-%d %H:%M:%S"
  local cur_timestamp os_name cur_time
  cur_timestamp=$(date +%s)
  os_name=$(uname -s | tr '[:upper:]' '[:lower:]')
  if [ "${os_name}" == "darwin" ]; then
    cur_time=$(date -r "${cur_timestamp}" +"%Y%m%d%H%M%S")
    echo "$cur_time"
    return
  fi

  cur_time=$(date -d@"${cur_timestamp}" +"%Y%m%d%H%M%S")
  echo "$cur_time"
}

# 错误显示且退出。红色
error_echo_exit() {
  echo -e "\033[31m[ERROR]:$1\033[0m"
  exit 1
}

error_code_exit() {
  echo -e "\033[31m[ERROR]:$1\033[0m"
  exit $M_ERROR_CODE
}

# 正常显示。绿色
normal_echo() {
  echo -e "\033[32m$1\033[0m"
}

# 提示显示。黄色
tips_echo() {
  [[ "$1" =~ "\c" ]] && echo -e "\033[33m${1//\\c/ }\033[0m\c" || echo -e "\033[33m$1\033[0m"
}

# 步骤显示。蓝色
step_echo() {
  local chinese_count
  # 统计中文字符个数, 设置临时编码格式,仅作用于perl命令(有些环境下会出现警告)
  # echo "abc" | LC_ALL=en_US.UTF-8 perl -CS -ne 'print scalar(() = /\p{Han}/g)'
  chinese_count=$(echo -n "$1" | perl -CS -ne 'print scalar(() = /\p{Han}/g)')
  local display_width=$((50 + chinese_count))
  printf "\e[34m--------------------------------%-${display_width}s-------------------------\e[0m\n" "$1" | sed 's/ /-/g'
  [[ "$1" =~ "结束" ]] && echo
}

# 调试日志显示。紫色
debug_echo() {
  [ "$DEBUG_SWITCH" == 1 ] && echo -e "\033[35m[DEBUG]$1\033[0m"
}

# 环境提示
env_echo() {
  echo -e "\033[31m##########################################【$1】##########################################
#                                                                                                  #
#                                   当前\033[33m$ENV_STR\033[31m$1                                       #
#                                                                                                  #
####################################################################################################\033[0m\n"
}

# 所有参数校验
ALLOW_OPTION_LIST=(-d -p)
if [[ $(inArray "$ENV_ARG" "${ALLOW_OPTION_LIST[*]}") == 0 ]]; then
  echo "[ERROR]:options args error. -d|-p"
  usage
fi

if [ -n "$CUR_ENV" ]; then
  ENV_STR="测试环境"
  CUR_ENV="Daily"
  PRO_ENV="dev"
else
  ENV_STR="生产环境"
  CUR_ENV=""
  PRO_ENV="prod"
fi

CUR_TIME=$(getCurTime)
# 打包后文件目录
BUILD_ROOT_DIR=mBuild${CUR_ENV}

# 检查当前目录
cur_dir_check() {
  local cur_pom_file=${SCRIPT_DIR}/pom.xml
  debug_echo "检查脚本目录 $cur_pom_file"
  [ ! -f "$cur_pom_file" ] && error_echo_exit "脚本目录无pom.xml文件"

  local query_pom project_root_dir
  if command -v /usr/bin/xpath >/dev/null 2>&1; then
    # macOS 自带xpath命令,获取pom文件project标签的第一个artifactId的文本值
    project_root_dir=$(xpath -q -e '/project/artifactId[1]/text()' "$cur_pom_file")
  else
    # 包含 artifactId 字段,但不包含parent字段的第一个匹配(TODO:这里可能会出现其他情况,一般项目pom位置都是在最前面)
    query_pom=$(grep "artifactId" "$cur_pom_file" | grep -m1 -v "parent")
    [ -z "$query_pom" ] && error_echo_exit "pom文件中获取项目名称失败"
    # 从pom文件中获取当前目录(一般情况下: 当前目录与POM文件的artifactId是一致的,而且是放在最前面)
    project_root_dir=$(echo "$query_pom" | sed -E 's/[[:space:]]+.*>(.*)<.*/\1/')
  fi

  debug_echo "当前目录:${SCRIPT_DIR##*/} 工程目录:$project_root_dir"
  # 判断是否工程根目录
  if [ "${SCRIPT_DIR##*/}" != "${project_root_dir}" ]; then
    error_echo_exit "脚本不在工程【${project_root_dir}】根目录下!"
  fi

  # 查找应用启动文件(TODO:这里可能会出现其他情况,一般启动文件命名都是*application.java结尾)
  local app_start_file line_count
  app_start_file=$(find "$SCRIPT_DIR" -iname "*application.java" | grep "main")
  [ -z "$app_start_file" ] && error_echo_exit "脚本目录下未找到[*application.java]名称的应用启动文件"

  # shellcheck disable=SC2126
  line_count=$(echo "$app_start_file" | grep -v '^\s*$' | wc -l)
  [ "$line_count" -gt 1 ] && error_echo_exit "存在多个[*application.java]应用启动文件,无法判断"

  local app_start_root_path=${app_start_file%/src/*}
  debug_echo "应用启动文件所在路径: $app_start_root_path"
  # 若应用启动文件父目录 等于 脚本目录(项目根目录)
  if [ "$app_start_root_path" == "$SCRIPT_DIR" ]; then
    APP_JAR_DIR=.
    PROJECT_NAME="$project_root_dir"
  else
    APP_JAR_DIR=${app_start_root_path#*"$SCRIPT_DIR"/}
    PROJECT_NAME=$APP_JAR_DIR
  fi
}

# 删除历史打包的目录
history_build_del() {
  if [ ! -d "${BUILD_ROOT_DIR}" ]; then
    return
  fi
  local confirm
  tips_echo "是否删除旧的${BUILD_ROOT_DIR}目录(y/n): \c" && read -r -n 1 confirm
  # 按回车键字符串为空
  if [[ "${confirm}" == "" || "${confirm}" == y* ]]; then
    rm -rf "${BUILD_ROOT_DIR}" 2>/dev/null
    [[ "${confirm}" == y* ]] && echo -e "\n删除成功!" || echo "删除成功!"
    return
  fi
  echo
}

# 构建打包
mvn_build_pack() {
  local build_result
  debug_echo "开始打包jar文件"
  # 指定环境打包(POM文件和项目配置文件中需要进行环境设置)
  build_result=$(mvn clean package -Dmaven.test.skip=true "-P${PRO_ENV}" | grep "FAILURE")
  debug_echo "BUILD_RESULT: ${build_result}"
  if [ "x${build_result}" != x ]; then
    error_echo_exit "构建失败!"
  fi
  normal_echo "构建打包成功"
}

# 复制jar包和脚本至指定后的打包目录
copy_jar_and_sh() {
  # 原始jar包路径
  local raw_jar_path=${APP_JAR_DIR}/target/${PROJECT_NAME}-$PRO_ENV.jar

  # 每次打包完后的目录
  EACH_BUILD_DIR=${BUILD_ROOT_DIR}/version_${CUR_TIME}
  # 复制后jar包名称(名称由脚本控制,不通过构建时来控制版本)
  AFTER_JAR_NAME=${PROJECT_NAME}.${CUR_TIME}.jar
  mkdir -p "${EACH_BUILD_DIR}" >/dev/null
  # 当退出时的状态为$M_ERROR_CODE 时,删除构建的目录
  trap '{ [ "$?" -eq "$M_ERROR_CODE" ] && rm -rf "$EACH_BUILD_DIR"; }' EXIT
  if ! cp "${raw_jar_path}" "${EACH_BUILD_DIR}/${AFTER_JAR_NAME}"; then
    error_code_exit "jar包复制错误"
  fi

  [ ! -e "${EACH_BUILD_DIR}/${AFTER_JAR_NAME}" ] && error_code_exit "复制错误. ${EACH_BUILD_DIR}/${AFTER_JAR_NAME}文件不存在"

  # 命令行可以复制,但是脚本里不行,不能多个文件复制。加双引号时把*变成了字符
  # cp -f -p -r "${app_meta_dir}/*.sh" "${BUILD_ROOT_DIR}"
  local app_meta_dir="${SCRIPT_DIR}/APP-META"
  local sh_files
  sh_files=$(find "${app_meta_dir}" -name "*sh")
  for pfile in ${sh_files}; do
    cp -f "${pfile}" "${BUILD_ROOT_DIR}" >/dev/null 2>&1
  done
  normal_echo "复制成功,路径:$EACH_BUILD_DIR"
}

# 生成dockerfile文件
generate_docker_file() {
  local cur_env_lower
  cur_env_lower=$(echo "${CUR_ENV}" | tr '[:upper:]' '[:lower:]')
  # docker 工作目录
  local docker_dir=/home/dockers
  local docker_run_jar_path=${docker_dir}/${cur_env_lower}myapp-${CUR_TIME}.jar
  # 生成每次构建的 dockerfile
  echo "FROM openjdk:8
# 容器中创建目录
RUN mkdir -p ${docker_dir}
# 设置容器的工作目录
WORKDIR ${docker_dir}
# 指定容器数据卷(日志文件会生成在user.home下,由于root用户部署故使用该目录)
#VOLUME /root
# 同步时间
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone
# 容器添加运行jar包
ADD ${AFTER_JAR_NAME} ${docker_run_jar_path}
# 容器启动时执行的命令
CMD [\"nohup\", \"java\", \"-jar\", \"${docker_run_jar_path}\", \"&\"]" >"${EACH_BUILD_DIR}"/Dockerfile

  normal_echo "文件生成成功,路径: $EACH_BUILD_DIR/Dockerfile"
}

# 选择器
select_options() {
  local show_str="$1"
  [ -z "$show_str" ] && error_echo_exit "select_options方法第1个参数为提示字符串, 不能为空"
  local array_strr seq_list option
  read -r -a array_strr <<<"$2"

  # 定义一个数组长度的编号序列(0~index_no)
  local index_no=$((${#array_strr[@]} - 1))
  seq_list=$(eval "echo {0..$index_no}")
  while true; do
    tips_echo "请输入${show_str}编号(enter键退出):\c" && read -r -n 1 option
    if [ -z "$option" ]; then
      error_echo_exit "未选择${show_str}编号,即将退出"
    fi
    # 如果输入的选项在编号序列中,则赋值给当前选择变量,否则提示错误继续选择
    if [ "$(inArray "$option" "${seq_list[*]}")" == 1 ]; then
      echo
      CUR_SELECT_OPTION=${array_strr[$option]}
      break
    else
      echo -e "\t\033[31m错误\033[0m"
    fi
  done
}

# 服务器IP地址和密码的校验与选择
select_ip_and_check() {
  local ip_password_cache_file=$HOME/.ssh_ip_pwd_cache

  # IP 正则匹配
  local pattern="([0-9]{1,3}\.){3}[0-9]{1,3}"
  local ip_in_file
  ip_in_file=$(grep -E "^${pattern}\@.{6,}$" "$ip_password_cache_file" 2>/dev/null)
  # 未指定密码参数,文件中也未匹配到IP密码格式,则直接退出
  if [[ -z "$IP_PASSWORD_ARG" && -z "$ip_in_file" ]]; then
    error_echo_exit "从未指定过服务器IP及密码,请携带参数运行"
  fi

  # IP密码参数为空则从缓存读取选择,否则写入缓存
  if [ -n "$IP_PASSWORD_ARG" ]; then
    local check_result
    check_result=$(echo "$IP_PASSWORD_ARG" | grep -E "^${pattern}\@.{6,}$")
    if [ -z "$check_result" ]; then
      echo "ip@password参数错误, 请检查! 密码至少6位"
      usage
    fi

    IP=${check_result%@*}
    PASSWORD=${check_result#*@}
    # 检查IP是否在文件中,不在则直接写入,否则修改文件内容
    local has_ip diff_cmd
    has_ip=$(grep -E "^$IP" "$ip_password_cache_file" 2>/dev/null)
    if [ -z "$has_ip" ]; then
      echo "$IP_PASSWORD_ARG" >>"$ip_password_cache_file"
    else
      [ "${os_name}" == "darwin" ] && diff_cmd="''" || diff_cmd=""
      debug_echo "sed -i $diff_cmd -e 's/^$IP.*/$IP_PASSWORD_ARG/' $ip_password_cache_file"
      sed -i "$diff_cmd" -e "s/^$IP.*/$IP_PASSWORD_ARG/" "$ip_password_cache_file"
    fi
  else
    local cache_data
    # 将缓存文件内容行转换为数组
    read -r -a cache_data <<<"$(tr '\n' ' ' <"$ip_password_cache_file")"
    if [[ "${#cache_data[*]}" == 1 ]]; then
      IP=${cache_data[0]%@*}
      PASSWORD=${cache_data[0]#*@}
    else
      normal_echo "当前可选择的服务器IP列表: "
      for i in "${!cache_data[@]}"; do
        normal_echo " ${i}.${cache_data[$i]}"
      done
      select_options "需部署的服务器IP" "${cache_data[*]}"
      IP=${CUR_SELECT_OPTION%@*}
      PASSWORD=${CUR_SELECT_OPTION#*@}
    fi
  fi
}

# 选择构建版本
select_build_version() {
  [ ! -e "$BUILD_ROOT_DIR" ] && error_echo_exit "目录不存在: $BUILD_ROOT_DIR"

  local version_dir
  version_dir=$(ls -ldcr "$BUILD_ROOT_DIR"/version* 2>/dev/null)
  if [ -z "$version_dir" ]; then
    error_echo_exit "$BUILD_ROOT_DIR 目录下无构建版本数据."
  fi

  local version_top
  read -r -a version_top <<<"$(echo "$version_dir" | awk -F ' ' '{print $NF}' | head -8 | tr '\n' ' ')"

  if [ ${#version_top[*]} == 1 ]; then
    EACH_BUILD_DIR=${version_top[0]}
  else
    normal_echo "当前可选择的构建版本列表:"
    for i in "${!version_top[@]}"; do
      normal_echo " ${i}.${version_top[$i]##*/}"
    done
    select_options "需要上传版本" "${version_top[*]}"
    EACH_BUILD_DIR=$CUR_SELECT_OPTION
  fi
}

# 上传数据到服务器
upload_data_to_service() {
  step_echo "开始上传服务器$IP"
  # 上传多个资源,空格隔开
  local upload_sources="${EACH_BUILD_DIR} ${BUILD_ROOT_DIR}/*.sh"
  # 服务器上部署脚本路径
  local deploy_sh=/${LOGIN_USER}/${BUILD_ROOT_DIR}/appctl.sh

  if ! command -v /usr/bin/expect >/dev/null 2>&1; then
    error_echo_exit "/usr/bin/ 下未找到expect命令,无法自动上传文件的到服务器。\n请手动操作: scp -r ${upload_sources} $LOGIN_USER@$IP:~/${BUILD_ROOT_DIR}"
  fi

  # 服务器 ${BUILD_ROOT_DIR} 目录仅需创建一次,为保障通用性每次都会登录创建目录
  normal_echo "即将上传至服务器[$IP]目录: ~/${BUILD_ROOT_DIR}"

  # 登录创建目录以及设置部署脚本快捷命令. 3s 等待. 设置log_user为0, 禁止输出信息
  /usr/bin/expect <<EOF
      log_user 0
      set timeout 3
      spawn ssh -o StrictHostKeyChecking=no -p 22 $LOGIN_USER@$IP
      expect "*password:"
      send "$PASSWORD\r"
      expect "$WAIT_SYMBOL"
      send "mkdir -p $BUILD_ROOT_DIR 2>/dev/null\r"
      expect "$WAIT_SYMBOL"
      send "touch $deploy_sh 2>/dev/null && chmod u+x $deploy_sh 2>/dev/null && ln -s $deploy_sh /usr/local/bin/appctl >/dev/null 2>&1\r"
      expect "$WAIT_SYMBOL"
      send "exit\r"
      expect eof
EOF
  sleep 1
  # 上传文件, -1 永不超时, # bash -c 是为了对参数的转换
  /usr/bin/expect <<EOF
      set timeout -1
      spawn bash -c "scp -r $upload_sources $LOGIN_USER@$IP:~/$BUILD_ROOT_DIR"
      expect "*password:"
      send "$PASSWORD\r"
      expect eof
EOF
  step_echo "上传服务器结束"
}

deploy_application() {
  local temp_login_file=$HOME/login_deploy.exp
  # 收到退出(EXIT)信号后,删除文件
  trap '{ rm -f "$temp_login_file"; }' EXIT
  [ -n "$CUR_ENV" ] && deploy_cmd="appctl -de deploy" || deploy_cmd="appctl -p deploy"
  # 登录执行部署命令
  echo -e "#!/use/bin/expect
set timeout -1
spawn ssh -p 22 $LOGIN_USER@$IP
expect \"*password:\"
send \"$PASSWORD\\\\r\"
expect \"$WAIT_SYMBOL\"
send \"$deploy_cmd\\\\r\"
expect {
    \"$WAIT_SYMBOL\" {
        send \"exit\\\\r\"
        expect eof
    }
    timeout {
        send \"exit\\\\r\"
        expect eof
        puts \"部署命令执行时间太久,将自动退出\"
    }
}
" >"$temp_login_file"

  if [ ! -e "$temp_login_file" ]; then
    error_echo_exit "$temp_login_file 生成失败,即将终止"
  fi

  step_echo "即将登录服务器部署,exit登出"
  sleep 5
  expect "$temp_login_file"
}

login_action() {
  step_echo "开始登录服务器[$IP]"
  # 在bash里使用 expect 执行(interact不生效)后会回到 bash. 通过写入文件执行可进入交互
  local temp_login_file=$HOME/login_deploy.exp
  trap '{ rm -f "$temp_login_file"; }' EXIT
  echo -e "#!/use/bin/expect
log_user 0
set timeout -1
spawn ssh -o StrictHostKeyChecking=no -p 22 $LOGIN_USER@$IP
expect \"*password:\"
send \"$PASSWORD\\\\r\"
expect \"$WAIT_SYMBOL\"
send \"clear\\\\r\"
interact
" >"$temp_login_file"

  expect "$temp_login_file"
}

base_build_pack() {
  step_echo "开始构建打包"
  mvn_build_pack
  step_echo "构建打包结束"

  step_echo "开始复制jar包"
  copy_jar_and_sh
  step_echo "复制jar包结束"

  step_echo "开始生成Dockerfile"
  generate_docker_file
  step_echo "Dockerfile生成结束"
}

base_check() {
  # 当前路径校验
  cur_dir_check
  # IP校验及选择
  select_ip_and_check
  # 历史build删除确认
  history_build_del
}

one_key_build() {
  env_echo "一键构建版本"
  base_check
  base_build_pack
}

one_key_upload() {
  env_echo "一键数据上传"
  base_check
  base_build_pack
  upload_data_to_service
}

one_key_select_upload() {
  env_echo "一键选择上传"
  cur_dir_check
  select_ip_and_check
  select_build_version
  upload_data_to_service
}

one_key_deploy() {
  env_echo "一键远程部署"
  base_check
  base_build_pack
  upload_data_to_service
  deploy_application
}

one_key_select_deploy() {
  env_echo "一键选择部署"
  cur_dir_check
  select_ip_and_check
  select_build_version
  upload_data_to_service
  deploy_application
}

one_key_login() {
  select_ip_and_check
  login_action
}

main() {
  case "${ACTION_ARG}" in
  deploy)
    one_key_deploy
    ;;
  upload)
    one_key_upload
    ;;
  build)
    one_key_build
    ;;
  sdeploy)
    one_key_select_deploy
    ;;
  supload)
    one_key_select_upload
    ;;
  login)
    one_key_login
    ;;
  *)
    usage
    ;;
  esac
}

main

第二阶段脚本实现 appctl.sh

#!/bin/bash

# 脚本名称
PROG_NAME=$0
# 环境参数
ENV_ARG=$1
# 动作参数
ACTION_ARG=$2
readonly ACTION_ARG

usage() {
  echo "Usage: $PROG_NAME [options] [actions]"
  echo "  通过docker命令操作(构建/部署)指定目录下的jar包版本"
  echo "options:"
  echo "  -d: 指定环境为日常环境 Daily"
  echo "  -p: 指定环境为生产环境 Prod"
  echo "  -e: 指是否开启调试开关,无法单独使用.与-d|-p连用. 如:-de|-pe"
  echo "actions:"
  echo "  deploy:    [一键部署]最新版本.默认会删除已有镜像和容器"
  echo "  rollback:  [一键回滚]上个版本.默认最大显示前6个版本,当无法回滚上个版本时需交互选择版本"
  echo "  online:    [上线应用].当有容器未启动时,可通过该参数重启上线"
  echo "  offline:   [下线应用].当有容器已启动时,可通过该参数暂停下线"
  echo "  start:     [启动应用].有镜像但无容器时,可通过该参数启动应用"
  echo "  delete:    [删除应用].会强制删除镜像和容器相关数据"
  echo "  select:    [选择版本].通过选择指定版本进行部署/回滚"
  echo
  exit 1
}

# 参数个数校验
[ $# -lt 1 ] && usage

if [[ $ENV_ARG != "-d" && $ENV_ARG != "-p" && $ENV_ARG != "-de" && $ENV_ARG != "-pe" ]]; then
  echo "options args error. -d or -p or -de or -pe"
  usage
fi

[[ "$ENV_ARG" == "-d" || "$ENV_ARG" == "-de" ]] && CUR_ENV="Daily" || CUR_ENV=""
# debug 开关. 0-关, 1-开
[[ "$ENV_ARG" == "-de" || "$ENV_ARG" == "-pe" ]] && DEBUG_SWITCH=1 || DEBUG_SWITCH=0

if [ -n "$CUR_ENV" ]; then
  ENV_STR="测试环境"
  OUT_PORT=7070
else
  ENV_STR="生产环境"
  OUT_PORT=8080
fi

# docker中repository不支持大写。bash4.0 以下版本不支持的转换写法 echo "${CUR_ENV,,}"
CUR_ENV_LOWER=$(echo "$CUR_ENV" | tr '[:upper:]' '[:lower:]')
IMAGE_REPOSITORY=${CUR_ENV_LOWER}myapp
DOCKERFILE_DIR=~/mBuild${CUR_ENV}
# 创建本机目录用于容器日志的挂载
LOGS_DIR=/home/dockers/${CUR_ENV_LOWER}
[ ! -d "$LOGS_DIR" ] && mkdir -p "$LOGS_DIR" 2>/dev/null
# 若目录修改状态(chmod等命令) 发生变化则排序会出问题。 可通过文件名的时间戳比大小
DIR_LIST=$(ls -ldcr "$DOCKERFILE_DIR"/version* 2>/dev/null)

# 错误显示且退出。红色
error_echo_exit() {
  echo -e "\033[31m[ERROR]$1\033[0m"
  exit 1
}

# 正常显示。绿色
normal_echo() {
  echo -e "\033[32m$1\033[0m"
}

# 提示显示。黄色
tips_echo() {
  [[ "$1" =~ "\c" ]] && echo -e "\033[33m${1//\\c/ }\033[0m\c" || echo -e "\033[33m$1\033[0m"
}

# 步骤显示。蓝色
step_echo() {
  local chinese_count
  # echo "abc" | LC_ALL=en_US.UTF-8 perl -CS -ne 'print scalar(() = /\p{Han}/g)'
  chinese_count=$(echo -n "$1" | perl -CS -ne 'print scalar(() = /\p{Han}/g)')
  local display_width=$((46 + chinese_count))
  printf "\e[34m--------------------------------%-${display_width}s-------------------------\e[0m\n" "$1" | sed 's/ /-/g'
  [[ "$1" =~ "结束" ]] && echo
}

# 调试日志显示。紫色
debug_echo() {
  [ "$DEBUG_SWITCH" == 1 ] && echo -e "\033[35m[DEBUG]$1\033[0m"
}

# 环境提示
env_echo() {
  echo -e "\033[31m##########################################【$1】##############################################
#                                                                                                  #
#                                   当前\033[33m$ENV_STR\033[31m$1                                           #
#                                                                                                  #
####################################################################################################\033[0m\n"
}

# 判断元素是否在数组。在数组内返回1,不在返回0
inArray() {
  # 第1参数为需判断的字符
  local value="$1"
  # 第2参数为数组数据。传参时使用 "$array[*]}" 将数组所有值当作一个参数传递,然后函数内解构
  local -a local_array
  read -r -a local_array <<<"$2"

  local flag=0
  for i in "${local_array[@]}"; do
    [ "$i" == "$value" ] && flag=1
  done
  echo $flag
}

docker_cmd_check_and_install() {
  # 检查docker命令是否可用
  local d_version
  d_version=$(docker -v 2>/dev/null | grep "Docker version")
  if [ -n "$d_version" ]; then
    # 当前安装的docker版本
    CUR_DOCKER_VERSION=$(echo "$d_version" | tr -s ' ' | awk -F ' ' '{print $3}')
    return
  fi

  normal_echo "未安装docker环境,开始安装..."
  local yum_search yum_result has_docker_ce docker_type
  yum_search=$(yum search docker 2>/dev/null | grep "^docker")
  has_docker_ce=$(echo "$yum_search" | grep docker-ce)
  # 若找到docker,但未找到docker-ce则直接安装 docker
  if [[ -n "$yum_search" && -z "$has_docker_ce" ]]; then
    docker_type=docker
    echo "安装$docker_type"
    yum_result=$(yum -y install $docker_type 1>/dev/null)
  else
    local docker_repo=/etc/yum.repos.d/docker-ce.repo
    if [ ! -f $docker_repo ]; then
      echo "下载docker的阿里yum源"
      local ali_repo=https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
      local wget_cmd="wget -q --timeout=30 --tries=3 --waitretry=5 -O $docker_repo $ali_repo"
      if ! eval "$wget_cmd"; then
        error_echo_exit "docker的yum源下载失败,请手动处理: \n $wget_cmd"
      fi
    fi

    docker_type=docker-ce
    echo "安装$docker_type"
    # 目前是安装最新版本(指定版本如: docker-ce-18.09.9-3.el7)
    # yum list docker-ce --showduplicates | sort -r | awk '/^docker-ce/ {print $2}'
    yum_result=$(yum --allowerasing -y install $docker_type 1>/dev/null)
  fi

  # 这里判断yum命令执行状态,在这之前不要有其他语句,否则 $? 会失效
  if [[ $? -ne 0 && -n "${yum_result}" ]]; then
    rm -rf "$docker_repo" >/dev/null 2>&1
    error_echo_exit "docker安装失败, 请手动安装后再执行脚本: yum -y install docker\n失败原因: ${yum_result}"
  fi

  d_version=$(docker -v 2>/dev/null | grep "Docker version")
  [ -n "$d_version" ] && CUR_DOCKER_VERSION=$(echo "$d_version" | tr -s ' ' | awk -F ' ' '{print $3}')
  normal_echo ">>>>docker命令已存在"
}

docker_service_check_and_start() {
  # 暂停下,防止过快执行
  sleep 2
  normal_echo ">>>>docker当前版本:${CUR_DOCKER_VERSION/,/}"
  # 检查docker服务是否是 active 状态
  local d_service_status
  d_service_status=$(systemctl is-active docker.service 2>/dev/null | grep '^active')
  if [ -z "$d_service_status" ]; then
    if ! systemctl reenable docker.service 2>/dev/null; then
      error_echo_exit "docker服务开机启动配置失败"
    fi
    systemctl start docker
  fi
  normal_echo ">>>>docker服务已启动"
}

docker_volume_clean() {
  # 强制删除容器未关联的卷,减少不必要的存储
  if ! docker volume prune -f >/dev/null 2>&1; then
    normal_echo "删除所有未与容器关联的docker卷失败!"
  fi
}

docker_image_contains_init() {
  normal_echo ">>>>docker初始化开始"
  local exist_containers exist_images
  exist_containers=$(docker ps -a | grep " ${IMAGE_REPOSITORY}\$")
  exist_images=$(docker images | grep "^$IMAGE_REPOSITORY")
  # 删除已有的docker镜像和容器
  if [ -n "$over_op" ]; then
    [[ -n "$exist_containers" || -n "$exist_images" ]] && tips_echo "存在镜像或容器,即将执行删除操作"

    if [ -n "$exist_containers" ]; then
      echo "$exist_containers" | cut -d ' ' -f 1 | while read -r line; do
        normal_echo "-删除已有的容器: CONTAINER=$line"
        docker rm -f "$line" >/dev/null 2>&1
      done
      docker_volume_clean
    fi
    if [ -n "$exist_images" ]; then
      echo "$exist_images" | tr -s ' ' | cut -d ' ' -f 3 | while read -r line; do
        normal_echo "-删除已有的镜像: IMAGE=$line"
        docker rmi -f "$line" >/dev/null 2>&1
      done
    fi
  else
    [[ -n "$exist_containers" || -n "$exist_images" ]] && tips_echo "存在以下镜像或容器: "
    [ -n "$exist_containers" ] && normal_echo "已有容器: " && (echo "$exist_containers" | cut -d ' ' -f 1 && echo)
    [ -n "$exist_images" ] && normal_echo "已有镜像: " && (echo "$exist_images" | tr -s ' ' | cut -d ' ' -f 3 && echo)
  fi
  normal_echo ">>>>docker初始化完成"
}

docker_dir_file_check() {
  if [ ! -d "$DOCKERFILE_DIR" ]; then
    error_echo_exit "不存在jar包构建后的目录[$DOCKERFILE_DIR]"
  fi

  if [ -z "$DIR_LIST" ]; then
    error_echo_exit "目录下没有jar包每次构建的目录 [$DOCKERFILE_DIR/version*]"
  fi
}

# 仅部署服务(构建镜像&创建容器) $1-脚本路径
deploy_version() {
  step_echo "开始镜像构建"
  # 带根目录的目录路径
  local dir_top_one=$1
  local image_repository_tag d_build_result
  image_repository_tag=${IMAGE_REPOSITORY}:v${dir_top_one#*version}

  normal_echo ">>>>>>>>>>>>构建版本: $dir_top_one"
  local docker_build_cmd="docker build -q -f $dir_top_one/Dockerfile $dir_top_one -t $image_repository_tag"
  debug_echo "$docker_build_cmd"
  # 不展示docker镜像构建过程
  d_build_result=$($docker_build_cmd | grep "^sha256:")
  if [ -z "$d_build_result" ]; then
    error_echo_exit ">>>>>>>>>>>>镜像构建失败<<<<<<<<<<<\n请手动构建:$docker_build_cmd"
  fi

  # 再次确认,防止镜像构建有问题
  local image_id query_result
  image_id=$(echo "${d_build_result}" | awk -F ':' '{print substr($2,0,12)}')
  query_result=$(docker images | grep "$image_id" | grep "^${image_repository_tag%:*}")
  if [ -z "$query_result" ]; then
    docker rmi -f "$image_id" >/dev/null 2>&1
    error_echo_exit ">>>>>>>>>>>>镜像构建错误<<<<<<<<<<<"
  fi

  echo -e "docker镜像: IMAGE_ID=${image_id}\t\c" && tips_echo "已创建"
  step_echo "镜像构建结束"

  # 创建启动容器
  start_container "${image_repository_tag}"
}

start_container() {
  step_echo "创建启动容器"
  local image_repository_tag=$1
  if [ -z "$image_repository_tag" ]; then
    error_echo_exit "start_container方法需要1个参数. 即镜像的TAG, 格式如: name:version_20231120141345_*"
  fi

  # 测试端口 7070:8080 线上 8080:8080
  # 容器内端口暂且都设置为8080,后续看是否更改
  local inner_port=8080
  if [ -z "$CUR_ENV" ]; then
    inner_port=8080
  fi

  normal_echo "容器日志挂载路径: $LOGS_DIR"
  local port_map=${OUT_PORT}:${inner_port}
  local container_name=${image_repository_tag%:*}
  local docker_run_cmd="docker run -d -p $port_map -v $LOGS_DIR:/root --name $container_name $image_repository_tag"
  debug_echo "$docker_run_cmd"

  local run_result container_id query_result
  run_result=$($docker_run_cmd 2>/dev/null)
  if [ -n "$run_result" ]; then
    container_id=${run_result:0:12}
  else
    if [ -n "$over_op" ]; then
      error_echo_exit ">>>>>>>>>>>>容器创建失败<<<<<<<<<<<\n请手动构建:$docker_run_cmd"
    fi

    debug_echo "容器已存在,未重新创建容器"
    query_result=$(docker ps -a | grep "${image_repository_tag}")
    if [ -n "$query_result" ]; then
      container_id=${query_result%% *}
    fi
  fi

  # 再次确认下容器是否创建成功
  query_result=$(docker ps -a | grep "$container_id" | grep "${image_repository_tag}")
  debug_echo "$query_result"
  if [ -z "$query_result" ]; then
    error_echo_exit ">>>>>>>>>>>>容器创建错误<<<<<<<<<<<\n\
    未找到对应的容器: docker ps -a| grep ${container_id} | grep ${image_repository_tag}"
  elif [[ ! "$query_result" =~ "Created" && ! $query_result =~ " ago"[[:space:]]+"Up" ]]; then
    error_echo_exit ">>>>>>>>>>>>容器启动异常<<<<<<<<<<<\n请手动启动: docker start $container_id"
  fi
  echo -e "docker容器:CONTAINER_ID=${container_id}\t\c" && tips_echo "已启动"
  step_echo "容器创建结束"
}

# 一键部署服务
one_key_deploy() {
  env_echo "一键部署"
  docker_dir_file_check

  # 是否将已有容器删除
  local over_op="Y"
  step_echo "开始检查docker环境"
  docker_cmd_check_and_install
  docker_service_check_and_start
  docker_image_contains_init
  step_echo "结束docker环境检查"

  local dir_top_one
  dir_top_one=$(echo "$DIR_LIST" | awk -F ' ' '{print $NF}' | head -1)
  deploy_version "$dir_top_one"

  application_check
  tips_echo "#########################################[部署完成]#######################################"
  echo
}

# 容器内应用检查(直接调用应用接口).
application_check() {
  step_echo "检查容器状态"
  local response
  local url=localhost:${OUT_PORT}/api/get/env

  for i in {1..120}; do
    response=$(curl -s $url)
    [ -n "$response" ] && break
    sleep 1
    echo "waiting for the app to start... ${i}s"
  done

  response=$(curl -s $url)
  debug_echo "请求结果: $response"
  if [ -z "$response" ]; then
    error_echo_exit "服务不可用,结果为空. 请手动检查: curl -s $url && echo"
  fi

  local is_json_result match_result
  is_json_result=$(echo "$response" | tr '\n' ' ' | sed 's/ //g' | grep "^\{\".*env.*\":\".*\"\}$")
  debug_echo "接口返回: $is_json_result"
  if [ -z "$is_json_result" ]; then
    error_echo_exit "接口返回错误,不是指定的JSON格式. 请手动检查: curl -s $url && echo"
  fi

  # 将 ","  作为分隔符,将其转换为换行符。然后将行按 ":" 分割判断第一列若包含 env 字符串,则打印第二段的字符串
  match_result=$(echo "$response" | awk -F '","' -v OFS="\n" '{$1=$1}1' | awk -F '":"' '$1 ~ /env/ {print $2}')
  debug_echo "匹配结果: $match_result"
  if [ -z "$match_result" ]; then
    error_echo_exit "接口返回JSON解析错误,需修改校验规则"
  fi

  local actual_result=${match_result%%\"*}
  local service_env
  [ -n "$CUR_ENV" ] && service_env="daily" || service_env="product"
  if [ "$actual_result" == "$service_env" ]; then
    echo -e "\033[32m服务可用,当前部署与实际一致\t\c\033[0m" && echo -e "\033[33m$ENV_STR\033[0m"
  else
    error_echo_exit "服务可用,部署错误. 部署环境:$ENV_STR\t实际为:$service_env"
  fi
  step_echo "状态检查结束"
}

# docker检查,若无docker则直接退出
docker_cmd_check_not_exit() {
  local d_version
  d_version=$(docker -v 2>/dev/null | grep "Docker version")
  if [ -z "$d_version" ]; then
    error_echo_exit "无docker环境, 请先执行一键部署脚本!"
  fi
}

docker_container_delete() {
  local exist_containers
  exist_containers=$(docker ps -a | grep " ${IMAGE_REPOSITORY}\$")
  if [ -z "$exist_containers" ]; then
    normal_echo "当前未部署相关容器->$IMAGE_REPOSITORY"
    return
  fi

  normal_echo "已有容器如下: "
  # 一行写法。 awk 若引用shell变量 system("docker rm -f "'"$old_id"'" >/dev/null 2>&1")
  # docker ps -a | sed -n '1d' | tr -s ' ' | awk -F '[ :]' \
  #     '{printf("ID=%s, 版本=%s\t", $1, $3); cmd="docker rm -f"$1">/dev/null 2>&1"; result=system(cmd); printf("%s\n", result ? "已删除": "未删除")}'

  local id_version_row old_version
  id_version_row=$(echo "$exist_containers" | tr -s ' ' | cut -d ' ' -f 1,2)
  while read -r line; do
    local old_id=${line%% *}
    old_version=$(echo "$line" | cut -d ':' -f 2)
    echo -e "ID=${old_id}, 版本=${old_version}\c"
    if docker rm -f "${old_id}" >/dev/null 2>&1; then
      DEL_VERSION_ARR[${#DEL_VERSION_ARR[@]}]=${old_version#*v_}
      tips_echo "\t已删除"
    else
      tips_echo "\t未删除"
    fi
  done <<ID_VERSION_ROW_INPUT
    $id_version_row
ID_VERSION_ROW_INPUT
  docker_volume_clean
}

docker_images_delete() {
  local exist_images
  exist_images=$(docker images | grep "^${IMAGE_REPOSITORY}")
  if [ -z "$exist_images" ]; then
    normal_echo "当前无相关镜像->$IMAGE_REPOSITORY"
    return
  fi

  # 一行写法。 awk 若引用shell变量 system("docker rm -f "'"$old_id"'" >/dev/null 2>&1")
  # docker images | sed -n '1d' | tr -s ' ' | awk -F ' ' \
  #     '{printf("ID=%s, 版本=%s\t", $3, $2); cmd="docker rm -f"$3">/dev/null 2>&1"; result=system(cmd); printf("%s\n", result ? "已删除": "未删除")}'
  normal_echo "已有镜像如下: "
  echo "$exist_images" | tr -s ' ' | while read -r line; do
    old_id=$(echo "$line" | cut -d ' ' -f 3)
    old_version=$(echo "$line" | cut -d ' ' -f 2)
    echo -e "ID=${old_id}, 版本=${old_version}\c"
    if docker rmi -f "$old_id" >/dev/null 2>&1; then
      tips_echo "\t已删除"
    else
      tips_echo "\t未删除"
    fi
  done
}

rollback_version() {
  echo
  local dir_top_arr
  # 带根目录的目录路径
  read -r -a dir_top_arr <<<"$(echo "$DIR_LIST" | awk -F ' ' '{print $NF}' | head -6 | tr '\n' ' ')"
  local dir_top_arr_len=${#dir_top_arr[*]}
  # 不能明确是数字时,最好不要这样写 [ $DEBUG_SWITCH ],一定不要加双引号
  if [ "$DEBUG_SWITCH" == 1 ]; then
    debug_echo "当前可部署的版本列表TOP${dir_top_arr_len}:"
    for ((i = 0; i < "$dir_top_arr_len"; i++)); do
      local element=${dir_top_arr[$i]}
      debug_echo "${i}.${element##*/}"
    done
  fi

  # 是否有构建版本在删除的历史部署版本中标识. 默认0为没有
  local has_version_in_del=0
  for ((i = 0; i < "$dir_top_arr_len"; i++)); do
    local element=${dir_top_arr[$i]}
    local build_version=${element#*version_}
    debug_echo "${build_version}==${DEL_VERSION_ARR[*]}"

    # 如果构建的版本在删除的容器版本中,则部署上一次的版本
    if [ "$(inArray "$build_version" "${DEL_VERSION_ARR[*]}")" == 1 ]; then
      # 部署上个版本
      local prev_version=$((i + 1))
      debug_echo "$prev_version"
      if [ $prev_version -ge "$dir_top_arr_len" ]; then
        tips_echo ">>>[$build_version]没有上一个版本"
        break
      fi

      local cur_version=${dir_top_arr[$prev_version]}
      tips_echo "回滚版本>>>$cur_version"
      # 开始部署
      has_version_in_del=1
      deploy_version "$cur_version"
    fi
  done

  # 构建的版本没在历史版本中
  if [ $has_version_in_del == 0 ]; then
    select_options "回滚" "${dir_top_arr[*]}" deploy_version
  fi
  echo
}

# 列表选项逻辑
select_options() {
  # 提示信息
  local show_str="$1"
  # 执行方法
  local exec_method=$3
  # 选项前缀
  local option_prefix="$4"

  [ -z "$show_str" ] && error_echo_exit "select_options方法第1个参数是提示词, 不能为空"
  [ -z "$exec_method" ] && error_echo_exit "select_options方法第3个参数是方法名, 不能为空"
  # 数组当参数传进来时,所有元素是一个参数,元素间空格隔开. 若要获取数组长度需要转换为数组结构
  local array_str
  read -r -a array_str <<<"$2"
  local array_len=${#array_str[*]}

  normal_echo "当前可${show_str}的版本列表TOP${array_len}:"
  if [ "$array_len" == 0 ]; then
    normal_echo "无可选项"
    return
  fi

  for ((i = 0; i < "$array_len"; i++)); do
    local element=${array_str[$i]}
    normal_echo "${i}.${element##*/}"
  done

  # 选项.    # 序列号.从0开始需要减1
  local option sequence
  local index_no=$((array_len - 1))
  sequence=$(eval "echo {0..$index_no}")
  while true; do
    tips_echo "请输入需${show_str}的版本编号(enter键退出):\c" && read -r -n 1 option
    if [ -z "$option" ]; then
      error_echo_exit "未选择${show_str}版本,即将退出!"
    fi

    # 编号在列表中
    if [ "$(inArray "$option" "${sequence[*]}")" == 1 ]; then
      local cur_option=${array_str[$option]}
      echo
      $exec_method "${option_prefix}${cur_option}"
      break
    else
      echo -e "\t\033[31m错误\003[0m\n"
    fi
  done
}

# 一键版本回滚
one_key_rollback() {
  env_echo "版本回滚"
  step_echo "查询当前docker部署"
  docker_cmd_check_not_exit
  # 这个变量在函数调用后, 外部能访问到
  local DEL_VERSION_ARR=()
  docker_container_delete
  docker_images_delete
  step_echo "结束当前docker查询"

  docker_dir_file_check
  step_echo "【开始版本回滚】"
  rollback_version
  step_echo "【版本回滚结束】"
}

# 选择版本部署/回滚
select_version_deploy() {
  env_echo "选择部署/回滚"
  docker_dir_file_check
  docker_cmd_check_not_exit
  # 是否将已有容器删除. over_op 值为空->否 不为空->是
  local over_op="Y"
  docker_image_contains_init

  local dir_top_arr
  read -r -a dir_top_arr <<<"$(echo "$DIR_LIST" | awk -F ' ' '{print $NF}' | head -8 | tr '\n' ' ')"
  select_options "部署/回滚" "${dir_top_arr[*]}" deploy_version
}

# 上线应用
online() {
  step_echo "开始上线应用"
  local exist_containers
  exist_containers=$(docker ps -a --format "table {{.ID}} {{.Image}} {{.Status} {{.Names}}" \  |
    grep " ${IMAGE_REPOSITORY}\$" | grep -iE "\s(exited|paused|created)")
  debug_echo "$exist_containers"
  # 若存在[已创建|已暂停|已退出]的容器则重启容器
  if [ -n "$exist_containers" ]; then
    normal_echo "已有容器如下: "
    echo "$exist_containers" | tr -s ' ' \  |
      awk -F '[ :]' '{printf("ID=%s,版本=%s\t", $1, $3);cmd="docker restart "$1">/dev/null 2>&1"; result=system(cmd); printf("%s\n", result==0? "已上线": "上线失败")}'
  else
    normal_echo "当前无[已创建|已暂停|已退出]状态及name=$IMAGE_REPOSITORY 相关容器"
  fi
  step_echo "上线应用结束"
}

# 下线应用
offline() {
  step_echo "开始下线应用"
  local exist_containers
  exist_containers=$(docker ps -a --format "table {{.ID}} {{.Image}} {{.Status} {{.Names}}" \  |
    grep " ${IMAGE_REPOSITORY}\$" | grep -iE "(\srunning|up\s.*\s)")
  debug_echo "$exist_containers"
  # 若存在[运行中]的容器则执行停止容器
  if [ -n "$exist_containers" ]; then
    normal_echo "已有容器如下: "
    # awk 一句写法,通过system函数执行命令
    echo "$exist_containers" | tr -s ' ' \  |
      awk -F '[ :]' '{printf("ID=%s,版本=%s\t", $1, $3);cmd="docker stop "$1">/dev/null 2>&1"; result=system(cmd); printf("%s\n", result==0? "已下线": "下线失败")}'
  else
    normal_echo "当前无[运行中]状态及name=$IMAGE_REPOSITORY 相关容器"
  fi

  step_echo "下线应用结束"
}

start() {
  step_echo "开始启动应用"
  local exist_images
  exist_images=$(docker images --format "table {{.ID}} {{.Tag}} {{.Repository}" | grep " ${IMAGE_REPOSITORY}\$")
  if [ -z "$exist_images" ]; then
    normal_echo "当前无相关镜像->$IMAGE_REPOSITORY"
    step_echo "应用启动结束"
    return
  fi

  local image_version_arr=()
  read -r -a image_version_arr <<<"$(echo "$exist_images" | cut -d ' ' -f2)"
  # 若只有一个镜像则直接启动容器, 否则需手动选择镜像
  if [ "${#image_version_arr[*]}" == 1 ]; then
    start_container "${IMAGE_REPOSITORY}:${image_version_arr[0]}"
  else
    select_options "启动部署镜像" "${image_version_arr[*]}" start_container "${IMAGE_REPOSITORY}:"
  fi
  step_echo "应用启动结束"
}

delete() {
  step_echo "开始删除应用"
  # 定义局部变量,防止容器删除操作后,其内部的该全局变量被外部访问
  local DEL_VERSION_ARR=()
  docker_container_delete
  docker_images_delete
  step_echo "删除应用结束"
}

check_pipe_exit_code() {
  local pipe_exit_code=${PIPESTATUS[0]}
  if test "$pipe_exit_code" -ne 0; then
    exit "$pipe_exit_code"
  fi
}

main() {
  local now
  now=$(date +"%Y-%m-%d %H:%M:%S")
  tips_echo "+++++++++++++++++++++++++++[$now]${ENV_STR}+++++++++++++++++++++++++++"

  case "${ACTION_ARG}" in
  deploy)
    one_key_deploy
    ;;
  rollback)
    one_key_rollback
    ;;
  online)
    online
    ;;
  offline)
    offline
    ;;
  start)
    start
    ;;
  delete)
    delete
    ;;
  select)
    select_version_deploy
    ;;
  *)
    usage
    ;;
  esac
}

main | tee -a appctl.log
check_pipe_exit_code

实现效果

当执行上传脚本命令不携带任何参数时,帮助信息展示

当执行上传脚本命令携带环境命令参数时,自动上传数据至服务器目录

当执行上传脚本选择本地已构建版本上传时

当执行上传脚本并携带继续部署参数时,即可直接部署远程应用

后续扩展

从上述脚本实现来看,其部署实现基本已经到达了需求,能支持快速的部署检查应用,但对于版本感知(可能仅适用于个人单分支的开发)还是比较弱、通用性(脚本目前只在自己电脑上测试过)可能还差些。以下是对后续扩展的一些思路(仅想法,因为感觉实现成本比较大😄):

加入git相关操作,实现指定分支(或增量代码)部署

对于增量代码部署,可通过判断当前分支版本 git brach | grep '\* ' , 然后 checkout 新分支合并。若合并失败(判断合并失败关键字), 则退出脚本告知需手动合并; 否则合并成功直接部署. 又或者是通过 git rev-parse <branch> 判断本地和远程分支自动后续处理。

# 代码下拉和编译
git_pull_npm_build(){
  local cur_branch cur_branch_remote local_commit remote_commit pull_result
  if ! git fetch >/dev/null 2>&1; then
    error_echo_exit "git fetch 执行失败,请手动检查"
  fi
  debug_echo "从远程仓库获取最新的提交和分支信息成功"

  cur_branch=$(git branch | grep "\*" | cut -d ' ' -f2)
  cur_branch_remote=$(git rev-parse --abbrev-ref --symbolic-full-name "@{u}")
  [ -z "$cur_branch_remote" ] && error_echo_exit "获取当前份值: $cur_branch 的远程份值失败"
  # 检查本地分支和远程分支的最新提交
  local_commit=$(git rev-parse "$cur_branch")
  remote_commit=$(git rev-parse "$cur_branch_remote")
  if [ "$local_commit" != "$remote_commit" ]; then 
    normal_echo "当前分支:$cur_branch 不是最新代码"
    # 需要处理git pull失败的问题
    pull_result=$(git pull "${cur_branch_remote%%/*}" "$cur_branch" 1>/dev/null) 
    if [[ "$pull_result" =~ "Automatic merge failed" ]]; then
      error_echo_exit "合并冲突,需手动解决"
    fi
  else
    normal_echo "当前分支:$cur_branch 已是最新代码"
  fi

  local build_root_dir="./dist"
  if [ -d $build_root_dir ]; then
    local confirm
    tips_echo "检查到已有构建产物 ${build_root_dir}, 是否删除重新构建(y/n):\c" && read -r -n 1 confirm
    if [[ "$confirm" == "" || "$confirm" == y* ]]; then
    rm -rf "$build_root_dir" >/dev/null 2>&1
    [[ "$confirm" == y*  ]] && echo -e "\n删除成功!" || echo "删除成功"
    if ! npm run build; then
      error_echo_exit "npm run build 构建失败,请手动处理"
    else
      normal_echo "构建成功"
    fi
  fi
}

加入mysql相关操作,实现mysql初始化配置

mysql 放在服务器下,不通过docker部署。通过 yum 安装下载,然后配置mysql信息,并初始化项目的库以及表信息等。

前端静态文件上传部署(nginx实现)

如同后端jar文件构建打包逻辑:先本地编译构建前端代码产物,服务器下载安装并配置 nginx。然后将前端构建后的静态文件放入nginx的 html 目录,并启动nginx服务。

若服务器IP不是直连,有中间服务器,脚本则无法进行上传部署

中间服务器(也就是跳板机), 单个跳板机可以通过ssh -o ProxyJump=jumpuser@jumphost来实现,其中跳板机到实际服务器间是通过密钥实现ssh免密登录,则需使用 -i 参数指定服务器所在的密钥文件即可。若是多跳板机ssh貌似不支持参数实现,但可以通过设置ssh的 config 文件来实现,以下为多跳的ssh 配置: (对于配置的密钥文件则需先从服务器下载到本地)

# 第一个跳板机
Host jumphost1
	HostName jumphost1.example.com
	User user1
	IdentityFile ~/.ssh/jumphost1_id_rsa

# 第二个跳板机
Host jumphost2
	HostName jumphost2.example.com
	User user2
	IdentityFile ~/.ssh/jumphost2_id_rsa
	ProxyJump jumphost1

# 目标主机
Host targethost
	HostName targethost.example.com
	User targetuser
	IdentityFile ~/.ssh/targetuser_id_rsa
	ProxyJump jumphost2
posted @ 2024-01-01 23:15  zeotoone  阅读(197)  评论(0编辑  收藏  举报