Кирилл

Вечкасов

Статьи
Наши курсы

Кирилл

Вечкасов

>

>

Обнолвение n8n в Easy Panel

Скрипт автоматического обновления n8n в Docker Swarm

Я активно использую n8n через Easypanel для автоматизации бизнес-процессов. Столкнулся с проблемой: после каждого обновления контейнера все сохранённые credentials (API ключи, токены доступа, пароли от сервисов) слетали. Приходилось вручную заходить и заново настраивать все подключения к Telegram, Google Sheets, базам данных и другим сервисам.
Обновлять n8n нужно было регулярно - выходят новые фичи, исправления безопасности, но каждый раз тратить 20-30 минут на восстановление credentials было неприемлемо.

Покопавшись в проблеме, понял что дело в отсутствии постоянного ключа шифрования (N8N_ENCRYPTION_KEY). При пересоздании контейнера генерировался новый ключ, и старые зашифрованные данные становились нечитаемыми.

Решил автоматизировать процесс: написал bash-скрипт, который не только обновляет n8n, но и проверяет защиту данных перед обновлением. Бонусом добавил отправку уведомлений в Telegram - теперь вижу когда и что обновилось, не заходя на сервер.

Скрипт работает уже несколько месяцев в продакшене, обновляет два экземпляра n8n каждую ночь. Ни одного слёта credentials. Делюсь решением - возможно, кому-то пригодится.

Установка

1. Создать файл

nano /usr/local/bin/n8n_update.sh

Вставить код скрипта (расположенный ниже), сохраните. Или скопирайте код, создайте новый текстовый файл вручную и разместите его тут "/usr/local/bin/"

2. Выдайте права на выполнение

chmod +x /usr/local/bin/n8n_update.sh

 3. Добавьте в автозапуск данный файл

(crontab -l 2>/dev/null; echo "0 0 * * * /usr/local/bin/n8n_update.sh") | crontab -

Скрипт для установки

Если требуется отправка уведомлений в личную ТГ группы, то в SEND_TO_TG выставьте true, а вместо "***" вставьте Токен своего бота и ИД чата куда отправлять сообщения об обновлении.

cat > /usr/local/bin/n8n_maintenance.sh << 'EOF'
#!/bin/bash
set -euo pipefail

export TZ=Europe/Moscow

# ====================
# НАСТРОЙКИ
# ====================

# Telegram
BOT_TOKEN="***"
CHAT_ID="-***"
SEND_TO_TG=false

# n8n обновление
IMAGE_NAME="n8nio/n8n:latest"
VERSION_FILE="/var/log/n8n_version.txt"

# Бэкапы баз данных
POSTGRES_URL="postgres://postgres:asdasda123dasd@kirill:5432/vechkasov?sslmode=disable"

BACKUP_HOST="p650555.backup.ihc.ru"
BACKUP_USER="asdasda123dasd"
BACKUP_PASS="asdasda123dasd"
REMOTE_DIR="backup_easy_panel"

# Общие настройки
DATE=$(date +%Y-%m-%d)
TMP_DIR="/tmp/n8n_maintenance"
LOG_FILE="/var/log/n8n_maintenance.log"
mkdir -p "$TMP_DIR"

LOG_SUMMARY=""

# ====================
# ФУНКЦИИ
# ====================

send_telegram() {
    local message="$1"
    if [ "$SEND_TO_TG" = true ]; then
        curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
            -d "chat_id=${CHAT_ID}" \
            -d "parse_mode=HTML" \
            --data-urlencode "text=${message}" > /dev/null 2>&1 || true
    fi
}

log_error() {
    local error_message="$1"
    echo "$(date '+%Y-%m-%d %H:%M:%S') - ОШИБКА: ${error_message}" >> $LOG_FILE
    send_telegram "❌ <b>[n8n ОШИБКА]</b> ${error_message}"
}

