Bash скрипты: Общая информация по работе с циклами

Общая информация по работе с тремя типами циклов в bash (for, while, until), их практическому применению в среде веб-хостинга, оптимизации производительности и типичным ошибкам. Материал рассчитан как на начинающих администраторов, так и на опытных инженеров, желающих систематизировать знания. Данную информацию можно использовать для автоматизации бэкапов, ротации логов, мониторингу ресурсов, массовому управлению файлами пользователей, обработки очередей задач.

Данная информация предназначена для услуг: VPS хостинг или Облачный хостинг

Синтаксис и терминология

Прежде чем погружаться в примеры, давайте разберемся, как bash интерпретирует циклы.

Структура цикла

Любой цикл в bash состоит из трёх обязательных частей:

  1. Ключевое слово (for, while, until), объявляющее тип цикла.

  2. Условие или список (например, список файлов или числовое условие).

  3. Тело цикла — блок команд, заключённый между do и done.

Важное правило: bash чувствителен к пробелам. После [ и перед ] обязательно нужны пробелы. Точка с запятой ; используется для разделения команд на одной строке.

Ключевые отличия циклов

 
Тип цикла Условие выполнения Аналог из других языков Типичное применение
for Перебор готового списка foreach Обработка файлов, элементов массива, аргументов
while Пока условие истинно while Чтение файлов построчно, ожидание изменения состояния
until Пока условие ложно (до момента, как станет истинным) do-while (перевёрнутое) Ожидание запуска сервиса, повторение до успеха


Помните: until противоположен while. Если while работает, пока true, то until работает, пока false.

Цикл for: Перебор элементов

Это самый популярный и интуитивно понятный цикл. Он проходит по каждому элементу из указанного списка и выполняет для него блок команд.

Синтаксис с генерацией списков

for переменная in список_значений
do
  команды с $переменная
done

Список значений можно задавать разными способами:
  • Явный перечень: for i in 1 2 3 4 5

  • Диапазон: for i in {1..10} (с шагом 1) или for i in {0..100..10} (шаг 10)

  • Вывод команды: for file in $(ls *.log)

  • Диапазон с ведущими нулями: for i in {001..010} (удобно для именования файлов)

Пример 1: Массовое создание резервных копий конфигов

В работе хостинга часто нужно заархивировать все конфигурационные файлы определённого типа. Допустим, у нас есть каталог /etc/nginx/sites-available/ с множеством файлов .conf:

#!/bin/bash
BACKUP_DIR="/root/nginx_backups"
mkdir -p "$BACKUP_DIR"

