Автоматизация резервного копирования ZFS через API TrueNAS: готовый пайплайн от снапшотов до репликации | AdminWiki
Timeweb Cloud — сервера, Kubernetes, S3, Terraform. Лучшие цены IaaS.
Попробовать

Автоматизация резервного копирования ZFS через API TrueNAS: готовый пайплайн от снапшотов до репликации

09 апреля 2026 13 мин. чтения

Зачем автоматизировать резервное копирование в TrueNAS и что дает этот гайд

Ручное управление ZFS снапшотами в TrueNAS — это не только потеря времени, но и прямой риск потери данных. Человеческий фактор, забывчивость при выполнении рутинных операций, ошибки в командах — всё это может привести к ситуации, когда критически важный снимок не был создан вовремя или старые снапшоты заняли всё свободное пространство. Этот гайд предоставляет готовое, проверенное на практике решение для полной автоматизации жизненного цикла резервных копий — от создания моментального снимка до его репликации на другой сервер и автоматической очистки по заданным правилам.

Вы получите не теорию, а готовые к использованию инструменты:

  • Рабочие скрипты на Python и Bash с полной обработкой ошибок для стабильной работы в production-среде.
  • Настроенный cron-джоб для запуска процесса по расписанию без вашего участия.
  • Систему уведомлений в Telegram или Slack о статусе каждой задачи резервного копирования.
  • Гибкие политики очистки старых снапшотов, которые предотвратят захламление дискового пространства.

Использование REST API TrueNAS вместо веб-интерфейса открывает возможности для глубокой интеграции, сложной логики и безошибочного повторения операций. Это решение экономит десятки часов ручной работы в год и значительно снижает операционные риски.

Что мы будем автоматизировать: обзор пайплайна

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

  1. Создание снапшота: Скрипт отправляет запрос к API TrueNAS для создания моментального снимка указанного датасета. Имя снапшота автоматически включает метку времени (timestamp) для уникальности и удобства сортировки.
  2. Репликация (опционально): Созданный снапшот может быть реплицирован на другой сервер TrueNAS. Это можно сделать либо через запуск встроенной задачи репликации TrueNAS, либо напрямую командами zfs send/zfs receive по SSH.
  3. Валидация успешности: Скрипт проверяет код ответа API после каждой критической операции (создание, репликация, удаление) и логирует результат.
  4. Очистка по политике хранения: Автоматически применяется заданная политика ретеншена (например, «хранить последние 30 дневных снапшотов»). Лишние, устаревшие снапшоты удаляются.
  5. Отправка отчета: По итогам выполнения всего пайплайна отправляется краткое уведомление в выбранный мессенджер с указанием статуса (успех/ошибка), задействованных датасетов и затраченного времени.

Этот замкнутый цикл обеспечивает полную автономность процесса резервного копирования.

Подготовка среды: API ключ, права и тестовая площадка

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

Пошаговая инструкция:

  1. Создание API-ключа в TrueNAS: В веб-интерфейсе перейдите в Account → API Keys и нажмите «Add». Дайте ключу понятное имя, например, «Backup Automation».
  2. Настройка прав (Privileges): Для нашего пайплайна ключу необходимо предоставить следующие права: Datasets (чтение/запись), Snapshot (полный доступ) и Replication (чтение/запись, если планируется использовать встроенные задачи). Не выдавайте права администратора без необходимости.
  3. Используйте тестовый датасет: Прежде чем применять скрипты к продакшен-данным, создайте отдельный тестовый пул или датасет (например, tank/test_backup) для отладки.
  4. Проверка доступности API: Убедитесь, что API доступен с того хоста, где будут запускаться скрипты.

Генерация и безопасное хранение API-ключа TrueNAS

После создания ключ будет показан только один раз. Его необходимо сохранить максимально безопасно. Никогда не храните ключ в открытом виде внутри скриптов, которые могут попасть в системы контроля версий.