get_n8n_version() {
    local image="$1"
    docker run --rm --entrypoint="" "$image" n8n --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -n1 || echo "unknown"
}

# ====================
# НАЧАЛО РАБОТЫ
# ====================

echo "========================================" >> $LOG_FILE
echo "$(date '+%Y-%m-%d %H:%M:%S') - Скрипт обслуживания n8n запущен" >> $LOG_FILE

# Проверка Docker
if ! command -v docker &> /dev/null; then
    log_error "Docker не найден в системе"
    exit 1
fi

if ! docker info > /dev/null 2>&1; then
    log_error "Нет доступа к Docker"
    exit 1
fi

# ====================
# БЛОК 1: БЭКАПЫ БАЗ ДАННЫХ
# ====================

echo "$(date '+%Y-%m-%d %H:%M:%S') - [БЭКАП] Начало резервного копирования" >> $LOG_FILE

# PostgreSQL (общий дамп)
if [ -n "${POSTGRES_URL:-}" ]; then
    PG_USER=$(echo "$POSTGRES_URL" | sed -E 's|postgres://([^:]+):.*|\1|')
    PG_PASS=$(echo "$POSTGRES_URL" | sed -E 's|postgres://[^:]+:([^@]+)@.*|\1|')
    PG_DB=$(echo "$POSTGRES_URL"   | sed -E 's|.*/([^/?]+).*|\1|')
    PG_HOST=$(echo "$POSTGRES_URL" | sed -E 's|.*@([^:/]+).*|\1|')

    PG_CONTAINER=$(docker ps --format "{{.Names}}" | grep -m1 "^${PG_HOST}" || true)
    if [ -z "$PG_CONTAINER" ]; then
        PG_CONTAINER=$(docker ps --format "{{.Names}} {{.Image}}" | grep postgres | head -n1 | awk '{print $1}')
    fi

    if [ -n "$PG_CONTAINER" ]; then
        PG_FILE="${TMP_DIR}/psql_full_${PG_DB}_${DATE}.sql.gz"
        echo "$(date '+%Y-%m-%d %H:%M:%S') - [БЭКАП] Дамп PostgreSQL ($PG_DB)" >> $LOG_FILE
        docker exec -e PGPASSWORD="$PG_PASS" "$PG_CONTAINER" pg_dump -U "$PG_USER" "$PG_DB" | gzip > "$PG_FILE"
        SIZE=$(du -m "$PG_FILE" | cut -f1)
        export SSHPASS="$BACKUP_PASS"
        sshpass -e ssh -o StrictHostKeyChecking=no ${BACKUP_USER}@${BACKUP_HOST} "mkdir -p ${REMOTE_DIR}/db" 2>/dev/null || true
        sshpass -e scp -o StrictHostKeyChecking=no "$PG_FILE" ${BACKUP_USER}@${BACKUP_HOST}:${REMOTE_DIR}/db/ 2>/dev/null || true
        LOG_SUMMARY+="✅ PostgreSQL (<code>$PG_DB</code>) — ${SIZE} MB
"
    else
        LOG_SUMMARY+="⚠️ PostgreSQL контейнер не найден
"
    fi
fi

