Модуль 04 — Циклы

Одно действие для каждого файла, сервера и строки

Прогресс курса Модуль 4 из 9

Что вы освоите в этом модуле

01

for по списку

for выполняет тело для каждого слова из списка, по очереди помещая его в переменную:

$ for srv in web01 web02 db01; do
>     echo "проверяю $srv"
> done
проверяю web01
проверяю web02
проверяю db01

Знак > в начале строк — приглашение продолжения: оболочка ждёт окончания конструкции. В скрипте цикл пишется так же, без этих знаков. Списки чисел:

$ for i in {1..5}; do printf "%s " $i; done; echo
1 2 3 4 5
$ for ((i=0; i<10; i+=3)); do printf "%s " $i; done; echo
0 3 6 9

{1..5} раскрывается до запуска цикла и не принимает переменных: {1..$n} не сработает. Для границы из переменной используйте форму for ((i=1; i<=n; i++)) или $(seq 1 "$n").

02

for по файлам

Файлы перебирают шаблоном, а не выводом ls. Шаблон раскрывается в готовые имена, и пробелы в них не мешают, если переменная в кавычках:

$ for f in *.txt; do echo "[$f]"; done
[a b.txt]
[c.txt]
[мой отчёт.txt]
$ for f in $(ls *.txt); do echo "[$f]"; done     # так нельзя
[a]
[b.txt]
[c.txt]
[мой]
[отчёт.txt]

Вывод ls — текст для человека: после подстановки он разбивается по пробелам, и имена рвутся на части. Вторая ловушка — когда шаблону ничего не соответствует:

$ for f in *.nomatch; do echo "[$f]"; done
[*.nomatch]
$ shopt -s nullglob
$ for f in *.nomatch; do echo "[$f]"; done; echo конец
конец

Без nullglob цикл выполнится один раз с буквальной строкой *.nomatch, и команда в теле получит несуществующее имя. Либо включайте shopt -s nullglob в начале скрипта, либо проверяйте в теле: [[ -e $f ]] || continue.

Пример: сжать все журналы старше суток в каталоге, кроме уже сжатых:

for f in /var/log/myapp/*.log; do
    [[ -e $f ]] || continue
    if [[ $(find "$f" -mtime +0) ]]; then
        gzip -- "$f" && echo "сжат $f"
    fi
done

-- перед именем говорит команде «дальше не опции»: файл с именем -rf не станет опцией.

03

while read: файл построчно

Строки файла читают циклом while read. Файл подаётся на вход всему циклу через < после done:

$ cat hosts.txt
srv01 192.168.1.10
srv02 192.168.1.11
# комментарий

srv03 192.168.1.12
$ while read -r name ip; do
>     [[ -z $name || $name == \#* ]] && continue     # пустые строки и комментарии
>     echo "$name → $ip"
> done < hosts.txt
srv01 → 192.168.1.10
srv02 → 192.168.1.11
srv03 → 192.168.1.12

read с несколькими переменными делит строку по пробелам: первое слово — в name, остаток — в ip. Целую строку читают в одну переменную, а чтобы не потерять пробелы в начале и конце — while IFS= read -r line.

Если внутри while read вызывается ssh или другая команда, читающая stdin, она «съест» оставшиеся строки файла, и цикл выполнится один раз. Решения: ssh -n … или читать файл через другой дескриптор: while read -r h <&3; do …; done 3< hosts.txt.
04

Ловушка конвейера

Каждая часть конвейера выполняется в отдельной подоболочке — копии оболочки в своём процессе. Переменные, изменённые в цикле справа от |, исчезают вместе с этим процессом:

$ count=0
$ grep -v "^#" hosts.txt | while read -r l; do count=$((count+1)); done
$ echo "строк: $count"
строк: 0

Решение — подстановка процесса <(команда): вывод команды подаётся циклу как файл, а сам цикл остаётся в текущей оболочке:

$ count=0
$ while read -r l; do count=$((count+1)); done < <(grep -v "^#" hosts.txt)
$ echo "строк: $count"
строк: 4

Пробел между < и <( обязателен: первое — перенаправление, второе — подстановка процесса. Пустая строка тоже посчиталась — её фильтровали бы условием в теле.

05

while и until: ждать события

while условие повторяет, пока условие истинно; until условие — пока ложно. Типичная задача — дождаться, пока служба поднимется, но не бесконечно:

#!/bin/bash
# wait-port.sh ХОСТ ПОРТ — ждать открытия порта до 30 секунд
host=$1 port=$2 tries=0
until timeout 1 bash -c "</dev/tcp/$host/$port" 2>/dev/null; do
    (( ++tries >= 30 )) && { echo "порт $port не открылся" >&2; exit 1; }
    sleep 1
done
echo "порт $port открыт через $tries с"

/dev/tcp/хост/порт — возможность bash открыть TCP-соединение без внешних программ; если порт закрыт, команда завершится ошибкой. { …; } группирует команды, чтобы обе выполнились после &&. Цикл без счётчика попыток в скрипте для cron — источник зависших процессов.

$ ./wait-port.sh 127.0.0.1 22
порт 22 открыт через 0 с
06

break и continue

continue переходит к следующему шагу цикла, break выходит из цикла совсем:

$ for i in {1..10}; do
>     (( i % 2 == 0 )) && continue    # чётные пропустить
>     (( i > 7 )) && break          # после 7 остановиться
>     printf "%s " $i
> done; echo
1 3 5 7

Приём «ранний выход» делает тело цикла плоским: сначала отсеять неподходящее через continue, потом основное действие без вложенных if.

07

Цикл по серверам

Соберём изученное: пройти по списку серверов из файла, на каждом выполнить команду по SSH и подвести итог.

#!/bin/bash
# uptime-all.sh [ФАЙЛ] — uptime всех серверов из списка
list=${1:-servers.txt}
ok=0 fail=0
while read -r host; do
    [[ -z $host || $host == \#* ]] && continue
    if out=$(ssh -n -o ConnectTimeout=5 -o BatchMode=yes "$host" uptime 2>&1); then
        echo "$host: $out"
        ok=$((ok + 1))
    else
        echo "$host: ОШИБКА — $out" >&2
        fail=$((fail + 1))
    fi
done < "$list"
echo "успешно: $ok, с ошибкой: $fail"
(( fail == 0 ))
$ ./uptime-all.sh; echo "код $?"
web01:  10:42:17 up 37 days,  2:05,  0 users,  load average: 0,08, 0,03, 0,01
nohost.invalid: ОШИБКА — ssh: Could not resolve hostname nohost.invalid: Name or service not known
успешно: 1, с ошибкой: 1
код 1

Детали, которые отличают рабочий скрипт от учебного: ssh -n не читает stdin (см. раздел 03), BatchMode=yes не даёт ssh зависнуть на вопросе пароля, ConnectTimeout ограничивает ожидание, if out=$(…) проверяет код команды и сохраняет её вывод, а последняя строка (( fail == 0 )) делает код возврата скрипта ненулевым, если хоть один сервер не ответил.

Ключевые выводы модуля

Запомните главное

  • Файлы — шаблоном for f in *.log, переменная — "$f"; никогда $(ls)
  • Пустой шаблон — shopt -s nullglob или [[ -e $f ]] || continue
  • Файл построчно — while IFS= read -r line; do …; done < файл
  • Переменные, изменённые в цикле после |, теряются; используйте done < <(команда)
  • Ожидание — until со счётчиком попыток; внутри циклов с чтением файла — ssh -n
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Модуль 03: Условия Все модули Модуль 05: Функции