Способ 1: Отдельный файл с ограниченными правами (рекомендуется для Bash)

echo "ваш_длинный_api_ключ" > ~/.truenas_api_key
chmod 600 ~/.truenas_api_key  # Только владелец может читать и писать

Затем в скрипте ключ можно прочитать так: API_KEY=$(cat ~/.truenas_api_key).

Способ 2: Переменная окружения (удобно для Python, Docker)

Экспортируйте ключ в сессии или добавьте в файл конфигурации оболочки (~/.bashrc, ~/.zshrc):

export TRUENAS_API_KEY="ваш_длинный_api_ключ"

В Python скрипте получите его через os.environ.get('TRUENAS_API_KEY').

Тестовый прогон: создание и удаление снапшота вручную

Перед автоматизацией убедитесь, что базовая связка работает. Выполните эти команды curl, подставив свои значения:

# Замените TRUENAS_HOST, API_KEY и DATASET на свои
TRUENAS_HOST="192.168.1.100"
API_KEY="ваш_ключ"
DATASET="tank/test_backup"
SNAPSHOT_NAME="manual-test-$(date +%Y%m%d-%H%M%S)"

# 1. Создание снапшота
curl -X POST \
  -H "Authorization: Bearer ${API_KEY}" \
  -H "Content-Type: application/json" \
  -d "{\"dataset\": \"${DATASET}\", \"name\": \"${SNAPSHOT_NAME}\"}" \
  "https://${TRUENAS_HOST}/api/v2.0/zfs/snapshot"

# В ответе должен быть JSON с id созданного снапшота и кодом 201 (Created).
# 2. Удаление созданного снапшота (очистка)
curl -X DELETE \
  -H "Authorization: Bearer ${API_KEY}" \
  "https://${TRUENAS_HOST}/api/v2.0/zfs/snapshot/${DATASET}@${SNAPSHOT_NAME}"

Что делать при ошибках:

  • 401 Unauthorized: Неверный или просроченный API-ключ. Перепроверьте ключ и его срок действия.
  • 403 Forbidden: У ключа недостаточно прав для операции. Проверьте список Privileges в настройках ключа.
  • 404 Not Found: Указанный датасет не существует. Проверьте имя датасета (регистр важен).

Успешное выполнение этих команд — сигнал, что среда готова для автоматизации.

Ядро автоматизации: пишем скрипт для управления снапшотами на Python

Python с библиотекой requests — идеальный выбор для сложной логики взаимодействия с API, обработки ошибок и удобного парсинга JSON. Ниже представлен готовый модульный скрипт, который можно адаптировать под ваши нужды.

#!/usr/bin/env python3

import requests
import json
import logging
from datetime import datetime, timedelta
from typing import List, Optional
import os

# ===== КОНФИГУРАЦИЯ =====
TRUENAS_HOST = os.environ.get("TRUENAS_HOST", "192.168.1.100")
API_KEY = os.environ.get("TRUENAS_API_KEY")  # Безопасное хранение!
DATASETS_TO_BACKUP = ["tank/data", "tank/vms"]  # Список датасетов
RETENTION_DAYS = 30  # Хранить снапшоты за последние N дней

# Настройка логирования
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('/var/log/truenas_backup.log'),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)

# Базовые заголовки для запросов
HEADERS = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
}
BASE_URL = f"https://{TRUENAS_HOST}/api/v2.0"

# ===== ОСНОВНЫЕ ФУНКЦИИ =====

def create_snapshot(dataset: str, snapshot_name: str) -> bool:
    """Создает снапшот указанного датасета через API TrueNAS."""
    url = f"{BASE_URL}/zfs/snapshot"
    payload = {
        "dataset": dataset,
        "name": snapshot_name
    }
    
    try:
        response = requests.post(url, headers=HEADERS, json=payload, verify=True, timeout=30)
        response.raise_for_status()  # Вызовет исключение для кодов 4xx/5xx
        
        if response.status_code == 201:
            logger.info(f"Снапшот создан: {dataset}@{snapshot_name}")
            return True
        else:
            logger.error(f"Неожиданный статус {response.status_code} при создании снапшота: {response.text}")
            return False
            
    except requests.exceptions.RequestException as e:
        logger.error(f"Ошибка сети/API при создании снапшота {dataset}: {e}")
        return False
    except json.JSONDecodeError as e:
        logger.error(f"Не удалось разобрать ответ API: {e}")
        return False