# n8n контейнеры (индивидуальные дампы БЕЗ execution_data)
for N8N_CONTAINER in $(docker ps --format "{{.Names}}" | grep n8n || true); do
    SHORT_NAME=$(echo "$N8N_CONTAINER" | cut -d'.' -f1)
    REMOTE_N8N_DIR="${REMOTE_DIR}/n8n/${SHORT_NAME}"

    echo "$(date '+%Y-%m-%d %H:%M:%S') - [БЭКАП] Обработка n8n: $N8N_CONTAINER" >> $LOG_FILE

    DB_NAME=$(docker exec "$N8N_CONTAINER" printenv DB_POSTGRESDB_DATABASE 2>/dev/null || echo "")
    DB_USER=$(docker exec "$N8N_CONTAINER" printenv DB_POSTGRESDB_USER 2>/dev/null || echo "")
    DB_PASS=$(docker exec "$N8N_CONTAINER" printenv DB_POSTGRESDB_PASSWORD 2>/dev/null || echo "")
    DB_HOST=$(docker exec "$N8N_CONTAINER" printenv DB_POSTGRESDB_HOST 2>/dev/null || echo "")
    DB_PORT=$(docker exec "$N8N_CONTAINER" printenv DB_POSTGRESDB_PORT 2>/dev/null || echo "")
    DB_TYPE=$(docker exec "$N8N_CONTAINER" printenv DB_TYPE 2>/dev/null || echo "")
    DB_SSL=$(docker exec "$N8N_CONTAINER" printenv DB_POSTGRESDB_SSL_DISABLED 2>/dev/null || echo "")

    if [ -n "$DB_NAME" ]; then
        PG_CONTAINER=$(docker ps --format "{{.Names}}" | grep -m1 "^${DB_HOST}" || true)
        if [ -z "$PG_CONTAINER" ]; then
            PG_CONTAINER=$(docker ps --format "{{.Names}} {{.Image}}" | grep postgres | head -n1 | awk '{print $1}' || true)
        fi

        if [ -n "$PG_CONTAINER" ]; then
            PG_FILE="${TMP_DIR}/psql_${DB_NAME}_${DATE}.sql.gz"
            echo "$(date '+%Y-%m-%d %H:%M:%S') - [БЭКАП] Дамп БД $DB_NAME (БЕЗ execution_data)" >> $LOG_FILE
            
            # Дамп БЕЗ таблицы execution_data
            docker exec -e PGPASSWORD="$DB_PASS" "$PG_CONTAINER" pg_dump -U "$DB_USER" "$DB_NAME" \
                --exclude-table-data=execution_data 2>/dev/null | gzip > "$PG_FILE" || true
            
            SIZE=$(du -m "$PG_FILE" 2>/dev/null | cut -f1 || echo "0")

            export SSHPASS="$BACKUP_PASS"
            sshpass -e ssh -o StrictHostKeyChecking=no ${BACKUP_USER}@${BACKUP_HOST} "mkdir -p ${REMOTE_N8N_DIR}" 2>/dev/null || true
            sshpass -e scp -o StrictHostKeyChecking=no "$PG_FILE" ${BACKUP_USER}@${BACKUP_HOST}:${REMOTE_N8N_DIR}/ 2>/dev/null || true
            LOG_SUMMARY+="✅ n8n <code>$SHORT_NAME</code> (<code>$DB_NAME</code>) — ${SIZE} MB
"
        else
            LOG_SUMMARY+="⚠️ n8n <code>$SHORT_NAME</code> — PostgreSQL не найден
