Shell一键部署方案及实现
前言
当我们在本地电脑上写了个后端服务,想将其部署到xx云上时,可选的部署方式至少有5种,如下:
- 原始部署方式
- docker部署方式
- 宝塔部署方式(免费,方便)
- Jenkins自动化部署方式
- 容器平台部署方式(花钱)
...
在上述部署方式中,原始部署无疑最麻烦的方式,主要是依赖安装、环境配置都需要一步步来操作,而且遇到多台服务器时,还得考虑如何保证环境的一致性。docker部署能省去一部分依赖、配置,从而保证环境统一的问题。后三种可能更偏向于 企业级应用部署,当然也适用于个人,不过最终还得看个人需求选择。
针对我个人来讲,想要的部署功能是这样的:
- 不想直接上传源码至服务器
- 最好是本地执行直接服务器部署
- 要是能支持版本管理就更好了. 等等
那么这些部署功能需求如何实现呢?上述方式能否满足呢?
显然答案是否定的。仅使用上述罗列的部署方式,对于自定义的部署功能来说,可能并不能直接完全满足需求,还需进行改造才能符合。那么接下来看看如何从需求到实现吧!
明确需求
在实现业务功能前,我们应该先来对需求进行分析,然后再进行实现。这样才能更好全面的思考以明确要实现怎么样的业务逻辑及功能。
部署流程
首先来看下部署的流程(也就是业务逻辑)
功能需求
- 需支持指定不同的环境(测试、线上等)
- 需支持指定不同的服务器IP,且IP/密码可记忆
- 需支持构建版本管理, 能指定选择版本
- 需支持自动构建打包、登录、上传功能
- 需支持自动依赖安装和自动部署功能
- 需支持一键部署和单独执行部署
- 需支持部署版本回滚功能
- 需支持指定不同版本部署/回滚
- 需支持应用启动、重启、暂停、删除应用功能
非功能需求
- 界面美观
- 提示友好
- 健壮性强
- 维护性好
方案设计
由于本地系统环境为 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