Shell-скрипт, который удалил базу данных, и история о том, как ShellCheck мог бы помочь это предотвратить

в 8:02, , рубрики: linux, ruvds_перевод, shell script, Блог компании RUVDS.com, системное администрирование

Сегодня хочу рассказать об одном случае из жизни, когда невинная ошибка при написании скрипта командной оболочки привела к удалению базы данных, используемой в продакшне. Расскажу я и о том, как ShellCheck (инструмент для линтинга и анализа скриптов, выходящий под лицензией GPLv3) мог бы обнаружить эту ошибку и предотвратил бы катастрофу. Да, сразу скажу, что я — автор ShellCheck.

Shell-скрипт, который удалил базу данных, и история о том, как ShellCheck мог бы помочь это предотвратить - 1

Происшествие

Вот — описание того печального происшествия, о котором я хочу рассказать. Следующее я взял из поста на StackOverflow:

Наш разработчик закоммитил код с огромной ошибкой и мы нигде не можем найти нашу базу данных MongoDB. Пожалуйста, спасите нас!!!

Он вошёл на сервер и сохранил следующий код в ~/crontab/mongod_back.sh:

#!/bin/sh
DUMP=mongodump
OUT_DIR=/data/backup/mongod/tmp     // 备份文件临时目录
TAR_DIR=/data/backup/mongod         // 备份文件正式目录
DATE=`date +%Y_%m_%d_%H_%M_%S`      // 备份文件将以备份时间保存
DB_USER=Guitang                     // 数据库操作员
DB_PASS=qq____________              // 数据库操作员密码
DAYS=14                             // 保留最新14夭的备份
TAR_BAK="mongod_bak_$DATE.tar.gz"   // 备份文件命名格式
cd $OUT_DIR                         // 创建文件夹
rm -rf $OUT_DIR/*                   // 清空临时目录
mkdir -p $OUT_DIR/$DATE             // 创建本次备份文件夹
$DUMP -d wecard -u $DB_USER -p $DB_PASS -o $OUT_DIR/$DATE  // 执行备份命令
tar -zcvf $TAR_DIR/$TAR_BAK $OUT_DIR/$DATE       // 将备份文件打包放入正式目
find $TAR_DIR/ -mtime +%DAYS -delete             // 删除14天前的旧备洲

А потом выполнил команду ./mongod_back.sh, после чего последовало множество сообщений об отказе в доступе. Дальше — он нажал Ctrl + C и сервер автоматически выключился.

Потом он связался с AliCloud, инженер подключил диск к другому, работающему серверу, что позволило бы нашему разработчику проверить диск. Далее, он понял, что некоторые папки куда-то пропали, включая /data/, где была база данных MongoDB!

P.S. Он не сделал снепшот диска.

В общем то это — кошмар для любого инженера.

Анализ причин данного происшествия — это интересная задачка, для решения которой достаточно будет базовых навыков написания Shell-скриптов. Если вы хотите попытаться сами во всём разобраться — попробуйте, сейчас — самое время это сделать. А если вам нужны подсказки — вот что выдал ShellCheck после анализа данного скрипта.

Далее я в подробностях разберу этот случай и расскажу о том, как ShellCheck мог бы предотвратить катастрофу.

Что пошло не так?

Вот — код минимального воспроизводимого примера (MCVE), запуск которого позволит любому всерьёз и надолго испортить себе жизнь:

#!/bin/sh
DIR=/data/tmp    // Директория, которую нужно удалить
rm -rf $DIR/*    // Удаление директории

Тут имеется одна фатальная ошибка, которая заключается в том, что последовательность // в shell-скриптах — это не комментарий. Это — путь к корневой директории, аналогичный /.

На некоторых платформах строка с командой rm сама по себе может привести к тяжёлым последствиям, так как она сводится к rm -rf / с ещё несколькими аргументами. Правда, реализация этой команды в наши дни часто такого не позволяет. Случай, о котором я рассказал, произошёл на Ubuntu, а в ней GNU-реализация rm не дала бы выполнить такую команду:

$ rm -rf //
rm: it is dangerous to operate recursively on '//' (same as '/')
rm: use --no-preserve-root to override this failsafe

Система сообщает о том, что опасно проводить рекурсивные действия над директорией // (что то же самое, что и /), и предлагает, если надо, отключить эту защитную меру, с помощью опции --no-preserve-root.

Именно тут в игру вступает команда присвоения значения переменной.

Оболочка воспринимает присвоение значения переменной и команды как две стороны одной медали. Вот что сказано об этом в стандарте POSIX:

«Простая команда» — это последовательность необязательных команд присвоения значений и перенаправлений, расположенных в любом порядке, за которыми, что тоже необязательно, следуют слова и перенаправления. Эта последовательность завершается оператором управления.

(«Простая команда» отличается от «сложной команды», которая представляет собой структуру наподобие инструкций if или циклов for, содержащую одну или большее количество простых или сложных команд.)

Это означает, что var=42 и echo «Hello» — это простые команды. В первой имеется одно необязательное присвоение значения и нет необязательных слов. Во второй нет необязательных присвоений значений, но есть два необязательных слова.

Это так же означает, что в состав отдельной простой команды может входить и то и другое: var=42 echo «Hello».

Если не вдаваться в подробности стандарта, то окажется, что операция присвоения значения переменной в простой команде действительна только при выполнении вызванной команды. А если в простой команде нет имени команды — команда присвоения действует на уровне текущей оболочки. Последнее из вышеприведённых утверждений объясняет конструкцию var=42. А вот с первым утверждением, пожалуй, стоит разобраться.

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

$ echo "$PAGER"  # Показать текущее значение переменной PAGER
less

$ PAGER="head -n 5" man ascii
ASCII(7)       Linux Programmer's Manual      ASCII(7)

NAME
       ascii  -  ASCII character set encoded in octal,
       decimal, and hexadecimal

$ echo "$PAGER"  # Текущее значение PAGER не изменилось
less

Именно это нечаянно и было сделано в вышеописанном случае. Так же, как в предыдущем примере новое значение PAGER действовало лишь во время выполнения команды man, тогда область действия DIR была ограничена //:

$ DIR=/data/tmp    // Директория, которую нужно удалить
bash: //: Is a directory

$ echo "$DIR"  # Переменная не установлена
(эта команда ничего не выводит)

Это значит, что конструкция rm -rf $DIR/* превратилась в rm -rf /* и в итоге её не отловила проверка, ориентированная на rm -rf /.

(А почему команда rm просто не отказывалась бы выполняться и при передаче ей /*? Дело в том, что она не видит конструкцию /*, так как оболочка сначала раскрывает эту конструкцию и команда rm видит лишь /bin /boot /dev /data и так далее. Хотя rm, очевидно, может отказать пользователю и в удалении директорий первого уровня, это уже начнёт мешать правильному применению данной команды, что, в соответствии с философией Unix — тяжкий грех.)

Как ShellCheck мог бы помочь предотвратить эту проблему?

Проанализируем в ShellCheck опасный фрагмент скрипта, который, напомню, выглядит так:

#!/bin/sh
DIR=/data/tmp    // The directory to delete
rm -rf $DIR/*    // Now delete it

Вот что у нас получилось (тут можно поэкспериментировать с интерактивным примером):

$ shellcheck myscript

In myscript line 2:
DIR=/data/tmp    // The directory to delete
                 ^-- SC1127: Was this intended as a comment? Use # in sh.

In myscript line 3:
rm -rf $DIR/*    // Now delete it
       ^----^ SC2115: Use "${var:?}" to ensure this never expands to /* .
       ^--^ SC2086: Double quote to prevent globbing and word splitting.
                 ^-- SC2114: Warning: deletes a system directory.

Мы уже обсудили две проблемы этого скрипта, своевременное обнаружение которых могло бы помочь предотвратить неприятности:

  • Анализатор ShellCheck обратил внимание на то, что первая последовательность //, вероятно, была задумана как комментарий (вики-страница SC1127).
  • ShellCheck указал на то, что вторая последовательность // приведёт к воздействию на системную директорию (вики-страница SC2114).

А третье замечание программы относится к универсальным способам защиты от ошибок. И применение этого совета, даже если оставить без внимания две предыдущих рекомендации, тоже позволило бы предотвратить катастрофу:

  • ShellCheck предложил использовать конструкцию вида rm -rf ${DIR:?}/* для того чтобы остановить выполнение команды в том случае, если переменная по любой причине будет пустой или неустановленной (вики-страница SC2115).

Это свело бы на нет последствия целой плеяды ошибок, способных привести к тому, что переменная окажется пустой, включая echo /tmp | read DIR (подоболочки), DIR= /tmp (неправильная расстановка пробелов) и DIR=$(echo /tmp) (потенциальные сбои форка или команды).

Итоги

Shell-скрипты — это очень удобный инструмент, но при их написании можно допустить массу ошибок. Многие из ошибок, носящих синтаксический характер, которые в других языках обнаружились бы очень быстро, приведя к возникновению сбоя, в нашем случае приводят лишь к тому, что скрипты начинают вести себя неправильно. Последствия неправильного поведения скриптов могут быть практически любыми — от слегка неприятных до катастрофических. Много примеров подобных скриптов можно найти здесь и здесь.

Если в мире есть инструменты для проверки скриптов — почему бы ими не воспользоваться? Даже если (или — даже не «если», а «когда»!) вы редко пишете shell-скрипты, вы можете установить shellcheck, воспользовавшись своим менеджером пакетов, а так же — установить подходящий плагин для своего редактора, вроде Flycheck (Emacs) или Syntastic (Vim) и просто, до определённого момента, обо всём этом забыть.

Когда вы, после установки этих инструментов, будете писать скрипт, редактор автоматически выведет предупреждения и даст советы. Вы можете обращать внимание на сообщения о стилистических погрешностях, а можете и не обращать, но, всё равно, возможно, вам стоит поглядывать на уведомления о неожиданных ошибках и на предупреждения. Это вполне может спасти вашу базу данных.

Совершали ли вы опасные ошибки при написании shell-скриптов?

Shell-скрипт, который удалил базу данных, и история о том, как ShellCheck мог бы помочь это предотвратить - 2
Shell-скрипт, который удалил базу данных, и история о том, как ShellCheck мог бы помочь это предотвратить - 3

Автор: ru_vds

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js