"
        fi
    fi

    # Ключ и config.json
    KEY_FILE="${TMP_DIR}/encryption_key_${SHORT_NAME}.txt"
    CONFIG_FILE="${TMP_DIR}/config_${SHORT_NAME}.json"

    # ИСПРАВЛЕНО: Сначала пробуем из переменной окружения, потом из файла config
    ENC_KEY=$(docker exec "$N8N_CONTAINER" printenv N8N_ENCRYPTION_KEY 2>/dev/null || echo "")
    
    if [ -z "$ENC_KEY" ]; then
        ENC_KEY=$(docker exec "$N8N_CONTAINER" cat /home/node/.n8n/config 2>/dev/null | \
            grep '"encryptionKey"' | sed -E 's/.*"encryptionKey": *"([^"]+)".*/\1/' || echo "")
    fi

    echo "$ENC_KEY" > "$KEY_FILE"

    cat > "$CONFIG_FILE" <<CONFIGEOF
{
  "N8N_ENCRYPTION_KEY": "$ENC_KEY",
  "DB_TYPE": "$DB_TYPE",
  "DB_POSTGRESDB_HOST": "$DB_HOST",
  "DB_POSTGRESDB_PORT": "$DB_PORT",
  "DB_POSTGRESDB_DATABASE": "$DB_NAME",
  "DB_POSTGRESDB_USER": "$DB_USER",
  "DB_POSTGRESDB_PASSWORD": "$DB_PASS",
  "DB_POSTGRESDB_SSL_DISABLED": "$DB_SSL"
}
CONFIGEOF

    export SSHPASS="$BACKUP_PASS"
    sshpass -e ssh -o StrictHostKeyChecking=no ${BACKUP_USER}@${BACKUP_HOST} "mkdir -p ${REMOTE_N8N_DIR}" 2>/dev/null || true
    [ -s "$KEY_FILE" ] && sshpass -e scp -o StrictHostKeyChecking=no "$KEY_FILE" ${BACKUP_USER}@${BACKUP_HOST}:${REMOTE_N8N_DIR}/encryption_key.txt 2>/dev/null || true
    [ -s "$CONFIG_FILE" ] && sshpass -e scp -o StrictHostKeyChecking=no "$CONFIG_FILE" ${BACKUP_USER}@${BACKUP_HOST}:${REMOTE_N8N_DIR}/config.json 2>/dev/null || true
done

# Очистка старых бэкапов (>7 дней)
echo "$(date '+%Y-%m-%d %H:%M:%S') - [БЭКАП] Очистка старых дампов (>7 дней)" >> $LOG_FILE
CUTOFF_DATE=$(date -d '7 days ago' +%Y-%m-%d)

sshpass -e ssh -o StrictHostKeyChecking=no ${BACKUP_USER}@${BACKUP_HOST} bash <<SSHEOF
  mkdir -p ${REMOTE_DIR}/db ${REMOTE_DIR}/n8n
  
  find ${REMOTE_DIR}/db -type f -name "*.sql.gz" 2>/dev/null | while read file; do
    FILE_DATE=\$(echo "\$file" | grep -oP '\d{4}-\d{2}-\d{2}' | head -1)
    if [[ "\$FILE_DATE" < "$CUTOFF_DATE" ]]; then
      rm -f "\$file" 2>/dev/null || true
    fi
  done
  
  find ${REMOTE_DIR}/n8n -type f -name "*.sql.gz" 2>/dev/null | while read file; do
    FILE_DATE=\$(echo "\$file" | grep -oP '\d{4}-\d{2}-\d{2}' | head -1)
    if [[ "\$FILE_DATE" < "$CUTOFF_DATE" ]]; then
      rm -f "\$file" 2>/dev/null || true
    fi
  done
SSHEOF

echo "$(date '+%Y-%m-%d %H:%M:%S') - [БЭКАП] Завершено" >> $LOG_FILE

# ====================
# БЛОК 2: ОБНОВЛЕНИЕ N8N
# ====================

echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Проверка обновлений n8n" >> $LOG_FILE

CURRENT_VERSION=$(get_n8n_version "$IMAGE_NAME")
echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Текущая версия: $CURRENT_VERSION" >> $LOG_FILE

PREVIOUS_VERSION=""
if [ -f "$VERSION_FILE" ]; then
    PREVIOUS_VERSION=$(cat "$VERSION_FILE")
fi

LOCAL_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' $IMAGE_NAME 2>/dev/null | sed 's/.*@//' || echo "none")
REMOTE_DIGEST=$(curl -s "https://registry.hub.docker.com/v2/repositories/n8nio/n8n/tags/latest" \
    | grep -o '"digest":"sha256:[^"]*' | head -n1 | cut -d'"' -f4 || echo "")

if [ -z "$REMOTE_DIGEST" ]; then
    echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Не удалось получить удалённый digest" >> $LOG_FILE
else
    echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Локальный: $LOCAL_DIGEST" >> $LOG_FILE
    echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Удалённый: $REMOTE_DIGEST" >> $LOG_FILE
fi