def apply_retention_policy(dataset: str, keep_days: int = RETENTION_DAYS) -> None:
    """Удаляет старые снапшоты датасета, оставляя только за последние keep_days дней."""
    # 1. Получаем все снапшоты датасета
    url = f"{BASE_URL}/zfs/snapshot?query=dataset,dataset_name,id,name,properties.creation.rawvalue&dataset_name={dataset}"
    try:
        response = requests.get(url, headers=HEADERS, verify=True, timeout=30)
        response.raise_for_status()
        snapshots = response.json()
    except requests.exceptions.RequestException as e:
        logger.error(f"Не удалось получить список снапшотов для {dataset}: {e}")
        return
    
    if not snapshots:
        logger.info(f"Для датасета {dataset} не найдено снапшотов.")
        return
    
    # 2. Фильтруем и сортируем
    cutoff_date = datetime.now() - timedelta(days=keep_days)
    snapshots_to_delete = []
    
    for snap in snapshots:
        snap_name = snap.get('name')
        # Извлекаем timestamp из свойства creation (UNIX epoch)
        creation_raw = snap.get('properties', {}).get('creation', {}).get('rawvalue')
        if creation_raw:
            try:
                creation_time = datetime.fromtimestamp(int(creation_raw))
                if creation_time < cutoff_date:
                    snapshots_to_delete.append(snap_name)
            except (ValueError, TypeError) as e:
                logger.warning(f"Не удалось обработать время создания для {snap_name}: {e}")
    
    # 3. Двойная проверка перед удалением (важно!)
    logger.info(f"Для датасета {dataset} найдено {len(snapshots_to_delete)} снапшотов старше {keep_days} дней для удаления.")
    if not snapshots_to_delete:
        return
    
    # 4. Удаление
    deleted_count = 0
    for snap_name in snapshots_to_delete:
        delete_url = f"{BASE_URL}/zfs/snapshot/{snap_name.replace('@', '%40')}"  # Экранирование '@'
        try:
            del_response = requests.delete(delete_url, headers=HEADERS, verify=True, timeout=30)
            if del_response.status_code == 204:
                logger.info(f"Удален снапшот: {snap_name}")
                deleted_count += 1
            else:
                logger.warning(f"Не удалось удалить {snap_name}: код {del_response.status_code}")
        except requests.exceptions.RequestException as e:
            logger.error(f"Ошибка при удалении {snap_name}: {e}")
    
    logger.info(f"Очистка для {dataset} завершена. Удалено: {deleted_count} снапшотов.")

# ===== ТОЧКА ВХОДА =====
if __name__ == "__main__":
    if not API_KEY:
        logger.critical("API_KEY не установлен. Задайте через переменную окружения TRUENAS_API_KEY.")
        exit(1)
    
    overall_success = True
    start_time = datetime.now()
    
    for dataset in DATASETS_TO_BACKUP:
        snapshot_name = f"auto-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
        logger.info(f"Обработка датасета: {dataset}")
        
        # Шаг 1: Создание снапшота
        if create_snapshot(dataset, snapshot_name):
            # Шаг 2: Применение политики хранения
            apply_retention_policy(dataset)
        else:
            overall_success = False
            logger.error(f"Создание снапшота для {dataset} не удалось, очистка пропущена.")
    
    elapsed = datetime.now() - start_time
    status = "УСПЕХ" if overall_success else "ОШИБКА"
    logger.info(f"Пайплайн завершен. Статус: {status}. Затрачено времени: {elapsed}")
    # Здесь позже добавим отправку уведомления

