Shell
本文范围
这篇讲的是把 Shell 从“临时敲命令”变成“可重复、可验证、可回滚的运维脚本”。重点是部署、备份、清理、巡检、定时任务和批量执行里的脚本组织方式,不把 Bash 语法大全从头背一遍。
本文会覆盖:
- Bash 和
/bin/sh的边界,脚本解释器、依赖命令和运行环境检查。 - 参数、变量、条件、循环、函数、退出码、错误处理和日志审计。
- 幂等、锁、临时目录、原子切换、路径保护和 dry-run。
- 部署、备份、清理、巡检、启动停止、crontab 和 systemd timer 模板。
- 中国大陆公网、企业内网、离线环境下的工具依赖闭环。
- 常见脚本失败现象、定位命令和生产化清单。
本文不讲复杂发布平台、Ansible Playbook、Jenkins 流水线语法、K8S Operator,也不建议用 Shell 承担复杂业务编排。Shell 适合做服务器本地动作和轻量自动化,一旦跨多系统、有复杂状态或多人并发发布,就要把边界收紧。
前置条件
建议在一台 Linux 测试机上练习,系统可以是 Ubuntu/Debian、RHEL/Rocky/Alma/CentOS Stream 或企业发行版。你需要有当前用户可写的练习目录,以及必要时能 sudo 查看服务状态。不要直接在生产机上验证会删除、覆盖或重启服务的脚本。
先确认运行时依赖:
bash --version
command -v bash tar gzip find xargs awk sed grep rsync ssh scp flock timeout sha256sum systemctl如果缺少命令,在线环境走受控软件源安装;企业内网走内部 apt/yum/dnf 源;离线环境提前准备 RPM、DEB、二进制包或系统 ISO,并附带 sha256 校验。脚本里的服务名、路径、端口和用户都用变量传入,不要写死真实密码、token、私钥或客户地址。
本文怎么用
很多人刚开始写 Shell,都是把平时敲过的命令复制到一个 .sh 文件里。这样确实能跑,但一到生产环境就容易出问题:路径不对、变量为空、定时任务没日志、上一次没跑完下一次又开始、发布失败后不知道回滚到哪里。
所以这篇不按“语法大全”的方式讲。我们就按一台服务器上的真实运维动作来走:先确认解释器和依赖,再写第一份脚本模板,然后一步步加参数、日志、错误处理、幂等锁,最后落到部署、备份、清理、巡检和定时任务。
正文默认使用 Bash。生产脚本建议显式写 #!/usr/bin/env bash 或固定写 #!/bin/bash,不要默认 /bin/sh 就等于 Bash。很多服务器上的 /bin/sh 可能是 dash、ash 或其他 POSIX shell,数组、[[ ]]、pipefail、BASH_SOURCE 等 Bash 特性不一定可用。
先在机器上看一下当前环境:
bash --version
command -v bash
command -v flock || echo "flock not found"
command -v systemctl || echo "systemd not found"执行后一般会看到 Bash 版本、Bash 所在路径,以及 flock、systemctl 是否存在。这里要注意,command -v 没输出就表示命令找不到,不要等脚本跑到一半才发现缺工具。
如果只是写兼容 POSIX 的脚本,可以用 #!/bin/sh。但本文后面会用 Bash 数组、[[ ]]、pipefail,所以示例统一按 Bash 写。原则很简单:脚本需要什么解释器,就把解释器写清楚。
中国大陆、内网和离线环境
Shell 脚本最容易给人一种错觉:它只是文本文件,所以没有依赖。真实交付时不是这样。脚本里只要调用了 tar、rsync、ssh、mysql、docker、systemctl、flock、timeout、sha256sum,这些命令就都是依赖。
在公网机器上,缺什么工具还可以临时装。到了中国大陆受限网络、企业内网、离线机房,现场一般不能边查边装。所以第一步不要写部署逻辑,先写依赖检查。
cat > requirements.commands <<'EOF'
bash
tar
gzip
find
xargs
rsync
ssh
scp
flock
timeout
sha256sum
systemctl
EOF
while IFS= read -r cmd; do
command -v "$cmd" >/dev/null 2>&1 || echo "MISSING $cmd"
done < requirements.commands这段命令会生成一个依赖清单,然后逐个检查。正常情况下没有输出;如果看到 MISSING rsync,就说明当前服务器没有 rsync,后面凡是依赖文件同步的脚本都会失败。
这里要注意两点。
- 不要只在自己的开发机检查,要在目标服务器上检查。
- 不要把缺失工具写成警告后继续执行,部署脚本应该在前置条件不满足时直接退出。
在线环境可以通过系统包管理器安装工具;内网环境建议走企业 apt/yum/dnf 源;离线环境要提前准备 RPM、DEB、二进制包或系统 ISO,并附带校验文件:
sha256sum shell-tools.tar.gz > shell-tools.tar.gz.sha256
sha256sum -c shell-tools.tar.gz.sha256执行校验时,如果看到 OK,说明文件没有被改坏;如果校验失败,不要继续解压安装。现场经常会遇到离线包传输不完整、重复覆盖、拿错版本的问题,校验能提前挡住一批低级事故。
ShellCheck、shfmt 这类工具是质量门禁,不应该成为生产脚本运行时依赖。可以在开发机、CI 或内网代码检查环境使用,生产服务器只保留脚本真正需要的运行依赖。
不要在生产脚本里写这种安装方式:
curl -fsSL https://example.com/install.sh | bash这种写法看起来省事,但在生产环境问题很大:内网不可达、地址变化、脚本被篡改、审计无法追溯。更稳妥的做法是:下载到内部制品库,固定版本,做校验,审批后再执行。
第一份可靠脚本模板
现在先写第一份脚本。它暂时不做部署,只做几件基础动作:设置严格模式、准备日志目录、检查依赖、打印开始信息。把这个模板跑通,后面的部署、备份、清理都可以在它上面加。
P1 图解:可靠 Shell 脚本执行模型
可靠脚本不是把命令复制进 .sh 文件,而是把输入、检查、执行保护、验证、失败处理和审计放进同一个运行模型。部署、备份、清理、巡检脚本可以做不同事情,但骨架应该一致。
这张图里的核心是“动作之后必须验证”。很多脚本失败不是因为命令不会写,而是执行完没有判断结果:包解压了但服务没切到新版本,备份生成了但文件为空,清理命令跑了但路径变量为空,巡检输出了 WARN 却仍然返回 0。
写完脚本后,至少做这几类验证:
bash -n ops-template.sh
DRY_RUN=1 ./ops-template.sh
flock -n /tmp/demo-app.lock ./ops-template.sh
echo $?
journalctl -u demo-app -n 80 --no-pager
crontab -l
systemctl list-timers --all如果团队使用 ShellCheck 和 shfmt,也建议把它们放进本地或 CI 检查:
shellcheck ops-template.sh
shfmt -w ops-template.sh常见坑是把 set -e 当成绝对保险;在 cron 里依赖交互式 shell 的 PATH;没有锁导致两个发布脚本同时跑;rm -rf "$TARGET_DIR" 的目标没有做空值、根目录和白名单保护;失败时只打印一行 failed,没有主机、路径、命令、退出码和日志位置。
生产约束是:脚本必须有 DRY_RUN 或等价预演能力;高危动作要有白名单和显式确认;所有远程批量操作要记录每台主机结果;定时任务要能从日志和退出码判断成功失败;失败要返回非 0,让流水线、systemd timer 或 cron 告警能够接住。
新建文件:
cat > ops-template.sh <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
APP_NAME="demo-app"
BASE_DIR="${BASE_DIR:-/opt/demo-app}"
LOG_DIR="${LOG_DIR:-/var/log/ops}"
DRY_RUN="${DRY_RUN:-0}"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/${APP_NAME}-$(date +%F).log"
log() {
local level="$1"
shift
printf '%s [%s] %s\n' "$(date '+%F %T')" "$level" "$*" | tee -a "$LOG_FILE" >&2
}
die() {
log ERROR "$*"
exit 1
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "missing command: $1"
}
run() {
log INFO "RUN $*"
if [[ "$DRY_RUN" == "1" ]]; then
return 0
fi
"$@"
}
on_error() {
local rc="$?"
local line="$1"
log ERROR "failed at line=${line}, rc=${rc}, cmd=${BASH_COMMAND}"
exit "$rc"
}
trap 'on_error $LINENO' ERR
main() {
need_cmd tar
need_cmd systemctl
log INFO "start ${APP_NAME}"
log INFO "base dir: ${BASE_DIR}"
}
main "$@"
EOF加执行权限并运行:
chmod +x ops-template.sh
./ops-template.sh正常情况下,你应该能看到类似这样的输出:
2026-07-09 10:30:00 [INFO] start demo-app
2026-07-09 10:30:00 [INFO] base dir: /opt/demo-app同时会生成日志文件:
ls -lh /var/log/ops/
tail -50 /var/log/ops/demo-app-$(date +%F).log这份模板解决几个问题:
set -u让未定义变量尽早暴露。pipefail避免管道前半段失败被最后一个命令掩盖。trap ERR打印失败行号、退出码和正在执行的命令。run统一执行命令,支持DRY_RUN=1预演。- 所有日志同时进入终端和文件,方便现场排障。
这里要注意,set -e 不是万能保险。if 条件、while 条件、&&、||、部分管道和子 shell 场景都有例外。关键业务步骤不要只靠严格模式,要显式判断。比如解压包、重启服务、健康检查,这些地方失败了就要打印清楚原因。
变量和参数
脚本一旦开始给别人用,就不要把路径、环境、版本写死。变量和参数就是为了把“脚本逻辑”和“现场输入”分开。
变量赋值不要加空格:
APP_NAME="demo-app"
VERSION="1.2.3"如果写成 APP_NAME = "demo-app",Shell 会把 APP_NAME 当成命令执行,然后报 command not found。
引用变量时默认加双引号:
rm -rf "$TARGET_DIR"
cp "$PACKAGE_FILE" "$RELEASE_DIR/"不加引号会触发单词拆分和通配符展开。一般测试目录都很简单,所以看不出问题;一到现场,目录里有空格、变量为空、文件名带星号,事故就来了。
下面写一个带参数的部署入口。先不用管里面是否真的部署,重点看参数怎么接、怎么校验。
#!/usr/bin/env bash
set -Eeuo pipefail
ENV=""
VERSION=""
DRY_RUN=0
FORCE=0
usage() {
cat <<'EOF'
Usage:
deploy.sh -e <dev|test|prod> -v <version> [-n] [-f]
Options:
-e Environment.
-v Release version.
-n Dry run.
-f Force deploy.
-h Help.
EOF
}
while getopts ":e:v:nfh" opt; do
case "$opt" in
e) ENV="$OPTARG" ;;
v) VERSION="$OPTARG" ;;
n) DRY_RUN=1 ;;
f) FORCE=1 ;;
h) usage; exit 0 ;;
:) echo "missing argument: -$OPTARG" >&2; usage; exit 2 ;;
\?) echo "unknown option: -$OPTARG" >&2; usage; exit 2 ;;
esac
done
shift $((OPTIND - 1))
[[ -n "$ENV" ]] || { echo "ENV is required" >&2; exit 2; }
[[ -n "$VERSION" ]] || { echo "VERSION is required" >&2; exit 2; }
case "$ENV" in
dev|test|prod) ;;
*) echo "invalid env: $ENV" >&2; exit 2 ;;
esac
echo "env=$ENV version=$VERSION dry_run=$DRY_RUN force=$FORCE"保存为 parse-args.sh 后执行:
chmod +x parse-args.sh
./parse-args.sh -e test -v 1.2.3
./parse-args.sh -e prod -v 1.2.3 -n第一条会输出 env=test version=1.2.3 dry_run=0 force=0。第二条里的 -n 表示 dry-run,适合发布前先看脚本准备做什么。
如果少传版本:
./parse-args.sh -e prod应该看到 VERSION is required,并且脚本退出。这里要注意,参数错误要尽早失败,不要跑到一半才发现版本为空。
如果参数只是简单位置参数,也要先校验:
PACKAGE_FILE="${1:-}"
[[ -n "$PACKAGE_FILE" ]] || { echo "package file is required" >&2; exit 2; }
[[ -s "$PACKAGE_FILE" ]] || { echo "package file not found or empty: $PACKAGE_FILE" >&2; exit 1; }多个剩余参数用 "$@",不要用 $*。比如一次只发布几个模块:
for module in "$@"; do
echo "deploy module: $module"
done执行:
./deploy.sh gateway api worker输出里应该依次看到 gateway、api、worker。如果用 $*,多个参数容易被合成一个字符串,后面循环就不准了。
逗号分隔的模块列表可以这样解析:
DEPLOY_MODULES="${DEPLOY_MODULES:-gateway,api,worker}"
IFS=',' read -r -a MODULE_LIST <<< "$DEPLOY_MODULES"
for module in "${MODULE_LIST[@]}"; do
echo "module=$module"
done这个写法是 Bash 数组能力,不能放在 #!/bin/sh 脚本里。一般部署脚本里会把 DEPLOY_MODULES 放到配置文件或环境变量里,现场需要发哪些模块就改这一项。
条件判断
条件判断不要理解成别的语言里的 true/false。Shell 判断本质上是在判断命令退出码:退出码为 0 表示成功,非 0 表示失败。
最常见的是判断文件和目录。比如发布包必须存在,而且不能为空:
[[ -f "$PACKAGE_FILE" ]] || die "not a regular file: $PACKAGE_FILE"
[[ -s "$PACKAGE_FILE" ]] || die "empty file: $PACKAGE_FILE"
[[ -d "$RELEASE_DIR" ]] || mkdir -p "$RELEASE_DIR"
[[ -x "$SCRIPT" ]] || chmod +x "$SCRIPT"执行后如果文件不存在,会直接输出错误并退出。这里要注意,-f 只判断普通文件,-s 还会判断文件大小是否大于 0。发布包如果是 0 字节,继续部署没有意义。
字符串判断一般用于环境、版本、开关:
[[ -n "$VERSION" ]] || die "VERSION is empty"
[[ "$ENV" == "prod" ]] && log WARN "production deploy"数字判断一般用于阈值,比如磁盘使用率:
if (( DISK_USAGE >= 85 )); then
log WARN "disk usage is high: ${DISK_USAGE}%"
fi如果 DISK_USAGE=90,脚本应该打印磁盘告警。生产脚本里遇到磁盘空间不足,通常应该停止发布,因为解包、备份、日志写入都可能失败。
多分支用 case 通常更清楚:
case "$ACTION" in
deploy) deploy ;;
rollback) rollback ;;
status) status ;;
*) die "unknown action: $ACTION" ;;
esac生产脚本里不要把判断写得过于隐蔽:
# 不推荐:失败原因不清晰
[[ -f "$file" ]] && cp "$file" "$dest"
# 推荐:失败能定位
[[ -f "$file" ]] || die "file not found: $file"
run cp "$file" "$dest"后一种写法啰嗦一点,但排障时很有价值。现场看到 file not found: app.tar.gz,就知道是包没传上去,而不是脚本莫名其妙没继续执行。
循环和批量执行
循环最常见的场景有两个:遍历模块、遍历主机。先看模块发布:
MODULES=(gateway api worker)
for module in "${MODULES[@]}"; do
log INFO "deploy $module"
done输出应该是三行:gateway、api、worker。模块名一般要和目录名、服务名保持一致,否则后面拼路径和重启服务会乱。
再看主机文件。先准备 hosts.txt:
cat > hosts.txt <<'EOF'
# app servers
app-01
app-02
app-03
EOF读取主机文件:
while IFS= read -r host || [[ -n "$host" ]]; do
[[ -z "$host" ]] && continue
[[ "$host" =~ ^# ]] && continue
echo "host=$host"
done < hosts.txt这里会跳过空行和 # 开头的注释行。while read 要加 IFS= read -r,避免反斜杠和前后空白被意外处理。|| [[ -n "$host" ]] 用来处理最后一行没有换行符的文件。
批量远程执行要有超时和结果汇总。下面这个脚本会对每台机器执行 uptime:
#!/usr/bin/env bash
set -Eeuo pipefail
HOST_FILE="${1:-hosts.txt}"
COMMAND="${2:-uptime}"
FAILED=0
while IFS= read -r host || [[ -n "$host" ]]; do
[[ -z "$host" || "$host" =~ ^# ]] && continue
echo "===== $host ====="
if timeout 15s ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" "$COMMAND"; then
echo "OK $host"
else
echo "FAILED $host" >&2
FAILED=1
fi
done < "$HOST_FILE"
exit "$FAILED"执行:
./batch-run.sh hosts.txt uptime每台机器会打印一个分隔块。成功就输出 OK 主机名,失败就输出 FAILED 主机名。这里要注意,批量脚本不要因为一台机器失败就悄悄继续到最后还返回成功。可以继续执行,但最终退出码必须反映失败。
函数组织
脚本超过一屏以后,就不要继续从上到下堆命令了。函数适合表达运维步骤,而不是炫技。一个部署脚本通常可以拆成:
check_env() { :; }
prepare_dirs() { :; }
backup_current() { :; }
upload_package() { :; }
switch_release() { :; }
restart_service() { :; }
health_check() { :; }
rollback() { :; }这里的 : 是空命令,表示先占位。等流程搭好,再一个个补实现。
函数返回值是退出码:
is_service_active() {
systemctl is-active --quiet "$1"
}
if is_service_active demo-app; then
log INFO "service is active"
else
log WARN "service is not active"
fi执行到 if is_service_active demo-app 时,Shell 实际上是在看 systemctl is-active --quiet demo-app 的退出码。
需要返回字符串时,用 stdout:
current_release() {
readlink -f /opt/demo-app/current 2>/dev/null || true
}
release_dir="$(current_release)"这里如果 current 链接不存在,函数不会报错退出,而是返回空字符串。调用方要继续判断。
函数内部变量用 local:
backup_file() {
local src="$1"
local dest="$2"
install -d "$(dirname "$dest")"
cp -a "$src" "$dest"
}长期维护的脚本,建议把入口放到最后:
main() {
check_env
prepare_dirs
deploy
}
main "$@"这样读脚本时先看到能力定义,最后看到流程编排。排障时也容易定位:是 backup_current 出错,还是 health_check 出错。
退出码和错误处理
脚本不能只靠肉眼看输出。CI、cron、systemd、Jenkins、运维平台都靠退出码判断成功失败。你手工执行时觉得“看起来没报错”,不代表平台会认为成功。
常用约定:
| 退出码 | 含义 |
|---|---|
0 | 成功 |
1 | 一般失败 |
2 | 参数错误或用法错误 |
126 | 命令存在但不可执行 |
127 | 命令不存在 |
128+n | 被信号 n 终止 |
先做一个小实验:
false
echo $?
true
echo $?false 的退出码是 1,true 的退出码是 0。Shell 的很多判断都建立在这个规则上。
部署脚本里,失败现场很重要。比如解压到一半失败,不要马上把临时目录删掉,否则后面没法判断是包坏了、磁盘满了,还是权限不对。
tmp_dir="$(mktemp -d)"
cleanup() {
local rc="$?"
if [[ "$rc" -eq 0 ]]; then
rm -rf "$tmp_dir"
else
log ERROR "keep temp dir for troubleshooting: $tmp_dir"
fi
}
trap cleanup EXIT这段逻辑的效果是:成功时清理临时目录,失败时保留临时目录。执行失败后日志里会告诉你临时目录在哪里。
对关键命令做定制错误信息:
if ! tar -tzf "$PACKAGE_FILE" >/dev/null; then
die "package is not a valid gzip tar file: $PACKAGE_FILE"
fi如果包不是合法的 gzip tar 文件,脚本会直接退出,并告诉你是哪个包有问题。这里要注意,不要只写 tar -zxf "$PACKAGE_FILE" 后让用户自己猜,关键步骤要把失败原因说清楚。
不要把失败吞掉:
# 不推荐
systemctl restart demo-app || true
# 推荐
if ! systemctl restart demo-app; then
journalctl -u demo-app -n 100 --no-pager >&2 || true
die "restart demo-app failed"
fi只有在失败可以接受、并且你明确记录了原因时,才使用 || true。比如查看日志失败不应该影响回滚,但重启服务失败一定不能吞掉。
日志和审计
脚本日志不要只写 echo done。真正排障时,日志至少要回答五个问题:谁执行、什么时间、在哪台机器、做了什么、结果如何。
基础日志函数:
log() {
local level="$1"
shift
printf '%s host=%s user=%s [%s] %s\n' \
"$(date '+%F %T')" "$(hostname -f 2>/dev/null || hostname)" "$(id -un)" "$level" "$*" \
| tee -a "$LOG_FILE" >&2
}把它放进脚本后,每次写日志都会带上时间、主机和用户。一般现场排查时,最先看的就是这个信息。
命令输出既要看得到,也要留文件:
{
echo "===== deploy begin $(date '+%F %T') ====="
./deploy.sh -e prod -v 1.2.3
echo "===== deploy end $(date '+%F %T') ====="
} 2>&1 | tee -a /var/log/ops/deploy-1.2.3.log执行时终端能看到输出,文件里也会保留一份。发布完成后,可以直接把这份日志归档到发布记录里。
定时任务不要依赖邮件提醒,显式重定向:
0 2 * * * /opt/ops/bin/backup.sh >> /var/log/ops/backup.cron.log 2>&1排障时常用组合:
tail -500f /var/log/ops/demo-app.log
grep -nE 'ERROR|WARN|failed|timeout|restart|rollback' /var/log/ops/demo-app.log
journalctl -u demo-app -n 200 --no-pager如果日志有中文乱码或采集链路做了转码,优先用稳定英文关键字、错误码、任务 ID、版本号、主机名过滤。现场排障时,不要只搜一句完整中文提示,换个编码或版本就搜不到了。
幂等、锁和原子操作
幂等不是“不会失败”,而是同一个脚本重复执行时,不会把系统带到更坏状态。部署运维脚本尤其需要幂等,因为现场经常会遇到网络中断、会话断开、服务重启一半失败、定时任务重复触发。
比如目录创建,就不要先判断再创建一堆分支,直接用 install -d 或 mkdir -p:
install -d -m 0755 /opt/demo-app/releases
install -d -m 0755 /var/log/ops如果目录已经存在,这条命令不会报错;如果不存在,它会创建出来。这里要注意,生产脚本里目录权限也要顺手写清楚。
发布版本切换建议用软链接。先把新版本解压到独立目录,验证通过后再切换 current:
ln -sfn "/opt/demo-app/releases/$VERSION" "/opt/demo-app/current.new"
mv -Tf "/opt/demo-app/current.new" "/opt/demo-app/current"这样做的好处是回滚简单。current 指向新版本就是发布,指回旧版本就是回滚。
临时文件用 mktemp:
tmp_dir="$(mktemp -d /tmp/demo-app.XXXXXX)"不要自己拼 /tmp/demo-app-$(date),并发时容易冲突。mktemp 会生成唯一目录。
并发控制用 flock:
LOCK_FILE="/var/lock/demo-app-deploy.lock"
exec 9>"$LOCK_FILE"
flock -n 9 || die "another deploy is running: $LOCK_FILE"如果另一个部署正在跑,这段会直接退出,避免两次发布同时改 current。这里要注意,如果脚本运行在 NFS、CIFS 等网络文件系统上,要谨慎依赖 flock。更稳妥的方式是把锁放在本机文件系统,或者使用数据库、Redis、运维平台提供的分布式锁。
状态文件适合给外部系统读取。比如运维面板想知道最近一次部署是否成功,就不要去解析一大堆日志,可以直接读 JSON:
STATUS_DIR="/var/lib/demo-app/status"
install -d "$STATUS_DIR"
write_status() {
local status="$1"
local message="$2"
cat > "$STATUS_DIR/deploy.json.tmp" <<EOF
{"status":"$status","message":"$message","time":"$(date -Is)"}
EOF
mv -f "$STATUS_DIR/deploy.json.tmp" "$STATUS_DIR/deploy.json"
}状态文件要用先写临时文件、再 mv 替换,避免外部系统读到半截 JSON。这种小细节很朴素,但线上很有用。
部署脚本模板
下面开始写一个真正能落地的部署脚本。它的目标不是把包复制过去,而是形成闭环:校验包、备份当前版本、解包到新目录、切换软链接、重启服务、健康检查、失败回滚。
部署脚本闭环:参数校验 -> 依赖检查 -> 备份 -> 解包 -> 切换版本 -> 重启 -> 健康检查 -> 成功/失败回滚
这条链路适合放在脚本开头反复对照,避免只写顺利路径而漏掉失败分支。
后面的模板就是围绕这条闭环展开:先保证每一步可验证,再让失败路径能自动收束。
先约定目录。这个目录结构很重要,后面的回滚就靠它:
/opt/demo-app/
current -> /opt/demo-app/releases/1.2.3
releases/
1.2.2/
1.2.3/
shared/
config/
logs/
backups/如果没有这些目录,脚本会自己创建。应用实际运行时只关心 /opt/demo-app/current,发布新版本时只切换这个软链接。
完整骨架如下。第一次看会觉得长,但每个函数都对应发布现场的一个动作:
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
APP_NAME="demo-app"
BASE_DIR="/opt/$APP_NAME"
RELEASES_DIR="$BASE_DIR/releases"
CURRENT_LINK="$BASE_DIR/current"
BACKUP_DIR="$BASE_DIR/backups"
LOG_FILE="/var/log/ops/${APP_NAME}-deploy-$(date +%F).log"
PACKAGE_FILE=""
VERSION=""
DRY_RUN=0
log() {
local level="$1"
shift
printf '%s [%s] %s\n' "$(date '+%F %T')" "$level" "$*" | tee -a "$LOG_FILE" >&2
}
die() {
log ERROR "$*"
exit 1
}
run() {
log INFO "RUN $*"
[[ "$DRY_RUN" == "1" ]] || "$@"
}
usage() {
echo "Usage: $0 -p <package.tar.gz> -v <version> [-n]"
}
while getopts ":p:v:nh" opt; do
case "$opt" in
p) PACKAGE_FILE="$OPTARG" ;;
v) VERSION="$OPTARG" ;;
n) DRY_RUN=1 ;;
h) usage; exit 0 ;;
:) usage; exit 2 ;;
\?) usage; exit 2 ;;
esac
done
verify_package() {
[[ -s "$PACKAGE_FILE" ]] || die "package not found or empty: $PACKAGE_FILE"
tar -tzf "$PACKAGE_FILE" >/dev/null || die "invalid package: $PACKAGE_FILE"
[[ "$VERSION" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]] || die "invalid version: $VERSION"
}
prepare_dirs() {
run install -d "$RELEASES_DIR" "$BACKUP_DIR" "/var/log/ops"
}
backup_current() {
if [[ -L "$CURRENT_LINK" ]]; then
local current
current="$(readlink -f "$CURRENT_LINK")"
local backup_file="$BACKUP_DIR/$(basename "$current")-$(date +%Y%m%d_%H%M%S).tar.gz"
log INFO "backup current release: $current -> $backup_file"
[[ "$DRY_RUN" == "1" ]] || tar -czf "$backup_file" -C "$current" .
else
log WARN "current link not found, skip backup"
fi
}
unpack_release() {
local target="$RELEASES_DIR/$VERSION"
if [[ -d "$target" ]]; then
die "release already exists: $target"
fi
local tmp
tmp="$(mktemp -d "$RELEASES_DIR/.${VERSION}.XXXXXX")"
log INFO "unpack package to $tmp"
tar -zxf "$PACKAGE_FILE" -C "$tmp"
mv "$tmp" "$target"
}
switch_release() {
local target="$RELEASES_DIR/$VERSION"
[[ -d "$target" ]] || die "release dir not found: $target"
ln -sfn "$target" "$CURRENT_LINK.new"
mv -Tf "$CURRENT_LINK.new" "$CURRENT_LINK"
log INFO "current -> $target"
}
restart_service() {
run systemctl restart "$APP_NAME"
}
health_check() {
for i in {1..30}; do
if curl -fsS "http://127.0.0.1:8080/actuator/health" >/dev/null; then
log INFO "health check passed"
return 0
fi
sleep 2
done
return 1
}
rollback() {
local previous
previous="$(find "$RELEASES_DIR" -mindepth 1 -maxdepth 1 -type d | sort -Vr | sed -n '2p')"
[[ -n "$previous" ]] || die "no previous release found"
ln -sfn "$previous" "$CURRENT_LINK.new"
mv -Tf "$CURRENT_LINK.new" "$CURRENT_LINK"
systemctl restart "$APP_NAME" || true
log WARN "rolled back to $previous"
}
main() {
exec 9>"/var/lock/${APP_NAME}-deploy.lock"
flock -n 9 || die "another deployment is running"
verify_package
prepare_dirs
backup_current
unpack_release
switch_release
restart_service
if ! health_check; then
log ERROR "health check failed, start rollback"
rollback
exit 1
fi
log INFO "deploy success: $VERSION"
}
main "$@"使用方式:
chmod +x deploy.sh
./deploy.sh -p demo-app-1.2.3.tar.gz -v 1.2.3 -n
./deploy.sh -p demo-app-1.2.3.tar.gz -v 1.2.3第一条带 -n,表示 dry-run,先看脚本准备执行什么;第二条才是真正发布。
发布成功后检查:
readlink -f /opt/demo-app/current
systemctl status demo-app --no-pager
curl -fsS http://127.0.0.1:8080/actuator/health
ls -lh /opt/demo-app/backups/正常结果应该是:current 指向 releases/1.2.3,服务处于 active,健康检查返回成功,备份目录里能看到旧版本压缩包。
这里要注意,示例里的健康检查地址要替换成你自己的服务地址。如果服务没有 HTTP 健康接口,至少要检查端口、进程和关键日志。
生产上还要补三件事:
- 发布前检查磁盘空间、端口、配置文件、依赖服务。
- 健康检查不要只看进程,要看 HTTP、端口、日志、关键接口或业务探针。
- 回滚后也要再次健康检查,否则只是“执行了回滚命令”,不是“回滚成功”。
备份脚本模板
备份脚本最怕两件事:第一,定时任务重复跑,两个备份互相覆盖;第二,备份文件生成了,但其实不可恢复。所以备份脚本要避免并发、要有校验、要有保留策略、要能被恢复演练验证。
先看文件备份示例:
#!/usr/bin/env bash
set -Eeuo pipefail
APP_NAME="demo-app"
DATA_DIR="/opt/demo-app/shared"
BACKUP_DIR="/backup/demo-app"
LOG_DIR="/var/log/ops"
RETENTION_DAYS=7
mkdir -p "$BACKUP_DIR" "$LOG_DIR"
LOG_FILE="$LOG_DIR/${APP_NAME}-backup-$(date +%F).log"
log() {
printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "$2" | tee -a "$LOG_FILE" >&2
}
exec 9>"/var/lock/${APP_NAME}-backup.lock"
flock -n 9 || { log WARN "backup already running"; exit 1; }
backup_file="$BACKUP_DIR/${APP_NAME}-$(date +%Y%m%d_%H%M%S).tar.gz"
log INFO "backup start: $DATA_DIR"
tar -czf "$backup_file" -C "$DATA_DIR" .
sha256sum "$backup_file" > "$backup_file.sha256"
sha256sum -c "$backup_file.sha256"
find "$BACKUP_DIR" -type f -name "${APP_NAME}-*.tar.gz" -mtime +"$RETENTION_DAYS" -print -delete
find "$BACKUP_DIR" -type f -name "${APP_NAME}-*.sha256" -mtime +"$RETENTION_DAYS" -print -delete
log INFO "backup success: $backup_file"执行:
chmod +x backup.sh
./backup.sh正常情况下会在 /backup/demo-app 下生成 .tar.gz 和 .sha256 两个文件。.tar.gz 是备份包,.sha256 用于校验备份包有没有损坏。
查看结果:
ls -lh /backup/demo-app/
sha256sum -c /backup/demo-app/demo-app-*.sha256
tar -tzf /backup/demo-app/demo-app-*.tar.gz | head如果 sha256sum -c 输出 OK,说明文件校验通过。如果 tar -tzf 能列出文件,说明压缩包结构正常。
数据库备份不要把密码直接写进命令行历史或日志。更推荐使用只读备份账号和客户端配置文件:
mysql --defaults-extra-file=/etc/demo-app/mysql-backup.cnf -e "select 1"
mysqldump --defaults-extra-file=/etc/demo-app/mysql-backup.cnf \
--single-transaction --routines --triggers demo_db \
| gzip > "$BACKUP_DIR/demo_db-$(date +%Y%m%d_%H%M%S).sql.gz"备份完成后至少做三类验证:
test -s "$backup_file"
sha256sum -c "$backup_file.sha256"
tar -tzf "$backup_file" >/dev/null真正的备份验收是恢复演练。没有恢复过的备份,只能算“复制过文件”。生产上一般需要定期拿一份备份在测试环境恢复,确认服务能启动、关键数据能查出来。
清理脚本模板
清理脚本是高风险脚本。部署失败最多是回滚,清理脚本写错可能直接删数据。这里要把规则定死:默认 dry-run,明确允许的目录,禁止空变量,禁止清理根目录、系统目录和未授权目录。
#!/usr/bin/env bash
set -Eeuo pipefail
TARGET_DIR="${1:-}"
DAYS="${2:-7}"
DRY_RUN="${DRY_RUN:-1}"
die() {
echo "ERROR: $*" >&2
exit 1
}
[[ -n "$TARGET_DIR" ]] || die "target dir is required"
[[ "$DAYS" =~ ^[0-9]+$ ]] || die "days must be number"
case "$TARGET_DIR" in
/var/log/demo-app|/opt/demo-app/tmp|/backup/demo-app) ;;
*) die "refuse to clean unsafe dir: $TARGET_DIR" ;;
esac
[[ -d "$TARGET_DIR" ]] || die "target dir not found: $TARGET_DIR"
echo "target=$TARGET_DIR days=$DAYS dry_run=$DRY_RUN"
if [[ "$DRY_RUN" == "1" ]]; then
find "$TARGET_DIR" -type f -mtime +"$DAYS" -print
else
find "$TARGET_DIR" -type f -mtime +"$DAYS" -print -delete
fi上线前先跑 dry-run:
DRY_RUN=1 ./cleanup.sh /var/log/demo-app 14这时候脚本只会打印准备删除的文件,不会真正删除。你要看输出是否都是预期目录里的旧日志。
确认输出符合预期后再执行:
DRY_RUN=0 ./cleanup.sh /var/log/demo-app 14不要写这种脚本:
rm -rf $TARGET_DIR/*一旦 TARGET_DIR 为空、带空格、被拼错或来自错误配置,后果不可控。清理脚本宁可啰嗦,也不要聪明。
巡检脚本模板
巡检脚本不是把几十条命令堆在一起,而是帮值班人员快速回答:机器是不是健康、服务是不是还活着、日志里有没有明显异常。
下面是一个轻量版本:
#!/usr/bin/env bash
set -Eeuo pipefail
WARN=0
check_disk() {
echo "## Disk"
df -h
local max_usage
max_usage="$(df -P | awk 'NR>1 {gsub("%","",$5); if ($5>m) m=$5} END {print m+0}')"
if (( max_usage >= 85 )); then
echo "WARN disk usage >= 85%"
WARN=1
fi
}
check_memory() {
echo "## Memory"
free -h
}
check_port() {
local port="$1"
echo "## Port $port"
if ss -lnt | awk '{print $4}' | grep -qE "[:.]${port}$"; then
echo "OK port listening: $port"
else
echo "WARN port not listening: $port"
WARN=1
fi
}
check_service() {
local service="$1"
echo "## Service $service"
if systemctl is-active --quiet "$service"; then
echo "OK service active: $service"
else
echo "WARN service inactive: $service"
systemctl status "$service" --no-pager || true
WARN=1
fi
}
check_logs() {
local file="$1"
echo "## Logs $file"
if [[ -f "$file" ]]; then
grep -nE 'ERROR|Exception|failed|timeout|refused' "$file" | tail -20 || true
else
echo "WARN log file not found: $file"
WARN=1
fi
}
check_disk
check_memory
check_port 8080
check_service demo-app
check_logs /var/log/demo-app/all.log
exit "$WARN"执行:
chmod +x inspect.sh
./inspect.sh
echo $?如果退出码是 0,表示没有发现告警;如果是 1,表示至少有一项巡检不通过。这里要注意,巡检脚本的退出码很适合接入 cron、监控探针或运维平台。
生产巡检可以继续扩展:
- CPU 负载:
uptime、top -b -n 1。 - 磁盘 inode:
df -ih。 - 端口监听:
ss -lntp。 - 进程状态:
ps -ef | grep或systemctl status。 - 日志异常:按错误码、任务 ID、关键字过滤。
- 证书有效期:
openssl x509 -noout -dates。 - 备份新鲜度:检查最近备份文件时间和校验结果。
巡检脚本的输出要能直接贴进工单或值班群。别只输出一堆命令原始结果,至少要有 OK/WARN/ERROR,否则看到的人还要重新判断一遍。
定时任务:crontab
cron 下最常见的问题是:手工执行成功,定时执行失败。原因通常不是脚本突然坏了,而是 cron 的环境和你的登录终端不同。cron 默认 shell、PATH、工作目录、环境变量都可能不一样。
编辑定时任务:
crontab -e推荐写法:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
0 2 * * * flock -n /var/lock/demo-backup.lock /opt/ops/bin/backup.sh >> /var/log/ops/backup.cron.log 2>&1
*/5 * * * * /opt/ops/bin/inspect.sh >> /var/log/ops/inspect.cron.log 2>&1这里要注意三件事。
- 显式指定
SHELL=/bin/bash,避免 Bash 脚本被/bin/sh执行。 - 显式设置
PATH,避免命令找不到。 - 所有输出都重定向到日志文件,避免任务失败后没有证据。
排查 cron:
crontab -l
systemctl status crond --no-pager || systemctl status cron --no-pager
journalctl -u crond -n 100 --no-pager || journalctl -u cron -n 100 --no-pager
tail -200 /var/log/ops/backup.cron.log如果 crontab -l 能看到任务,但日志文件没有新内容,先看 cron 服务是否运行,再看脚本路径是否写错。
脚本内部不要依赖当前目录:
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"涉及 Java、Node、Python、Docker 的定时任务,建议在脚本里显式加载环境或写绝对路径:
export JAVA_HOME=/usr/lib/jvm/java-17
export PATH="$JAVA_HOME/bin:/usr/local/bin:/usr/bin:/bin"cron 适合简单、稳定、可容忍少量平台差异的任务。更复杂的任务可以考虑 systemd timer。
定时任务:systemd timer
如果你的服务器使用 systemd,定时任务可以优先考虑 systemd timer。它的优势是可观测性更好,可以用 systemctl 和 journalctl 管理,也支持错峰和错过补跑。
先写服务单元:
# /etc/systemd/system/demo-backup.service
[Unit]
Description=Demo App Backup
[Service]
Type=oneshot
ExecStart=/opt/ops/bin/backup.sh
User=root
WorkingDirectory=/opt/ops再写定时器单元:
# /etc/systemd/system/demo-backup.timer
[Unit]
Description=Run Demo App Backup Daily
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=10min
Unit=demo-backup.service
[Install]
WantedBy=timers.target启用和验证:
systemctl daemon-reload
systemctl enable --now demo-backup.timer
systemctl list-timers --all | grep demo-backup
systemctl start demo-backup.service
journalctl -u demo-backup.service -n 100 --no-pagersystemctl list-timers --all 里能看到下一次执行时间,说明 timer 已经生效。journalctl -u demo-backup.service 能看到每次任务输出。
Persistent=true 适合服务器关机期间错过的任务在下次启动后补跑。RandomizedDelaySec 适合多台机器同一时间备份、巡检或上报时错峰,避免所有节点同时打满磁盘、数据库或网络。
启动、停止和重启脚本
启动停止脚本最重要的是“动作后验证”。如果服务已经由 systemd 托管,脚本不要自己 nohup java -jar 到处飞。优先调用 systemd,并把状态检查补上:
restart_and_wait() {
local service="$1"
systemctl restart "$service"
for i in {1..30}; do
if systemctl is-active --quiet "$service"; then
echo "service active: $service"
return 0
fi
sleep 1
done
systemctl status "$service" --no-pager || true
journalctl -u "$service" -n 100 --no-pager || true
return 1
}
restart_and_wait demo-app执行后如果服务正常启动,会输出 service active: demo-app。如果 30 秒内没有变成 active,脚本会把 systemctl status 和最近日志打印出来。
如果服务还没有 systemd 单元,至少要有 pid 文件和端口检查:
start_app() {
[[ -f app.pid ]] && kill -0 "$(cat app.pid)" 2>/dev/null && {
echo "already running"
return 0
}
nohup java -jar demo-app.jar >> logs/stdout.log 2>&1 &
echo $! > app.pid
}
stop_app() {
[[ -f app.pid ]] || return 0
local pid
pid="$(cat app.pid)"
kill "$pid" 2>/dev/null || true
for i in {1..30}; do
kill -0 "$pid" 2>/dev/null || {
rm -f app.pid
return 0
}
sleep 1
done
kill -9 "$pid" 2>/dev/null || true
rm -f app.pid
}这里要注意,pid 文件只能说明进程还在,不代表服务真的可用。生产服务建议尽快收敛到 systemd、容器编排或平台托管,并补健康接口。
批量发布和滚动执行
多机器发布不要一口气全重启。先做主机列表,再逐台执行、逐台验证。先准备 hosts.txt:
cat > hosts.txt <<'EOF'
app-01
app-02
EOF然后写批量发布脚本:
#!/usr/bin/env bash
set -Eeuo pipefail
HOST_FILE="hosts.txt"
PACKAGE_FILE="${1:?package required}"
REMOTE_DIR="/opt/demo-app/packages"
FAILED=0
deploy_host() {
local host="$1"
echo "===== deploy $host ====="
ssh "$host" "install -d '$REMOTE_DIR'"
scp "$PACKAGE_FILE" "$host:$REMOTE_DIR/"
ssh "$host" "bash /opt/ops/bin/deploy.sh -p '$REMOTE_DIR/$(basename "$PACKAGE_FILE")' -v '1.2.3'"
ssh "$host" "curl -fsS http://127.0.0.1:8080/actuator/health >/dev/null"
}
while IFS= read -r host || [[ -n "$host" ]]; do
[[ -z "$host" || "$host" =~ ^# ]] && continue
if deploy_host "$host"; then
echo "OK $host"
else
echo "FAILED $host" >&2
FAILED=1
break
fi
done < "$HOST_FILE"
exit "$FAILED"执行:
chmod +x batch-deploy.sh
./batch-deploy.sh demo-app-1.2.3.tar.gz如果第一台发布失败,脚本会输出 FAILED app-01 并停止后续发布。这里要注意,批量发布宁可慢一点,也不要所有机器同时失败。
滚动发布要配合入口层摘流量。如果只是两台机器,可以按这个节奏:
- 从负载均衡摘掉第一台。
- 发布第一台。
- 本机健康检查通过。
- 加回第一台。
- 摘掉第二台。
- 发布第二台。
- 全链路检查。
Shell 里可以只负责执行动作,但动作前后必须有验证命令。没有验证的滚动发布,只是分批重启。
排障手册
排障时不要先改脚本。先把现象、退出码、最后一条日志、执行用户、执行目录收集出来。很多 Shell 问题最后不是语法问题,而是环境问题。
| 现象 | 常见原因 | 排查命令 | 修复方式 |
|---|---|---|---|
| 手工执行成功,cron 失败 | PATH、SHELL、工作目录不同 | crontab -l、journalctl -u crond、查看脚本日志 | 写绝对路径,设置 SHELL 和 PATH,显式 cd |
脚本报 command not found | 依赖命令未安装或 PATH 不同 | command -v xxx、echo "$PATH" | 安装依赖,脚本前置 need_cmd |
| 变量为空导致误删 | 未开启 set -u,未做路径白名单 | 查脚本日志和执行参数 | 增加必填参数校验、路径白名单、dry-run |
| 管道前半段失败但脚本成功 | 默认返回最后一个命令退出码 | 复现 `cmd1 | cmd2; echo $?` |
| 定时任务重复执行 | 上一次未结束,新一次又启动 | ps -ef、锁文件、日志时间 | 使用 flock 或状态锁 |
| 部署后服务启动但不可用 | 只检查进程,未检查端口和健康接口 | systemctl status、ss -lntp、curl、日志 | 加健康检查和失败回滚 |
| 远程批量脚本卡住 | SSH 等密码、网络超时、命令无超时 | ssh -v、timeout、连通性检查 | 配置免密,BatchMode=yes,加 ConnectTimeout |
| 回滚后仍失败 | 回滚目标不完整,配置或数据库已变更 | 查备份、软链接、版本、日志 | 回滚后做同样健康检查,数据库变更单独设计 |
| 日志没有输出 | stdout/stderr 未重定向 | 查 cron、systemd journal | 使用 tee 或 >> file 2>&1 |
| 脚本在一台机器可用,另一台失败 | Bash 版本、工具版本、系统差异 | bash --version、uname -a、依赖检查 | 降低语法依赖,写环境检查,固定工具包 |
排障时建议先收集四类证据:
date
hostname -f || hostname
whoami
pwd
bash --version | head -1
env | sort
systemctl status demo-app --no-pager || true
journalctl -u demo-app -n 100 --no-pager || true
tail -200 /var/log/ops/demo-app.log || true不要边猜边改。先确认脚本执行到哪一步、退出码是多少、最后一条成功命令是什么。确认以后再修,不然很容易把第一现场覆盖掉。
落地工程深水区
Shell 自动化上线后,最怕的不是脚本语法错,而是脚本在错误的机器、错误的路径、错误的并发窗口里“正确执行”。生产脚本要提前处理这些深水问题。
第一是执行身份和权限。脚本要明确谁执行、用哪个用户执行、哪些动作需要 sudo。不要让脚本全程 root,也不要把 sudo 密码写进脚本。上线前执行:
whoami
id
sudo -l
pwd
umask第二是路径保护。凡是会 rm -rf、mv、rsync --delete、切软链接的脚本,都要先检查目标路径不为空、在允许目录下、不是 /、不是 /var、不是变量展开失败后的空字符串。高风险命令先 dry-run,再执行。
第三是并发和重入。发布、备份、清理、巡检都可能被人工、cron、CI 同时触发。flock 不是装饰,它决定同一时刻是否只能有一个任务修改状态。锁文件残留时不要直接删除,先查进程、日志和当前版本。
第四是日志和审计。脚本失败后要能回答:谁执行的、执行参数是什么、每一步输出是什么、返回码是什么、是否触发回滚。日志里不能打印密码、token、私钥、数据库连接串;需要记录敏感配置时只记录变量名和脱敏后的末尾几位。
第五是离线依赖。脚本调用的每个命令都是依赖。内网和离线环境里,缺 rsync、flock、timeout、sha256sum 不应该现场联网装。把 requirements.commands 放进交付包,发布前检查并输出缺失项;缺依赖就失败退出,而不是带病执行。
第六是团队接管。脚本要有 --help、dry-run、日志路径、回滚入口和版本记录。否则写脚本的人离开后,团队只剩一个不敢改也不敢删的黑盒文件。可维护的 Shell 脚本宁可朴素,也要能被下一位值班同学看懂和接管。
生产化清单
发布前:
- 脚本有明确 shebang。
- 参数有
usage和必填校验。 - 所有变量引用默认加双引号。
- 关键命令有退出码检查。
- 有日志文件,stdout/stderr 不丢失。
- 有锁,避免并发执行。
- 清理脚本默认 dry-run。
- 部署脚本有备份和回滚入口。
- 依赖命令已检查。
- 包和离线工具有校验文件。
上线中:
- 记录执行人、时间、主机、版本。
- 先备份,再发布。
- 先单节点,再批量。
- 每台机器发布后做本机健康检查。
- 入口层切流量前后都验证。
- 失败立即停止后续节点。
上线后:
- 检查服务状态、端口、健康接口。
- 检查错误日志。
- 检查关键业务接口。
- 检查定时任务是否正常。
- 检查备份是否生成。
- 保存执行日志和版本信息。
故障时:
- 保留临时目录和失败现场。
- 不要立刻清理日志。
- 先确认是否需要回滚。
- 回滚后再次健康检查。
- 记录根因、修复命令、验证命令。
常用命令速查
脚本检查:
bash -n deploy.sh
shellcheck deploy.sh
shfmt -w deploy.sh权限和格式:
chmod +x deploy.sh
file deploy.sh
sed -i 's/\r$//' deploy.sh日志:
tail -200f /var/log/ops/deploy.log
grep -nE 'ERROR|WARN|failed|timeout' /var/log/ops/deploy.log服务:
systemctl status demo-app --no-pager
systemctl restart demo-app
journalctl -u demo-app -n 100 --no-pager网络和端口:
ss -lntp
curl -fsS http://127.0.0.1:8080/actuator/health
timeout 5 bash -c '</dev/tcp/127.0.0.1/8080'磁盘和文件:
df -h
df -ih
du -sh /var/log/*
find /var/log/demo-app -type f -mtime +7 -print定时任务:
crontab -l
systemctl list-timers --all
journalctl -u demo-backup.service -n 100 --no-pager官方文档入口
- GNU Bash Manual: https://www.gnu.org/software/bash/manual/bash.html
- Bash current status: https://tiswww.case.edu/php/chet/bash/bashtop.html
- POSIX Shell Command Language: https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html
- crontab(5): https://man7.org/linux/man-pages/man5/crontab.5.html
- flock(1): https://man7.org/linux/man-pages/man1/flock.1.html
- systemd.timer(5): https://man7.org/linux/man-pages/man5/systemd.timer.5.html
- GNU Coreutils Manual: https://www.gnu.org/software/coreutils/manual/coreutils.html
- ShellCheck: https://github.com/koalaman/shellcheck
- shfmt: https://github.com/mvdan/sh