BASE_IMAGE_UPDATED=false
NEW_VERSION=""

if [ "$LOCAL_DIGEST" != "$REMOTE_DIGEST" ] && [ -n "$REMOTE_DIGEST" ] && [ "$REMOTE_DIGEST" != "none" ]; then
    echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Найдено обновление!" >> $LOG_FILE
    
    docker pull $IMAGE_NAME >> $LOG_FILE 2>&1 || true
    
    NEW_VERSION=$(get_n8n_version "$IMAGE_NAME")
    echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Новая версия: $NEW_VERSION" >> $LOG_FILE
    
    if [ "$CURRENT_VERSION" != "$NEW_VERSION" ] && [ "$NEW_VERSION" != "unknown" ]; then
        BASE_IMAGE_UPDATED=true
        echo "$NEW_VERSION" > "$VERSION_FILE"
        LOG_SUMMARY+="
🔄 <b>n8n обновлён:</b> $CURRENT_VERSION → $NEW_VERSION
"
    fi
else
    echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Образ актуален" >> $LOG_FILE
fi

# Обновление сервисов
SERVICES=$(docker service ls --format '{{.Name}}' 2>/dev/null | grep n8n || true)

if [ -z "$SERVICES" ]; then
    LOG_SUMMARY+="
⚠️ Сервисы n8n не найдены"
else
    UPDATED_COUNT=0
    TOTAL_COUNT=0

    for SERVICE_NAME in $SERVICES; do
        TOTAL_COUNT=$((TOTAL_COUNT + 1))
        echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Проверка: $SERVICE_NAME" >> $LOG_FILE
        
        if [ "$BASE_IMAGE_UPDATED" = true ]; then
            if docker service update --image $IMAGE_NAME --with-registry-auth --update-order stop-first "$SERVICE_NAME" >> $LOG_FILE 2>&1; then
                LOG_SUMMARY+="✅ $SERVICE_NAME обновлён
"
                UPDATED_COUNT=$((UPDATED_COUNT + 1))
            else
                LOG_SUMMARY+="❌ $SERVICE_NAME ошибка обновления
"
            fi
        fi
    done

    if [ $UPDATED_COUNT -eq 0 ] && [ "$BASE_IMAGE_UPDATED" = false ]; then
        LOG_SUMMARY+="
📌 <b>Версия n8n:</b> <code>$CURRENT_VERSION</code>"
    fi
fi

# Очистка старых образов
echo "$(date '+%Y-%m-%d %H:%M:%S') - [ОБНОВЛЕНИЕ] Очистка старых образов" >> $LOG_FILE
docker image prune -f >> $LOG_FILE 2>&1 || true

# ====================
# ЗАВЕРШЕНИЕ
# ====================

rm -rf "$TMP_DIR" 2>/dev/null || true
echo "$(date '+%Y-%m-%d %H:%M:%S') - Скрипт завершён" >> $LOG_FILE
echo "========================================" >> $LOG_FILE

# Отправка в Telegram
if [ "${SEND_TO_TG}" = true ]; then
    MESSAGE="🔧 <b>Обслуживание n8n завершено</b>
📅 <code>${DATE}</code>

<b>💾 Бэкапы:</b>
${LOG_SUMMARY}
<i>🗑️ Файлы старше 7 дней удалены</i>"

    curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
         -d "chat_id=${CHAT_ID}" \
         -d "parse_mode=HTML" \
         --data-urlencode "text=${MESSAGE}" > /dev/null 2>&1 || true
fi
EOF

chmod +x /usr/local/bin/n8n_maintenance.sh

echo "✅ Скрипт обновлён! Теперь берёт ключ сначала из ENV, потом из config"
echo "Тестируем..."
sudo /usr/local/bin/n8n_maintenance.sh

Хотите меньше рутины и больше идей?

В социальных сетях я делюсь фишками по n8n, кейсами по рекламе и рабочими инструментами для бизнеса.