Этот скрипт — готовый каркас. Его можно расширить, добавив аргументы командной строки (argparse) для большей гибкости или интеграцию с системой конфигурации (YAML, JSON).

Альтернатива: Bash-скрипт с curl и jq для быстрого внедрения

Для минималистичных окружений или если вы предпочитаете Bash, вот компактный скрипт, выполняющий те же задачи. Для работы с JSON потребуется утилита jq.

#!/bin/bash

# Конфигурация
TRUENAS_HOST="192.168.1.100"
API_KEY=$(cat ~/.truenas_api_key)  # Ключ из защищенного файла
DATASET="tank/data"
RETENTION_DAYS=30
LOG_FILE="/var/log/truenas_backup.log"

# Функция для логгирования
log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}

# 1. Создание снапшота с timestamp
SNAPSHOT_NAME="auto-$(date +%Y%m%d-%H%M%S)"
log "Создание снапшота для $DATASET..."

RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"dataset\": \"$DATASET\", \"name\": \"$SNAPSHOT_NAME\"}" \
  "https://$TRUENAS_HOST/api/v2.0/zfs/snapshot")

HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | sed '$d')

if [[ "$HTTP_CODE" -eq 201 ]]; then
    log "Снапшот $DATASET@$SNAPSHOT_NAME успешно создан."
else
    log "ОШИБКА: Не удалось создать снапшот. Код: $HTTP_CODE. Ответ: $BODY"
    exit 1
fi

# 2. Получение списка снапшотов и применение политики хранения
log "Применение политики хранения ($RETENTION_DAYS дней)..."
CUTOFF_TIMESTAMP=$(date -d "-$RETENTION_DAYS days" +%s)

# Получаем список снапшотов и их время создания
SNAPSHOT_LIST=$(curl -s -H "Authorization: Bearer $API_KEY" \
  "https://$TRUENAS_HOST/api/v2.0/zfs/snapshot?query=dataset,dataset_name,id,name,properties.creation.rawvalue&dataset_name=$DATASET")

# Используем jq для фильтрации: оставляем только имена снапшотов старше CUTOFF_TIMESTAMP
TO_DELETE=$(echo "$SNAPSHOT_LIST" | jq -r --arg cutoff "$CUTOFF_TIMESTAMP" \
  '.[] | select(.properties.creation.rawvalue | tonumber < ($cutoff | tonumber)) | .name')

if [[ -n "$TO_DELETE" ]]; then
    log "Найдено снапшотов для удаления: $(echo "$TO_DELETE" | wc -l)"
    echo "$TO_DELETE" | while read -r SNAP; do
        # Экранируем символ '@' в URL
        SNAP_ESCAPED=${SNAP//@/%40}
        DELETE_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
          -H "Authorization: Bearer $API_KEY" \
          "https://$TRUENAS_HOST/api/v2.0/zfs/snapshot/$SNAP_ESCAPED")
        if [[ "$DELETE_CODE" -eq 204 ]]; then
            log "Удален: $SNAP"
        else
            log "Предупреждение: не удалось удалить $SNAP (код: $DELETE_CODE)"
        fi
    done
else
    log "Нет снапшотов для удаления по заданной политике."
fi

log "Скрипт завершил работу успешно."

Плюсы и минусы подхода на Bash:

  • Плюсы: Легковесность, не требует установки интерпретатора Python, отлично подходит для простых сценариев и cron.
  • Минусы: Сложнее реализовать продвинутую обработку ошибок, вложенную логику и модульность. Работа с JSON через jq может быть менее интуитивной для сложных структур.

Выбор между Python и Bash зависит от сложности ваших задач и личных предпочтений. Для начала можно использовать Bash-скрипт, а по мере роста потребностей переписать логику на Python.

Настройка репликации ZFS снапшотов между системами

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

