2. Shell脚本在Devops下的应用

Shell脚本成为运维开发人员的“瑞士军刀”。所以不管是系统管理员,还是运维开发人员或者开发人员,掌握Shell脚本语言,对我们的工作能够起到很大的帮助作用。

2.1. 2.1 Shell脚本的基本元素

单行注释

#!/bin/bash     //第一行
#               // 表示单行注释

多行注释

:<<BLOCK
中间部分为要省略的内容
BLOCK

Shell脚本文件的命令解释程序

#! /bin/bash

2.2. 2.2 Shell基础正则表达式

参考玩转shell脚本编程的 `正则表达式章节

Shell中的正则表达式

2.3. 2.3 Shell特殊字符

image0

2.4. 2.4 变量和运算符

变量名=“变量”
readonly 变量名=“变量”       #设置只读变量
echo $变量名
set         #显示本地所有的变量
unset       #变量名清除变量
readonly    #显示当前Shell下有哪些只读变量

设置环境变量,环境变量可以用于所有的子shell中,其包括编辑器、脚本和应用。

环境变量的主目标如下:

$HOME/.bash_profile(/etc/profile)

设置环境变量,例句如下:

export test="123"

查看环境变量,命令如下:

env

或者使用

export

变量清除命令:

unset 变量名

内部变量

Shell提供了丰富的内部变量,为用户的Shell编程提供支持,具体如下。

❑ PWD:表示当前的工作目录,其变量值等同于pwd内部命令的输出。
❑ RANDOM:每次引用这个变量时,将会生成一个均匀分布的0~32767范围内的随机整数。
❑ SCONDS:脚本已经运行的时间(秒)。
❑ PPID:当前进程的父进程的进程ID。
❑ $? :表示最近一次执行的命令或Shell脚本的出口状态。

环境变量

Shell提供的主要环境变量如下所示。
❑ EDITOR:用于确定命令行编辑所用的编辑程序,通常为vim。
❑ HOME:用户主目录。
❑ PATH:指定命令的检索路径。

2.4.1. 写入环境变量

例如,要将/usr/local/mysql/bin目录添加进系统默读的PATH变量中

PATH=$PATH:/usr/local/mysql/bin
export PATH
echo $PATH

Linux中含有两个重要的文件,

/etc/profile$HOME/.bash_profile,每当系统登录时都要读取这两个文件,用来初始化系统所用到的变量,其中/etc/profile是超级用户所用,

$HOME/.bash_profile是每个用户自己独立的,可以通过修改该文件来设置PATH变量。

注意

这种方法也只能使当前用户生效,而并非所有用户。

如果要让所有用户都能够用到此PATH变量,则可以用vim命令打开“/etc/profile”文件,在适当位置添加“PATH=$PATH:/usr/local/mysql/bin”,然后执行“source /etc/profile”使其生效。

位置变量的用法

#!/usr/bin/env bash
echo "第1个参数为:" $0
echo "第2个参数为:" $1
echo "第3个参数为:" $2
echo "第4个参数为:" $3
echo "第5个参数为:" $4

执行结果如下:

$ ./aaa.sh 1 2 3 4 5
第1个参数为: ./aaa.sh
第2个参数为: 1
第3个参数为: 2
第4个参数为: 3
第5个参数为: 4

从第10个参数开始就要使用花括号将编号括起来,如“${10}”。

表示方法

描述

$n

$1 表示第一个参数,$2 表示第二个参数 …

$#

命令行参数的个数

$0

当前程序的名称

$?

前一个命令或函数的返回码

$*

以“参数1 参数2 …” 形式保存所有参数

$@

以“参数1”“参数2” … 形式保存所有参数

$$

本程序的(进程ID号)PID

$_

表示上一个命令的最后一个参数

$!

表示最后执行的后台命令的PID

$*$@都是表示所有的位置参数,但是区别如下:

相同点:都是引用所有参数。 不同点:只有在双引号中体现出来。

假设在脚本运行时写了三个参数 1、2、3

$* 等价于 “1 2 3”(传递了一个参数)

而$@等价于 “1” “2” “3”(传递了三个参数)

$#命令行参数的个数

关于位置参数shift的常见用法

例如,脚本publishconf依次对后面的IP进行操作,代码如下:

./publishconf -p 192.168.11.2 192.168.11.3 192.168.11.4 192.168.11.5 192.168.11.6

部分代码不如

#!/usr/bin/env bash
#usage:xxx
#scripts_name:${NAME}.sh
# author:xiaojian
if [ "$#" >=3 ]; then
    shift 1
#    echo "此次需要更新的机器IP为$@"
    for flat in $@ ; do
           echo "此次需要更新的机器IP为$flat"
           # 操作动作
    done
fi

2.5. 2.5 运行、调试Shell脚本

调试Shell脚本

· -n: 不会执行该脚本,仅查询脚本语法是否有问题,并给出错误提示。
· -v:在执行脚本时,先将脚本的内容输出到屏幕上,然后执行脚本。如果有错误,也会给出错误提示。
· -x:  将执行的脚本内容输出显示到屏幕上,这是对调试很有用的参数。

参数-x是追踪脚本执行过程的一种非常好的方法,其可以在执行前列出所执行的所有程序段。

bash -x 调试Shell脚本,bash会先打印出每行脚本,再打印出每行脚本的执行结果,如果只调试其中几行脚本,
可以采用“set -x”和“set +x”把要测试的部分包含进来。示例代码如下:

set -x
脚本部分内容
set +x

set命令最大的优点是:

与“bash -x”相比,“set -x”可以缩小调试的作用域,这个功能在工作中是非常有用的功能,可以帮助我们调试变量,找出Bug的位置并打印。

退出或出口状态

在Shell脚本中,可以利用"exit[n]" n在0~255范围之内。向脚本的父进程返回一个数值为n的shell脚本出口状态。

如果Shell脚本是以不带参数的exit语句结束的,则Shell脚本的出口状态就是脚本最后一条命令的出口状态。

在Unix中,为了测试一个命令或Shell脚本的执行结果,"$?"内部变量将返回执行执行的最后一条命令的出口状态。其中0位正确值,其他非零为错误值。

2.6. 2.6 test语句

2.6.1. 2.6.1 文件测试运算符

下面是文件属性测试表达式。

❑ -e file:如果给定的文件存在,则条件测试的结果为真。

❑ -r file:如果给定的文件存在,且其访问权限是当前用户可读的,则条件测试的结果为真。

❑ -w file:如果给定的文件存在,且其访问权限是当前用户可写的,则条件测试的结果为真。

❑ -x file:如果给定的文件存在,且其访问权限是当前用户可执行的,则条件测试的结果为真。

❑ -s file:如果给定的文件存在,且其大小大于0,则条件测试的结果为真。

❑ -f file:如果给定的文件存在,且是一个普通文件,则条件测试的结果为真。

❑ -d file:如果给定的文件存在,且是一个目录,则条件测试的结果为真。

❑ -L file:如果给定的文件存在,且是一个符号链接文件,则条件测试的结果为真(注意:此处的L为大写)。

❑ -c file:如果给定的文件存在,且是字符特殊文件,则条件测试的结果为真。

❑ -b file:如果给定的文件存在,且是块特殊文件,则条件测试的结果为真。

❑ -p file:如果给定的文件存在,且是命名的管道文件,则条件测试的结果为真。

文件测试运算符常见代码举例如下:

BACKDIR=/data/backup

[ -d ${BACKDIR} ] || mkdir -p ${BACKDIR}
[ -d ${BACKDIR}/${DATE} ] || mkdir -p ${BACKDIR}/${DATE}
[ ! -d ${BACKDIR}/${OLDDATE} ] || rm -rf ${BACKDIR}/${OLDDATE}

2.6.2. 2.6.2 字符串测试运算符

❑ -z str:如果给定的字符串的长度为0,则条件测试的结果为真。
❑ -n str:如果给定的字符串的长度大于0,则条件测试的结果为真。要求字符串必须加引号。
❑ s1=s2:如果给定的字符串s1等同于字符串s2,则条件测试的结果为真。
❑ s1! =s2:如果给定的字符串s1不等同于字符串s2,则条件测试的结果为真。
❑ s1<s2,如果给定的字符串s1小于字符串s2,则条件测试的结果为真。例如:if[[ "$a"<"Sb" ]]注意,if[ "$a"/<"$b"],在单方括号情况下,字符“<”和“>”前须必加转义符号“\”。

❑ s1>s2:若给定的字符串s1大于字符串s2,则条件测试的结果为真。

在比较字符串的test语句中,变量或字符串表达式的前后一定要加双引号。

2.6.3. 2.6.3 整数值测试运算符

整数测试表达式为

“-eq”(等于)
“-ne”(不等于)
“-gt”(大于)
“-lt”(小于)
“-ge”(大于等于)
“-le”(小于等于)

2.6.4. 2.6.4 逻辑运算符

Shell中的逻辑运算符,具体说明如下所示。

❑ (expression):用于计算括号中的组合表达式,如果整个表达式的计算结果都为真,则测试结果也为真。

❑! exp:可对表达式进行逻辑非运算,即对测试结果求反。例如“test ! -ffile1”。

❑ 符号-a或&&:表示逻辑与运算。

❑ 符号-o或||:表示逻辑或运算。

2.7. 2.7 Shell中的自定义函数

自定义语法比较简单,语法结构如下:

function 函数名() {
    action;
    [return 数值;]
}

❑ 自定义函数既可以带function函数名()定义,也可以直接用函数名()定义,不带任何参数。

❑ 参数返回时,可以显式加return返回;如果不加,则将以最后一条命令的运行结果作为返回值。return后跟数值,取值范围为0~255。

Shell不像Python及Go语言,其没有OOP的概念,因此Shell肯定也是没有Class(类)的,所以我们若想以Class的方式来封装多个Shell函数,那是不可能实现的。但是我们在编写Shell需求工作时会有一种很常见的需求.

现在为了减少代码复用,各业务功能需求就是多个函数的组合,具体应该怎么实现呢?这里其实可以结合case语句来实现。

脚本名称为automanage.sh。部分内容如下:

--mirror-interac)
    rg_Checkrelease
    rg_RebootCheck && rg_BasicCheck && rg_InitBasic && rg_mkpart && rg_mkfs_inerac && rg_info 0 '完成'

事实上我们执行以下命令:

automanage.sh --mirror-interac

此命令会依次调用rg_ChkRelease()、rg_RebootCheck()、rg_BasicCheck()、rg_InitBasic()、rg_mkpart()、rg_mkfs_interac()及rg info一系列函数,实现工作需求。

2.8. 2.8 Shell中的字符串截取

| Expression                                | Meaning                                                      |
| ----------------------------------------- | ------------------------------------------------------------ |
| ${#string}                              | $string 的长度                                               |
|                                           |                                                              |
| ${string:position}                   | 在 $string 中, 从位置 $position 开始提取子串                 |
| ${string:position:length}               | 在 $string 中, 从位置 $position 开始提取长度为 $length 的<br/>子串 |
|                                           |                                                              |
| ${string#substring}                        | 从变量 $string 的开头, 删除最短匹配 $substring 的子串        |
| ${string##substring}                      | 从变量 $string 的开头, 删除最长匹配 $substring 的子串        |
| ${string%substring}                       | 从变量 $string 的结尾, 删除最短匹配 $substring 的子串        |
| ${string%%substring}                    | 从变量 $string 的结尾, 删除最长匹配 $substring 的子串        |
|                                           |                                                              |
| ${string/substring/replacement}         | 使用 $replacement , 来代替第一个匹配的 $substring            |
| ${string//substring/replacement}        | 使用 $replacement , 代替 所有 匹配的 $substring              |
| ${string/#substring/replacement}        | 如果 $string 的 前缀 匹配 $substring , 那么就<br/>用 $replacement 来代替匹配到的 $substring |
| ${string/%substring/replacement}        | 如果 $string 的 后缀 匹配 $substring , 那么就<br/>用 $replacement 来代替匹配到的 $substring |
|                                           |                                                              |
| expr match "$string" '$substring'          | 匹配 $string 开头的 $substring *的长度                       |
| expr "$string" : '$substring'                   | 匹配 $string 开头的 $substring *的长度                       |
| expr index "$string" $substring             | 在 $string 中匹配到的 $substring 的第一个字符出现的位置      |
| expr substr $string $position $length   | 在 $string 中从位置 $position 开始提取长度为 $length 的子<br/>串 |
| expr match "$string" '\($substring\)'     | 从 $string 的开头位置提取 $substring *                       |
| expr "$string" : '\($substring\)'              | 从 $string 的开头位置提取 $substring *                       |
| expr match "$string" '.*\($substring\)'  | 从 $string 的结尾提取 $substring *                           |
| expr "$string" : '.*\($substring\)'           |                                                              |

2.9. 2.9 Shell参数替换和扩展

| Expression        | Meaning                                                      |
| ----------------- | ------------------------------------------------------------ |
| ${var}          | 变量 var 的值, 与 $var 相同                                  |
|                   |                                                              |
| ${var-DEFAULT}  | 如果 var 没有被声明, 那么就以 $DEFAULT 作为其值 *            |
| ${var:-DEFAULT} | 如果 var 没有被声明, 或者其值为空, 那么就以 $DEFAULT 作为其值 * |
|                   |                                                              |
| ${var=DEFAULT}  | 如果 var 没有被声明, 那么就以 $DEFAULT 作为其值 *            |
| ${var:=DEFAULT} | 如果 var 没有被声明, 或者其值为空, 那么就以 $DEFAULT 作为其值 * |
|                   |                                                              |
| ${var+OTHER}    | 如果 var 声明了, 那么其值就是 $OTHER , 否则就为null字符串    |
| ${var:+OTHER}   | 如果 var 被设置了, 那么其值就是 $OTHER , 否则就为null字符串  |
|                   |                                                              |
| ${var?ERR_MSG}  | 如果 var 没被声明, 那么就打印 $ERR_MSG *                     |
| ${var:?ERR_MSG}  | 如果 var 没被设置, 那么就打印 $ERR_MSG *                     |
|                   |                                                              |
| ${!varprefix*}  | 匹配之前所有以 varprefix 开头进行声明的变量                  |
| ${!varprefix@}  | 匹配之前所有以 varprefix 开头进行声明的变量                  |

2.10. 2.10 Shell应用于Devops开发中音掌握的系统知识点

2.10.1. 2.10.1 Shell多进程并发

1)进程。若采用这种方法,则每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,因此要想与其他流进行通信,控制流必须使用进程间通信(IPC)。

2)I/O多路复用。若采用这种形式的并发,则应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模拟为“状态机”,数据到达文件描述符之后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享一个地址空间。

3)线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。线程可以看作是进程和I/O多路复用的合体,像进程一样由内核调度,像I/O多路复用一样共享一个虚拟地址空间。

默认情况下,Shell脚本中的命令是串行执行的,必须等到前一条命令执行完毕之后才执行接下来的命令, 但是如果有一大批的命令需要执行,而且相互之间没有影响的情况下,此时就要使用命令的并发执行了。

正常的程序echo_hello.sh代码如下所示:

#!/usr/bin/env bash
#usage:xxx
#scripts_name:${NAME}.sh
# author:xiaojian
for (( VAR = 0; VAR < 5; VAR++ )); do
    {
    sleep 3
    echo "hello,world" >> aa & echo "done!"
    }
done

cat aa |wc -l
rm aa

使用time计算脚本的执行时间,命令结果如下:

$ time sh echo_hello.sh
done!
done!
done!
done!
done!
5

real    0m18.873s
user    0m0.091s
sys     0m0.214s

并发执行的代码如下:

#!/usr/bin/env bash
#usage:xxx
#scripts_name:${NAME}.sh
# author:xiaojian
for (( VAR = 0; VAR < 5; VAR++ )); do
    {
    sleep 3
    echo "hello,world" >> aa & echo "done!"
    } &
done
# wait 命令有一个很重要的用途就是在Shell的并发编程中,可以在Shell脚本中启动多个后台进程(使用“&”),然后调用wait命令,等待所有后台进程都运行完毕,
# shell脚本再继续向下执行,
wait
cat aa |wc -l
rm aa

使用time计算脚本执行时间,如下:

$ time sh echo_hello2.sh
done!
done!
done!
done!
done!
5

real    0m5.689s
user    0m0.062s
sys     0m0.244s

当多个进程可能会对同样的数据执行操作时,这些进程需要保证其他进程没有在操作, 以免损坏数据,通常,这样的进程会使用一个“锁文件”,也就是创建一个文件告诉别的进程之间在运行, 如果检测到这个文件存在,就认为操作同样数据的进程在工作。这样做有个问题,当进程不小心意外死亡了,没有清理掉那个文件, 只能由用户手工的去清理了。

2.10.2. 2.10.2 Shell脚本中执行另一个Shell脚本

在运行Shell脚本的时候,可采用下面两种方式来调用外部的脚本,即exec方式和source方式。

(1)exec方式

使用exec方式来调用脚本,被执行的脚本会继承当前Shell的环境变量。但事实上,exec产生了新的进程,其会占用主Shell的进程资源并替换脚本内容,继承原主Shell的PID号,即原主Shell剩下的内容不会再执行。

(2)source方式

使用source或者“.”来调用外部脚本,不会产生新的进程继承当前Shell环境变量,而且被调用的脚本运行结束之后,它所拥有的环境变量和声明变量会被当前Shell保留,这点类似于将调用脚本的内容复制过来直接执行,执行完毕后原主Shell将继续运行。

(3)fork方式

直接运行脚本,会以当前shell为父进程,产生新的进程,并且继承主脚本的环境变量和声明变量。执行完毕后,主脚本不会保留其环境变量和声明变量。

总结:这样来看fork最灵活,source次之,exec最诡异。

工作中推荐使用source的方式来调用外部的Shell脚本,该方式稳定性高,不会出现一些诡异的问题和bug,从而影响主程序的业务逻辑(大家也可以参考下Linux系统中的Shell脚本,如/etc/init.d/network等,基本上采用的都是这种处理方式)。

2.10.3. 2.8.3 flock文件锁

Linux中的例行性工作排程Crontab会定时执行一些脚本,但脚本的执行时间往往会无法控制,若脚本执行时间过长,则可能会导致上一次任务的脚本还没执行完毕,下一次任务的脚本又开始执行了。这种情况下可能会出现一些并发问题,严重时还会导致出现脏数据、性能瓶颈的恶性循环。

使用flock建立排它锁可以规避这个问题,如果一个进程对某个任务加持了独占锁(排他锁),则其他进程会无法加锁,可以选择等待超时或马上返回。

脚本file_lock.sh的内容如下:

#!/bin/bash
echo "----------------------------------"
echo "start at $(date "+%Y-%m-%d %H:%M:%S")...."
sleep 100s
echo "finished at $(date "+%Y-%m-%d %H:%M:%S")...."

每隔一分钟执行一次该脚本,并将输出信息写入到/tmp/test_tmp.log,下面简单介绍下flock在这里用到的选项,具体如下所示。

❑ -x, –exclusive:获得一个独占锁。

❑ -n, –nonblock:如果没有立即获得锁,则直接失败而不是等待。

❑ -c, –command:在Shell中运行一个单独的命令。

[root@localhost home]# crontab -l
*/1  * * * * flock -xn /dev/shm/test.lock -c "sh /home/file_lock.sh >> /tmptest_tmp.log"

输出日志如下:

tail -f /tmptest_tmp.log  -f
----------------------------------
start at 2020-06-10 13:26:01....
finished at 2020-06-10 13:28:21....
----------------------------------
start at 2020-06-10 13:29:01....
finished at 2020-06-10 13:30:41....

2.11. 2.10 什么是并行(parallellism)

目前大部分语言都能瞒住并发执行,当多核CPU出现后,多CPU的场景下开始产生并行的概念。

(1)总体概念

在单CPU系统中,系统调度在某一时刻只能让一个线程运行,虽然这种调试机制具有多种形式(大多数是以时间片轮询为主),
但无论如何,需要通过不断切换需要运行的线程让其运行的方式就称为并发(concurrent)。

在多CPU系统中,可以让两个以上的线程同时运行,这种可以让两个以上的线程同时运行的方式称为并行(parallel)。

CPU到了多核时代,那么就出现了新的概念:并行。

并行是真正细粒度上的同时进行,即同一时间点上同时发生着多个并发;更加确切并且简单地讲就是,每个CPU上运行一个程序,以达到同一时间点上各个CPU上均在运行一个程序。

并行和并发的具体区别

1)并行是指两个或者多个事件在同一时刻发生,而并发是指两个或多个事件在同一时间间隔发生。

2)并行是在不同实体上的多个事件,而并发是在同一实体上的多个事件。

3)在一台处理器上“同时”处理多个任务,在多台处理器上同时处理多个任务。

2.12. 2.11 什么是管道

管道的特点:

1)管道是半双工的,数据只能向一个方向流动;双方需要进行通信时,需要建立起两个管道。

2)只能用于父子进程或者兄弟进程之间(具有亲缘关系的进程)。

3)单独构成一种独立的文件系统:管道对于管道两端的进程而言,就是一个文件,但它不是普通的文件,它不属于某种文件系统,而是自立门户,单独构成一种文件系统,并且只存在于内存之中。

4)数据的读出和写入:一个进程向管道中写的内容被管道另一端的进程读出。写入的内容每次都添加在管道缓冲区的末尾,并且每次都是从缓冲区的头部读出数据。

示例代码

echo "hello"| cat

需要注意以下几点。

1)管道命令只处理前一个命令的正确输出,不处理错误输出。

2)管道右边的命令,必须能够接收标准输入流命令才行。

3)管道触发两个子进程执行“|”两边的程序。