for conf_file in /etc/nginx/sites-available/*.conf
do
  # Извлекаем имя файла без пути
  filename=$(basename "$conf_file")
  # Создаём копию с текущей датой
  cp "$conf_file" "$BACKUP_DIR/${filename}.$(date +%Y%m%d).bak"
  echo "Скопирован: $conf_file -> $BACKUP_DIR/${filename}.$(date +%Y%m%d).bak"
done

echo "Всего обработано файлов: $(ls /etc/nginx/sites-available/*.conf | wc -l)"

 

Что здесь важно: Мы использовали $() для подстановки вывода команды basename и date. Цикл сам разберётся с именами, даже если в них содержатся пробелы (при использовании *.conf bash подставляет каждый файл как отдельный элемент).

Пример 2: Классический C-стиль (для числовых итераций)

Bash поддерживает синтаксис, похожий на язык C, что удобно для сложных арифметических последовательностей:

#!/bin/bash
# Суммируем числа от 1 до 100 с шагом 3
sum=0
for ((i=1; i<=100; i+=3))
do
  ((sum += i))
done
echo "Сумма: $sum"

 

Примечание: Двойные круглые скобки (( ... )) — это арифметическое вычисление в bash. Внутри них не нужен знак $ перед переменными.

Подводные камни for при работе с именами файлов

Если в вашем каталоге есть файлы с пробелами (например, my backup file.log), цикл for file in $(ls) или for file in * разобьёт их на несколько итераций. Правильный способ — использовать специальный синтаксис с find и while, либо установить корректный разделитель полей IFS:

#!/bin/bash
# Исправляем IFS, чтобы bash не разбивал строки по пробелам
SAVEIFS=$IFS
IFS=$'\n' # Разделитель только перевод строки

for file in *.log
do
  echo "Обработка: $file"
done

IFS=$SAVEIFS # Восстанавливаем IFS

 

Всегда используйте for file in * или for file in ./*.log вместо $(ls), так как ls предназначен для вывода на экран, а не для передачи данных в циклы.

Цикл while: Работа до изменения условия

Цикл while проверяет условие перед каждой итерацией. Если условие истинно (код возврата 0), тело выполняется. Как только условие становится ложным (код возврата не 0), цикл завершается.

Синтаксис и конструкции условий

while [ условие ]
do
  команды
done

Условия обычно проверяются с помощью:
  • Числовые сравнения: [ $count -lt 10 ] (less than), -gt (greater), -eq (equal), -ne (not equal).

  • Строковые: [ "$name" == "root" ][ -z "$var" ] (пустая строка).

  • Файловые тесты: [ -f /etc/passwd ] (существует ли файл), [ -d /backup ] (директория), [ -r "$file" ] (читаемый).

Пример 1: Мониторинг загрузки диска до критического порога

Представьте, что ваш скрипт должен архивировать логи до тех пор, пока занятое место на диске не упадёт ниже 80%. Здесь while идеален:

#!/bin/bash
THRESHOLD=80
LOG_DIR="/var/log/httpd"
TEMP_ARCHIVE="/tmp/logs_archive"

while [ $(df -h / | awk 'NR==2 {print $5}' | sed 's/%//') -gt $THRESHOLD ]
do
  echo "ВНИМАНИЕ: Занято более ${THRESHOLD}%. Выполняем ротацию логов."

  # Находим самый старый .log файл и архивируем
  OLDEST_LOG=$(find $LOG_DIR -name "*.log" -type f -printf '%T@ %p\n' | sort -n | head -1 | cut -d' ' -f2-)
  if [ -n "$OLDEST_LOG" ]; then
    gzip "$OLDEST_LOG"
    echo "Заархивирован: $OLDEST_LOG"
  else
    echo "Нет больше логов для очистки, но диск всё ещё переполнен!"
    break
  fi
  sleep 60 # Пауза 1 минута перед следующей проверкой
done

echo "Уровень заполнения диска в норме."

 

Пример 2: Построчное чтение файла (CSV, логи)

Самый частый сценарий while в администрировании — чтение больших файлов. Это в разы эффективнее, чем cat file | while, потому что не создаёт подпроцесс.

#!/bin/bash
# Чтение списка доменов для создания FTP-пользователей на хостинге
INPUT_FILE="/root/new_domains.csv"
while IFS=',' read -r domain username quota_mb
do
  # Пропускаем строки комментариев и пустые строки
  [[ "$domain" =~ ^#.* ]] && continue
  [[ -z "$domain" ]] && continue

  echo "Создаю хостинг для $domain с пользователем $username (квота: $quota_mb MB)"
  # Здесь могла бы быть команда: useradd -m -d "/var/www/$domain" $username
  # или вызов API панели управления хостингом
done < "$INPUT_FILE"

Ключевые моменты:

  • IFS=',' временно меняет разделитель полей на запятую.

  • read -r предотвращает экранирование обратных слешей.

  • Перенаправление done < "$INPUT_FILE" подаёт файл на вход всему циклу.

Бесконечные циклы и выход по условию

Бесконечный цикл while true использует команду true, которая всегда возвращает 0. Выход осуществляется через break или exit.

#!/bin/bash
# Скрипт-демон, который перезапускает PHP-FPM, если он упал
while true
do
  if ! pgrep -x "php-fpm" > /dev/null
  then
    echo "$(date): PHP-FPM не отвечает. Выполняю перезапуск." | tee -a /var/log/php_monitor.log
    systemctl restart php-fpm
    sleep 10
  fi
  sleep 30
done

 

Предупреждение: Если вы запускаете такой скрипт в интерактивной сессии, не забудьте запустить его в фоне (script.sh &) или с использованием nohup, чтобы он не завершился при закрытии терминала.

Цикл until: Противоположность while

Синтаксис until полностью идентичен while, но логика обратная. Цикл выполняется до тех пор, пока условие ложно (т.е. пока команда возвращает ненулевой код). Как только условие становится истинным — цикл прекращается.

Когда использовать until?

  • Ожидание запуска процесса.

  • Ожидание появления файла.

  • Повторение команды до успешного выполнения (например, до успешного ответа от API).

Пример: Ожидание появления сетевого интерфейса

После перезагрузки сервера сетевой интерфейс eth1 может подниматься несколько секунд. Скрипт должен ждать, пока он не появится.

#!/bin/bash
INTERFACE="eth1"
echo "Ожидаем появление интерфейса $INTERFACE..."

until [ -d "/sys/class/net/$INTERFACE" ]
do
  echo -n "."
  sleep 2
done

echo -e "\nИнтерфейс $INTERFACE обнаружен. Поднимаем его."
ip link set $INTERFACE up

Отличие от while на примере

Сравните:

# Использование while (пока не готов - НЕ будет работать)
count=0
while [ $count -ne 5 ] # Остановится, когда count станет 5
do
  echo "Count = $count"
  ((count++))
done

# Использование until (пока не стал равен 5)
count=0
until [ $count -eq 5 ] # Остановится, когда count станет 5
do
  echo "Count = $count"
  ((count++))
done

Оба цикла выведут числа от 0 до 4. Разница только в читаемости: иногда логичнее написать until ping -c1 google.com (жди, пока пинг не пройдёт), чем while ! ping -c1 google.com.

Управление циклами: break, continue, exit

Чтобы сделать циклы гибкими, bash предоставляет три команды управления:

  • break — немедленно выходит из текущего цикла. Используется при обнаружении ошибки или достижении цели.

  • continue — пропускает оставшуюся часть тела цикла для текущей итерации и переходит к следующей.

  • exit — полностью завершает скрипт. В отличие от break, выходит даже за пределы всех вложенных циклов.

Пример с continue: Пропуск определённых файлов

Допустим, мы обрабатываем все .php файлы в директории хостинга, но хотим пропустить файлы, содержащие слово "backup":

#!/bin/bash
for php_file in /var/www/*/public_html/*.php
do
  # Если в имени файла есть 'backup', пропускаем
  if [[ "$php_file" == *"backup"* ]]; then
    echo "Пропускаем (бэкап): $php_file"
    continue
  fi

  echo "Проверяем синтаксис PHP: $php_file"
  php -l "$php_file"
done

 

Пример с break: Поиск первого доступного порта

Скрипт ищет свободный порт для нового веб-приложения, начиная с 8080:

#!/bin/bash
START_PORT=8080
MAX_PORT=8090

for ((port=$START_PORT; port<=$MAX_PORT; port++))
do
  if ! ss -tln | grep -q ":$port " ; then
    echo "Найден свободный порт: $port"
    break
  fi
  echo "Порт $port занят, проверяю следующий..."
done

if [ $port -gt $MAX_PORT ]; then
  echo "Ошибка: нет свободных портов в диапазоне $START_PORT-$MAX_PORT" >&2
  exit 1
fi

Выход из нескольких циклов

Вложенные циклы требуют осторожности. break по умолчанию выходит только из самого внутреннего цикла. Чтобы выйти из двух уровней, используйте break 2:

#!/bin/bash
while read -r domain_list
do
  for subdomain in www mail ftp
  do
    if ! host "$subdomain.$domain_list" > /dev/null 2>&1; then
      echo "DNS запись $subdomain.$domain_list отсутствует. Прерываю всё."
      break 2 # Выход из for И while
    fi
  done
done < domains.txt

Продвинутые техники для хостинга

Перейдём к сложным и полезным конструкциям, которые реально встречаются в production-среде.

Работа с ассоциативными массивами

Bash поддерживает ассоциативные массивы (ключ → значение) с версии 4.0. Это незаменимо при обработке конфигураций.

#!/bin/bash
declare -A SITE_OWNERS=(
  ["example.com"]="user1"
  ["testsite.org"]="user2"
  ["myblog.net"]="user3"
)

# Перебираем все ключи ассоциативного массива
for domain in "${!SITE_OWNERS[@]}"
do
  owner="${SITE_OWNERS[$domain]}"
  echo "Домен: $domain, Владелец: $owner"
  # Проверяем квоту диска для владельца
  quota -s "$owner"
done

 

Циклы в одну строку (для терминала)

Для быстрых задач не нужно писать полноценный скрипт. Однострочные циклы:

# Быстро изменить права для всех PHP-файлов
for f in *.php; do chmod 644 "$f"; done

# Переименовать все .htm в .html
for f in *.htm; do mv "$f" "${f%.htm}.html"; done

# Запустить проверку SSL для всех доменов из файла
while read d; do echo -n "$d: "; openssl s_client -connect $d:443 -servername $d < /dev/null 2>/dev/null | openssl x509 -noout -dates; done < domains.txt

 

Параллельное выполнение и фоновые задачи

Обычный цикл выполняет команды последовательно, что медленно для 1000 сайтов. Можно ускорить, запуская задачи в фоне (&) с последующим ожиданием wait.

#!/bin/bash
# Параллельная архивация домашних директорий пользователей
USERS=("user1" "user2" "user3" "user100")
BACKUP_DATE=$(date +%Y%m%d)

for user in "${USERS[@]}"
do
  (
    echo "Архивирую $user... (PID $$)"
    tar -czf "/backup/${user}_${BACKUP_DATE}.tar.gz" "/home/$user" 2>/dev/null
    echo "Готово для $user"
  ) & # Запускаем в фоне
done

wait # Дожидаемся завершения всех фоновых задач
echo "Все бэкапы завершены."

 

Важно: Не запускайте десятки тысяч параллельных процессов — это убьёт сервер. Используйте xargs -P или реализуйте очередь.

Чтение вывода команды с read (без fork)

Более элегантный способ обработки результатов findgrep или ls без потери пробелов:

#!/bin/bash
# Ищем все .conf файлы и копируем их с изменением расширения
find /etc -name "*.conf" -type f -print0 | while IFS= read -r -d '' file
do
  target="/backup/configs/$(basename "$file" .conf).cfg"
  cp "$file" "$target"
  echo "Сконвертирован: $file -> $target"
done

Конструкция -print0 и read -d '' позволяет корректно обрабатывать файлы с любыми спецсимволами, включая переводы строк.

Производительность и подводные камни

Почему for i in $(cat file) — это плохо?

# ПЛОХО (медленно и опасно)
for line in $(cat huge_file.txt)
do
  echo $line
done

# ХОРОШО (быстро и стандартно)
while IFS= read -r line
do
  echo "$line"
done < huge_file.txt

 

Причина: $(cat file) сначала загружает ВЕСЬ файл в память, разбивает его по символам IFS (пробелы, табуляции, переводы строк) и только потом передаёт в цикл. Для больших логов это приведёт к нехватке памяти. while read обрабатывает файл потоково, построчно.

Проблема с подстановочными знаками при отсутствии файлов

Если в каталоге нет файлов *.log, цикл for file in *.log выполнится один раз, и $file будет содержать строку "*.log". Как защититься:

#!/bin/bash
shopt -s nullglob # Включаем режим "если нет файлов, не подставлять шаблон"
for file in *.log
do
  [ -e "$file" ] || continue # Дополнительная проверка
  echo "Обработка $file"
donesudo ipset create block_ru hash:net

Избыточный вызов внешних команд

Внутри цикла каждый вызов sedawkgrep порождает новый процесс. Для узких мест используйте встроенные возможности bash:

# Медленно (1000 вызовов awk)
while read line; do
  echo "$line" | awk '{print $1}'
done

# Быстро (один вызов awk)
awk '{print $1}' data.txt

# Ещё быстрее (встроенное разбиение bash)
while read first rest; do
  echo "$first"
done < data.txt

 

Утечка файловых дескрипторов

При использовании перенаправления внутри цикла всегда закрывайте дескрипторы:

# Плохо: файл открывается 1000 раз, но не закрывается до конца цикла
for i in {1..1000}; do
  cat /proc/cpuinfo > /dev/null
done

# Хорошо: используйте exec, чтобы держать дескриптор открытым
exec 3< /proc/cpuinfo
for i in {1..1000}; do
  cat <&3 > /dev/null
done
exec 3<&-

Отладка

Режим отладки bash

Перед запуском критического скрипта включите режим трассировки:

#!/bin/bash -x
# или внутри скрипта:
set -x # Включить подробный вывод
for i in {1..3}; do echo $i; done
set +x # Выключить

 

Другие полезные опции:

  • set -e — выход при любой ошибке (ненулевой код возврата).

  • set -u — ошибка при использовании неопределённой переменной.

  • set -o pipefail — код возврата пайпа равен коду последней упавшей команды.

Правила именования и форматирования для команды

  1. Используйте lowercase для локальных переменных, UPPERCASE для окружения.

  2. Всегда заключайте переменные в кавычки"$var" — защита от пробелов и глоббинга.

  3. Проверяйте наличие аргументов и файлов перед входом в цикл.

  4. Лимитируйте время выполнения для критических циклов через timeout или внутренний таймер.

Логирование

Производственный цикл обязательно должен писать в лог:

#!/bin/bash
LOG_FILE="/var/log/hosting_automation.log"
exec 3>&1 1>>"$LOG_FILE" 2>&1 # Перенаправляем stdout и stderr в лог

echo "$(date): Начинаем массовую операцию"
for site in /var/www/*; do
  echo "Обработка $site"
  # ... команды
done | tee /dev/fd/3 # Дублируем вывод в консоль и лог

Часто задаваемые вопросы (FAQ)

Вопрос 1: Можно ли использовать break внутри случая case?

Да, break внутри case выходит из цикла, в который вложен case. Если case не внутри цикла, break вызовет синтаксическую ошибку.

Вопрос 2: Как передать в цикл аргументы командной строки?

#!/bin/bash
for arg in "$@" # или просто for arg
do
  echo "Аргумент: $arg"
done

 

Используйте "$@" (в кавычках), чтобы сохранить аргументы с пробелами. $* (без кавычек) объединит всё в одну строку.

Вопрос 3: Почему после ssh внутри цикла читается только первая строка?

# Проблема: ssh "съедает" весь stdin
while read host; do
  ssh user@$host "uptime" # Забирает остаток файла
done < hosts.txt

# Решение: перенаправить ssh из /dev/null или использовать другой дескриптор
while read host <&3; do
  ssh user@$host "uptime" </dev/null
done 3< hosts.txt

 

Вопрос 4: Как изменить переменную цикла внутри скрипта?

Переменные цикла в bash передаются по значению. Модификация i внутри цикла не влияет на следующую итерацию. Для C-стиля for ((i=1; i<=5; i++)) изменение i сработает, потому что это арифметический контекст.

Заключение

Циклы в bash — это не просто синтаксическая конструкция, а фундамент автоматизации работы хостинга. Освоив forwhile и until, вы сможете создавать сложные пайплайны обработки данных, управлять тысячами файлов и реагировать на изменения состояния системы в реальном времени.

Ключевые выводы:

  • for — для перебора готовых списков (файлы, массивы, диапазоны).

  • while — для построчного чтения и ожидания изменения состояния.

  • until — альтернатива while с отрицательной логикой.

  • Всегда учитывайте производительность: избегайте $(cat) внутри циклов.

  • Используйте breakcontinue и вложенные перенаправления для гибкого управления.

  • Отлаживайте скрипты с помощью set -x и проверяйте на файлах с пробелами в именах.

  • 0 Пользователи нашли это полезным

Помог ли вам данный ответ?

Ищете что-то другое?

xvps.ru