Сравнение подходов:

  • Встроенные задачи репликации TrueNAS (Replication Task): Высокоуровневый, оптимизированный механизм. Управляется через веб-интерфейс или API. Поддерживает инкрементальную передачу, сжатие, шифрование и валидацию данных. Идеален для репликации между серверами TrueNAS.
  • Прямая репликация через zfs send | zfs receive: Низкоуровневый, гибкий метод. Работает между любыми системами, поддерживающими ZFS (Linux, FreeBSD). Требует настройки SSH-ключей и более глубокого понимания команд ZFS.

Запуск задачи репликации TrueNAS через API

Если у вас уже настроена задача репликации в TrueNAS, её можно запускать по расписанию через API, что удобно для интеграции в общий пайплайн после создания снапшота.

1. Найдите ID задачи репликации:

curl -s -H "Authorization: Bearer $API_KEY" \
  "https://$TRUENAS_HOST/api/v2.0/replication" | jq '.[] | select(.name=="Ваша_Задача_Репликации") | .id'

2. Запустите задачу:

REPLICATION_TASK_ID=1  # Подставьте ваш ID
curl -X POST \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  "https://$TRUENAS_HOST/api/v2.0/replication/run" \
  -d "{\"id\": $REPLICATION_TASK_ID}"

3. Мониторинг статуса: Запрос к /api/v2.0/replication?id=$REPLICATION_TASK_ID покажет состояние задачи (state: SUCCESS, FAILED, RUNNING).

Прямая репликация через SSH (пример для Bash):

Этот метод требует предварительной настройки аутентификации по SSH-ключам между серверами.

# На сервере-источнике (где запускается скрипт)
REMOTE_HOST="backup-server.local"
REMOTE_USER="root"
REMOTE_DATASET="backup/tank_data"
LOCAL_SNAPSHOT="tank/data@auto-20260409-120000"

# Инкрементальная отправка последнего снапшота
# Предполагаем, что предыдущий общий снапшот называется tank/data@parent
zfs send -I tank/data@parent "$LOCAL_SNAPSHOT" | \
  ssh "$REMOTE_USER@$REMOTE_HOST" zfs receive -F "$REMOTE_DATASET"

Для надежности добавьте проверку кода выхода ($?) после каждой команды.

Запуск по расписанию и надежный мониторинг (cron + Telegram/Slack)

Чтобы пайплайн работал полностью автономно, его нужно добавить в планировщик cron и настроить уведомления о результатах.

1. Настройка cron-доба

Откройте crontab для редактирования: crontab -e.

Добавьте строку для ежедневного запуска в 2:00 ночи с логированием вывода:

# Запуск Python-скрипта каждый день в 2:00
0 2 * * * /usr/bin/python3 /path/to/your/truenas_backup.py >> /var/log/truenas_backup_cron.log 2>&1

# Или для Bash-скрипта
# 0 2 * * * /bin/bash /path/to/your/truenas_backup.sh >> /var/log/truenas_backup_cron.log 2>&1

Важно: Cron запускает задачи в минимальном окружении. Убедитесь, что в скрипте используются абсолютные пути к бинарникам (/usr/bin/python3, /usr/bin/curl) и что переменные окружения (особенно TRUENAS_API_KEY) установлены глобально (например, в /etc/environment) или заданы прямо в crontab.

2. Интеграция уведомлений в скрипт

Модифицируйте основной скрипт (Python), добавив функцию отправки отчета.

Интеграция с Telegram Bot API: код и настройка бота

Telegram — один из самых простых и популярных способов получения уведомлений.

Настройка бота (5 минут):

  1. Найдите в Telegram @BotFather, отправьте команду /newbot и следуйте инструкциям.
  2. После создания бота BotFather предоставит токен (например, 1234567890:ABCdefGHIjklMNOpqrsTUVwxyz). Сохраните его.
  3. Найдите своего бота в Telegram и отправьте ему любое сообщение (это нужно для активации).
  4. Чтобы получить chat_id, отправьте GET-запрос (в браузере или curl):
    https://api.telegram.org/bot<ВАШ_ТОКЕН>/getUpdates
    В ответе JSON найдите message.chat.id — это ваш числовой chat_id.

Код функции для Python:

import requests

def send_telegram_notification(message: str, token: str, chat_id: str) -> bool:
    """Отправляет сообщение в Telegram чат через бота."""
    url = f"https://api.telegram.org/bot{token}/sendMessage"
    payload = {
        "chat_id": chat_id,
        "text": message,
        "parse_mode": "HTML"
    }
    try:
        response = requests.post(url, json=payload, timeout=10)
        return response.status_code == 200
    except requests.exceptions.RequestException:
        return False

# Конфигурация (лучше хранить в переменных окружения!)
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID")

# В конце основного скрипта, после завершения пайплайна:
status_msg = f"<b>Резервное копирование TrueNAS</b>\n"
status_msg += f"Статус: {status}\n"
status_msg += f"Датасеты: {', '.join(DATASETS_TO_BACKUP)}\n"
status_msg += f"Затрачено времени: {elapsed}\n"
status_msg += f"Время завершения: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"

if TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
    send_telegram_notification(status_msg, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID)

Для Slack процесс аналогичен: создайте Incoming Webhook в настройках Slack-канала и отправляйте POST-запросы на полученный URL.

Отладка и решение частых проблем

Даже с готовыми скриптами могут возникнуть проблемы при внедрении. Этот чек-лист поможет быстро их диагностировать и устранить.

  1. Ошибка 401/403 при запросах API:
    • Проверьте, что API-ключ скопирован полностью, без лишних пробелов.
    • Убедитесь, что срок действия ключа не истек (в TrueNAS можно задать срок).
    • Перепроверьте права (Privileges) ключа в веб-интерфейсе TrueNAS.
  2. Скрипт работает из консоли, но не из cron:
    • Самая частая причина — разное окружение. Cron не загружает ваш .bashrc или .profile. Либо задайте переменные (TRUENAS_API_KEY, PATH) прямо в crontab, либо используйте абсолютные пути ко всем исполняемым файлам (/usr/bin/python3, /usr/bin/curl).
    • Проверьте права на исполнение скрипта: chmod +x /path/to/script.py.
    • Смотрите логи cron: grep CRON /var/log/syslog или journalctl -u cron.
  3. Не удаляются старые снапшоты:
    • Убедитесь, что политика именования снапшотов в скрипте совпадает с теми, что ищет функция очистки. Если вы создаете снапшоты с префиксом auto-, а очистка ищет все — проблем не будет.
    • Проверьте логику работы с датами в скрипте. Убедитесь, что временная зона (timezone) обрабатывается корректно.
    • Для Bash-скрипта проверьте, что jq корректно извлекает поле properties.creation.rawvalue.
  4. Репликация не запускается или падает:
    • Проверьте сетевое соединение и разрешения брандмауэра между серверами (порты 22 для SSH, 443 для API).
    • Для SSH-репликации убедитесь, что аутентификация по ключам работает без запроса пароля.
    • Для встроенных задач репликации проверьте, что задача активна (не отключена) и у неё указан корректный удаленный хост и данные для аутентификации.
  5. Совместимость и актуальность:
    • Представленные скрипты и API-запросы проверялись и актуальны для TrueNAS CORE 13.0+ и TrueNAS SCALE 22.02+ (Angelfish) и более поздних версий.
    • API TrueNAS развивается. Если вы столкнулись с ошибкой «endpoint not found», проверьте актуальную документацию по API для вашей версии. Основные эндпоинты (/zfs/snapshot, /replication) остаются стабильными.
    • Для работы со сложными сценариями передачи данных, например, выгрузкой готовых резервных копий на внешние FTP-серверы, вам могут пригодиться готовые скрипты для автоматической передачи файлов.

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

Поделиться:
Сохранить гайд? В закладки браузера