[{"content":"","date":"16 марта 2026","externalUrl":null,"permalink":"/tags/amavis/","section":"Теги","summary":"","title":"Amavis","type":"tags"},{"content":"","date":"16 марта 2026","externalUrl":null,"permalink":"/tags/clamav/","section":"Теги","summary":"","title":"Clamav","type":"tags"},{"content":"","date":"16 марта 2026","externalUrl":null,"permalink":"/tags/fail2ban/","section":"Теги","summary":"","title":"Fail2ban","type":"tags"},{"content":"","date":"16 марта 2026","externalUrl":null,"permalink":"/tags/postgrey/","section":"Теги","summary":"","title":"Postgrey","type":"tags"},{"content":"","date":"16 марта 2026","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","date":"16 марта 2026","externalUrl":null,"permalink":"/tags/spamassassin/","section":"Теги","summary":"","title":"Spamassassin","type":"tags"},{"content":"","date":"16 марта 2026","externalUrl":null,"permalink":"/categories/","section":"Категории","summary":"","title":"Категории","type":"categories"},{"content":"","date":"16 марта 2026","externalUrl":null,"permalink":"/","section":"Олег Казанин","summary":"","title":"Олег Казанин","type":"page"},{"content":"","date":"16 марта 2026","externalUrl":null,"permalink":"/series/%D0%BF%D0%BE%D1%87%D1%82%D0%BE%D0%B2%D1%8B%D0%B9-%D1%81%D0%B5%D1%80%D0%B2%D0%B5%D1%80-%D0%BD%D0%B0-debian-12/","section":"Series","summary":"","title":"Почтовый Сервер На Debian 12","type":"series"},{"content":" Защита от спама и вирусов # Почтовый сервер без защиты - это открытые ворота для спама и вирусов. Сейчас настроим многоуровневую оборону, которая отсечет 90%+ мусора еще до попадания в ящики.\nЧетыре уровня защиты:\nPostgrey - отбрасывает спам-ботов на входе (greylisting) ClamAV - проверяет вложения на вирусы SpamAssassin - анализирует содержимое писем Fail2ban - блокирует IP при брутфорсе паролей Перед началом # У тебя должно быть:\nРабочий Postfix + Dovecot из предыдущей части Почтовый сервер на Debian 12: полное руководство от установки до production. Часть 2 - Postfix и Dovecot 10 марта 2026\u0026middot;12 минут Системное Администрирование Электронная Почта Postfix Dovecot Smtp Imap Lmtp Sasl Self-Hosting Минимум 2GB RAM (ClamAV прожорлив) Дисковое пространство для базы вирусов (~500MB) Проверь что Postfix работает:\nsudo systemctl status postfix Установка компонентов # Ставим все сразу # sudo apt install -y \\ amavisd-new \\ clamav \\ clamav-daemon \\ clamav-freshclam \\ spamassassin \\ postgrey Что установили:\namavisd-new - прослойка между Postfix и антивирусом/антиспамом clamav - антивирусный движок clamav-daemon - демон ClamAV для фоновой работы clamav-freshclam - автообновление вирусных баз spamassassin - антиспам фильтр postgrey - greylisting демон Создание необходимых файлов и директорий # Создаем mailname (используется Amavis)\necho \u0026#34;mail.example.com\u0026#34; | sudo tee /etc/mailname Создаем директорию для PID файла Amavis\nsudo mkdir -p /var/run/amavis sudo chown amavis:amavis /var/run/amavis sudo chmod 755 /var/run/amavis Создаем tmpfiles конфиг для автоматического создания директории\nsudo nano /etc/tmpfiles.d/amavis.conf Добавь:\nd /run/amavis 0755 amavis amavis - Примени конфиг:\nsudo systemd-tmpfiles --create Замени mail.example.com на свое полное имя хоста.\nОбновляем базу вирусов # ClamAV нужна актуальная база вирусов:\nsudo systemctl stop clamav-freshclam sudo freshclam sudo systemctl start clamav-freshclam Это займет 2-5 минут. Freshclam скачает ~200-300MB данных.\nПроверь статус:\nsudo systemctl status clamav-freshclam Должен быть active (running).\nНастройка ClamAV # Проверяем что демон запущен # sudo systemctl status clamav-daemon Если не запущен(что скорее всего):\nsudo systemctl enable clamav-daemon sudo systemctl start clamav-daemon Настройка сокета # ClamAV слушает через UNIX-сокет. Проверь:\nls -la /var/run/clamav/clamd.ctl Должен быть сокет с правами для группы clamav.\nДобавь пользователя amavis в группу clamav:\nsudo adduser clamav amavis sudo adduser amavis clamav Перезапусти ClamAV:\nsudo systemctl restart clamav-daemon Настройка Amavis # Amavis - это диспетчер, который принимает письма от Postfix, прогоняет через ClamAV и SpamAssassin, и возвращает обратно.\nОсновной конфиг # Открой:\nsudo nano /etc/amavis/conf.d/15-content_filter_mode Раскомментируй:\n@bypass_virus_checks_maps = ( \\%bypass_virus_checks, \\@bypass_virus_checks_acl, \\$bypass_virus_checks_re); @bypass_spam_checks_maps = ( \\%bypass_spam_checks, \\@bypass_spam_checks_acl, \\$bypass_spam_checks_re); Что сделали: Включили проверку на вирусы и спам.\nНастройка интеграции # Открой:\nsudo nano /etc/amavis/conf.d/50-user Добавь в конец:\n# Домен $mydomain = \u0026#39;example.com\u0026#39;; $myhostname = \u0026#39;mail.example.com\u0026#39;; # Интерфейс $inet_socket_bind = \u0026#39;127.0.0.1\u0026#39;; # Порты $inet_socket_port = 10024; # Политика для локальных доменов $policy_bank{\u0026#39;MYNETS\u0026#39;} = { originating =\u0026gt; 1, os_fingerprint_method =\u0026gt; undef, }; # Антиспам $sa_tag_level_deflt = -999; # Всегда добавлять заголовки $sa_tag2_level_deflt = 5.0; # Помечать как спам при 5+ баллах $sa_kill_level_deflt = 10.0; # Отклонять при 10+ баллах # Антивирус $virus_admin = \u0026#34;postmaster\\@$mydomain\u0026#34;; # Уведомления $virus_quarantine_to = \u0026#34;virus-quarantine\\@$mydomain\u0026#34;; $spam_quarantine_to = \u0026#34;spam-quarantine\\@$mydomain\u0026#34;; # Обязательно в конце (по умолчанию уже присутствует) 1; Замени:\nexample.com на свой домен mail.example.com на свое имя хоста Что настроили:\nОсновные параметры:\nСлушаем на localhost:10024 Домен и hostname для заголовков Антиспам:\n-999 - всегда добавлять X-Spam заголовки 5.0 - при 5+ баллах помечать как спам (X-Spam-Flag: YES) 10.0 - при 10+ баллах отклонять письмо Антивирус:\nВсегда проверять через ClamAV Карантин для вирусов и спама Права на директории # sudo chown -R amavis:amavis /var/lib/amavis sudo chmod 750 /var/lib/amavis Запуск Amavis # sudo systemctl enable amavis sudo systemctl start amavis sudo systemctl status amavis Должен быть active (running).\nПроверь порт:\nsudo ss -tulnp | grep 10024 Должно быть:\ntcp LISTEN 0 4096 127.0.0.1:10024 0.0.0.0:* users:((\u0026#34;/usr/sbin/amavi\u0026#34;,pid=43480,fd=5),(\u0026#34;/usr/sbin/amavi\u0026#34;,pid=43479,fd=5),(\u0026#34;/usr/sbin/amavi\u0026#34;,pid=43456,fd=5)) Настройка SpamAssassin # SpamAssassin работает через Amavis, но нужно настроить его правила.\nОсновной конфиг # Открой:\nsudo nano /etc/spamassassin/local.cf Добавь в самый конец:\n# Требуемый балл для спама required_score 5.0 # Использовать Bayesian фильтр use_bayes 1 bayes_auto_learn 1 # DNSBL проверки use_razor2 0 use_pyzor 0 # Сетевые проверки (SPF, DKIM) use_dcc 0 # Автообучение bayes_auto_learn_threshold_nonspam -0.1 bayes_auto_learn_threshold_spam 6.0 # Путь к базе Bayes bayes_path /var/lib/amavis/.spamassassin/bayes # Язык ok_languages en ru ok_locales en ru # Размер письма для проверки (500KB) report_safe 0 Что настроили:\nrequired_score 5.0:\nПорог для пометки спама Bayesian фильтр:\nОбучаемая модель на основе примеров Автообучение включено DNSBL:\nRazor/Pyzor/DCC отключены (используем встроенные DNSBL) Автообучение:\nПисьма с баллами \u0026lt; -0.1 учатся как не-спам Письма с баллами \u0026gt; 6.0 учатся как спам Создаем директорию для Bayes # sudo mkdir -p /var/lib/amavis/.spamassassin sudo chown -R amavis:amavis /var/lib/amavis/.spamassassin sudo chmod 700 /var/lib/amavis/.spamassassin Запуск SpamAssassin # sudo systemctl enable spamassassin sudo systemctl start spamassassin sudo systemctl status spamassassin Должен быть active (running).\nПерезапуск Amavis # sudo systemctl restart amavis Настройка Postgrey # Postgrey - это greylisting(\u0026ldquo;временная задержка\u0026rdquo;). Принцип: первое письмо от нового отправителя откладывается на 5 минут. Легальные серверы повторят попытку, спам-боты - нет.\nКонфигурация # Открой:\nsudo nano /etc/default/postgrey Найди и измени:\nPOSTGREY_OPTS=\u0026#34;--inet=127.0.0.1:10023 --delay=300\u0026#34; Что настроили:\n--inet=127.0.0.1:10023 - слушать на localhost:10023 --delay=300 - задержка 5 минут (300 секунд) Белые списки # Postgrey имеет встроенные белые списки для крупных отправителей (Google, Microsoft, и т.д.).\nПосмотреть:\ncat /etc/postgrey/whitelist_clients Добавить свои (опционально):\nsudo nano /etc/postgrey/whitelist_clients.local Формат:\n/^.*\\.trusted-domain\\.com$/ 192.168.1.0/24 specific-server.example.com На примере Yandex:\n/^.*\\.yandex\\.ru$/ /^.*\\.ya\\.ru$/ Запуск # sudo systemctl enable postgrey sudo systemctl start postgrey sudo systemctl status postgrey Должен быть active (running).\nПроверь порт:\nsudo ss -tulnp | grep 10023 Должно быть:\ntcp LISTEN 0 5 127.0.0.1:10023 ... Интеграция с Postfix # Сейчас настроим Postfix для прогона всех писем через Amavis и Postgrey.\nНастройка content_filter # Открой:\nsudo nano /etc/postfix/main.cf В конце секции smtpd_recipient_restrictions, созданную на предыдущих этапах, добавь строку:\n# Postgrey для greylisting check_policy_service inet:127.0.0.1:10023 И добавь в конец файла:\n# Content filter через Amavis content_filter = smtp-amavis:[127.0.0.1]:10024 Что добавили:\ncontent_filter:\nВсе письма идут через Amavis на порт 10024 Amavis проверяет через ClamAV и SpamAssassin Возвращает обратно в Postfix на порт 10025 smtpd_recipient_restrictions:\nДобавили check_policy_service inet:127.0.0.1:10023 - проверка через Postgrey Настройка master.cf # Открой:\nsudo nano /etc/postfix/master.cf Добавь в конец:\n# Отправка в Amavis smtp-amavis unix - - n - 2 smtp -o smtp_data_done_timeout=1200 -o smtp_send_xforward_command=yes -o disable_dns_lookups=yes -o max_use=20 # Прием из Amavis обратно 127.0.0.1:10025 inet n - n - - smtpd -o content_filter= -o smtpd_delay_reject=no -o smtpd_client_restrictions=permit_mynetworks,reject -o smtpd_helo_restrictions= -o smtpd_sender_restrictions= -o smtpd_recipient_restrictions=permit_mynetworks,reject -o smtpd_data_restrictions=reject_unauth_pipelining -o smtpd_end_of_data_restrictions= -o smtpd_restriction_classes= -o mynetworks=127.0.0.0/8 -o smtpd_error_sleep_time=0 -o smtpd_soft_error_limit=1001 -o smtpd_hard_error_limit=1000 -o smtpd_client_connection_count_limit=0 -o smtpd_client_connection_rate_limit=0 -o receive_override_options=no_header_body_checks,no_unknown_recipient_checks,no_milters -o local_header_rewrite_clients= Что настроили:\nsmtp-amavis:\nТранспорт для отправки в Amavis Таймаут 1200 секунд (для больших писем) Максимум 20 использований соединения 127.0.0.1:10025:\nПрием обратно из Amavis Отключаем повторные проверки (content_filter пустой) Пропускаем только с localhost Перезапуск Postfix # Проверь конфиг:\nsudo postfix check Если ошибок нет - перезапускай:\nsudo postfix reload Проверь статус:\nsudo systemctl status postfix Тестирование защиты # Тест 1: Проверка антивируса # Отправь тестовый вирус EICAR (безопасная тестовая сигнатура):\ntelnet localhost 25 EHLO test.local MAIL FROM:\u0026lt;test@example.com\u0026gt; RCPT TO:\u0026lt;admin@example.com\u0026gt; DATA Subject: Virus test X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H* . QUIT Проверь логи:\nsudo grep -i \u0026#34;eicar\\|infected\\|virus\u0026#34; /var/log/mail.log | tail -20 Должно быть:\n... mail amavis[44437]: (44437-01) Blocked INFECTED (Eicar-Signature) {DiscardedOutbound,Quarantined}, MYNETS LOCAL [127.0.0.1]:6674 \u0026lt;test@example.com\u0026gt; -\u0026gt; \u0026lt;admin@example.com\u0026gt;, quarantine: virus-quarantine@example.com, Queue-ID: 6B6C425808, Message-ID: \u0026lt;20260317085556.6B6C425808@mail.example.com\u0026gt;, mail_id: fqAsJO87JdbS, Hits: -, size: 375, 454 ms ... mail postfix/smtp[47529]: 6B6C425808: to=\u0026lt;admin@example.com\u0026gt;, relay=127.0.0.1[127.0.0.1]:10024, delay=24, delays=24/0.02/0.09/0.39, dsn=2.7.0, status=sent (250 2.7.0 Ok, discarded, id=44437-01 - INFECTED: Eicar-Signature) ... mail postfix/lmtp[47533]: B34582580B: to=\u0026lt;virus-quarantine@example.com\u0026gt;, relay=mail.example.com[private/dovecot-lmtp], delay=0.12, delays=0.01/0.02/0.04/0.06, dsn=5.1.1, status=bounced (host mail.example.com[private/dovecot-lmtp] said: 550 5.1.1 \u0026lt;virus-quarantine@example.com\u0026gt; User doesn\u0026#39;t exist: virus-quarantine@example.com (in reply to RCPT TO command)) Письмо отклонено - антивирус работает.\nТест 2: Проверка антиспама # Отправь письмо с внешнего почтового ящика (Gmail, Yandex) на свой сервер с текстом:\nSubject: BUY CHEAP VIAGRA NOW!!! Body: CLICK HERE FOR AMAZING DEALS!!! FREE MONEY! ACT NOW! BUY VIAGRA CIALIS CHEAP! 100% GUARANTEED! NO PRESCRIPTION! MAKE MONEY FAST! LIMITED TIME! Важно: Тест через telnet localhost 25 не покажет X-Spam заголовки, т.к. письмо будет считаться исходящим от своих (MYNETS).\nПроверь логи:\nsudo grep \u0026#34;amavis\u0026#34; /var/log/mail.log | tail -5 Должна приблизительно быть строка:\n... mail amavis[51765]: (51765-01) Passed SPAMMY {RelayedOutbound}, MYNETS LOCAL [127.0.0.1]:45220 \u0026lt;spam@spam.com\u0026gt; -\u0026gt; \u0026lt;admin@example.com\u0026gt;, Queue-ID: 526902584D, Message-ID: \u0026lt;20260317101453.526902584D@mail.example.com\u0026gt;, mail_id: yUPoX6uzzm6f, Hits: 6.549, size: 375, queued_as: 9DA0D2584F, 1259 ms Passed CLEAN — не спам (баллов \u0026lt; 5.0) Passed SPAMMY — спам (баллов \u0026gt;= 5.0) Hits: X.XX — количество баллов SpamAssassin Если баллов \u0026gt;= 5.0, письмо помечено как спам и в заголовках будет:\nX-Spam-Flag: YES X-Spam-Score: 15.2 X-Spam-Status: Yes, score=15.2 Проверь письмо:\nsudo ls -t /var/mail/example.com/admin/new/ | head -1 | xargs -I {} sudo cat /var/mail/example.com/admin/new/{} | grep X-Spam Если баллов мало (\u0026lt; 5.0):\nЭто нормально — письмо от доверенного провайдера (Yandex, Gmail) с валидной DKIM подписью получает мало баллов. SpamAssassin работает правильно, отличая легитимную почту от спама.\nУточнение: В качестве спам рассылки я использовал почту Yandex.\nЛоги испытания:\n... mail amavis[51765]: (51765-01) Passed SPAMMY {RelayedOutbound}, MYNETS LOCAL [127.0.0.1]:45220 \u0026lt;spam@spam.com\u0026gt; -\u0026gt; \u0026lt;admin@example.com\u0026gt;, Queue-ID: 526902584D, Message-ID: \u0026lt;20260317101453.526902584D@mail.example.com\u0026gt;, mail_id: yUPoX6uzzm6f, Hits: 6.549, size: 375, queued_as: 9DA0D2584F, 1259 ms ... mail amavis[51766]: (51766-01) Passed CLEAN {RelayedOpenRelay}, [178.154.239.223]:36200 [2a02:6b8:c42:e720:0:640:3001:0] \u0026lt;oakazanin@ya.ru\u0026gt; -\u0026gt; \u0026lt;admin@example.com\u0026gt;, Queue-ID: AA84721409, Message-ID: \u0026lt;121751773743278@mail.yandex.ru\u0026gt;, mail_id: V5iYivtYPpbe, Hits: 1.567, size: 1912, queued_as: 948F02584F, 853 ms Из логов видно, что в начале письмо получает Passed SPAMMY, а затем Passed CLEAN, Hits: 1.567(колличество баллов, что \u0026lt; 5.0). Причины:\nОтправитель - Yandex (доверенный провайдер) Валидная DKIM подпись: DKIM-Signature: v=1; a=rsa-sha256; d=ya.ru; s=mail; IP не в блэклистах Тест 3: Проверка Postgrey # Отправь письмо с другого IP (запрос с внешнего сервера):\ntelnet mail.example.com 25 где, mail.example.com - полное доменное имя или IP твоего почтового сервера.\nEHLO mail.google.com MAIL FROM:\u0026lt;test@newdomain.com\u0026gt; RCPT TO:\u0026lt;admin@example.com\u0026gt; DATA Subject: Greylisting test . QUIT При первой попытке должен получить:\n450 4.2.0 \u0026lt;admin@example.com\u0026gt;: Recipient address rejected: Greylisted, see http://postgrey.schweikert.ch/help/example.com.html Подожди 5 минут и повтори - письмо пройдет.\nПроверь статус Postgrey:\nsudo grep \u0026#34;postgrey\u0026#34; /var/log/mail.log | tail -10 Должна быть запись о Greylisted.\nОбучение SpamAssassin # Чем больше примеров спама и не-спама покажешь SpamAssassin, тем точнее он работает.\nРучное обучение # Пометить письмо как спам:\nsudo sa-learn --spam /var/mail/example.com/admin/.Spam/cur/* Пометить как не-спам:\nsudo sa-learn --ham /var/mail/example.com/admin/cur/* Проверить статистику обучения # sudo sa-learn --dump magic Вывод:\n0.000 0 3 0 non-token data: bayes db version 0.000 0 150 0 non-token data: nspam 0.000 0 450 0 non-token data: nham nspam - количество спам-писем в базе\nnham - количество не-спам писем в базе\nПосле обучения перезапусти Amavis:\nsudo systemctl restart amavis Автообучение # SpamAssassin автоматически учится на письмах с четкими признаками (настроили в local.cf):\nБаллы \u0026lt; -0.1 → автоматически не-спам Баллы \u0026gt; 6.0 → автоматически спам Через неделю-две база накопится, точность вырастет.\nFail2ban для почтовых сервисов # Защита от брутфорса паролей SMTP/IMAP.\nПроверка установки # Fail2ban должен быть установлен из статьи по базовой настройке сервера:\nБезопасный production-сервер на Debian 12: пошаговая настройка 7 марта 2026\u0026middot;13 минут Linux Безопасность Debian Linux Security Безопасность Ssh Ufw Fail2ban Sysctl Linux-Admin Проверь:\nsudo systemctl status fail2ban Настройка jail для почты # Открой:\nsudo nano /etc/fail2ban/jail.local Добавь в конец:\n[postfix-sasl] enabled = true port = smtp,submission,smtps filter = postfix[mode=auth] logpath = /var/log/mail.log maxretry = 3 bantime = 600 [dovecot] enabled = true port = imap,imaps,pop3,pop3s filter = dovecot logpath = /var/log/mail.log maxretry = 3 bantime = 600 Что настроили:\npostfix-sasl:\nЗащита SMTP AUTH (порты 25, 587, 465) Максимум 3 неудачных попытки Бан на 10 минут dovecot:\nЗащита IMAP/POP3 (порты 143, 993, 110, 995) Максимум 3 неудачных попытки Бан на 10 минут Перезапуск Fail2ban # sudo systemctl reload fail2ban Проверь тюрьмы:\nsudo fail2ban-client status Должно быть:\nStatus |- Number of jail: 3 `- Jail list: dovecot, postfix-sasl, sshd Тест Fail2ban # Попробуй подключиться с неправильным паролем 3 раза:\ntelnet localhost 587 EHLO test.local AUTH PLAIN dGVzdEBleGFtcGxlLmNvbQB3cm9uZ3Bhc3N3b3Jk AUTH PLAIN dGVzdEBleGFtcGxlLmNvbQB3cm9uZ3Bhc3N3b3Jk AUTH PLAIN dGVzdEBleGFtcGxlLmNvbQB3cm9uZ3Bhc3N3b3Jk После 3-й попытки твой IP должен быть забанен.\nПроверь:\nsudo fail2ban-client status postfix-sasl Должен появиться IP в Banned IP list.\nМониторинг защиты # Статистика Amavis # sudo amavisd-nanny Команда выводит состояние worker-процессов в реальном времени. Точки (.) — процесс idle, звездочки (*) — обрабатывает письмо.\nСтатистика ClamAV # sudo clamdscan --version sudo freshclam --version Проверь обновление баз:\nsudo cat /var/log/clamav/freshclam.log | tail -20 Статистика SpamAssassin # sudo sa-learn --dump magic Вывод:\n0.000 0 3 0 non-token data: bayes db version 0.000 0 234 0 non-token data: nspam 0.000 0 789 0 non-token data: nham 0.000 0 45123 0 non-token data: ntokens 0.000 0 1773745892 0 non-token data: oldest atime 0.000 0 1773831245 0 non-token data: newest atime 0.000 0 0 0 non-token data: last journal sync atime 0.000 0 0 0 non-token data: last expiry atime 0.000 0 0 0 non-token data: last expire atime delta 0.000 0 0 0 non-token data: last expire reduction count Где:\nnspam: 234 — количество спам-писем в обучающей базе nham: 789 — количество не-спам писем в обучающей базе ntokens: 45123 — количество токенов (слов) в базе\nЛоги # Все логи почты в одном месте:\nsudo tail -f /var/log/mail.log Фильтруй по ключевым словам:\n# Вирусы sudo grep \u0026#34;Blocked INFECTED\u0026#34; /var/log/mail.log # Спам sudo grep \u0026#34;Passed SPAM\u0026#34; /var/log/mail.log # Greylisting sudo grep \u0026#34;Greylisted\u0026#34; /var/log/mail.log # Fail2ban баны sudo grep \u0026#34;Ban\u0026#34; /var/log/fail2ban.log Тонкая настройка # Увеличить порог спама # Если много ложных срабатываний, увеличь required_score:\nsudo nano /etc/spamassassin/local.cf Измени:\nrequired_score 7.0 Перезапусти:\nsudo systemctl restart amavis Добавить домен в белый список Postgrey # sudo nano /etc/postgrey/whitelist_clients.local Добавь:\n/^.*\\.important-partner\\.com$/ Перезапусти:\nsudo systemctl restart postgrey Отключить greylisting для авторизованных # Если не хочешь задержек для своих пользователей, в Postfix измени:\nsudo nano /etc/postfix/main.cf В smtpd_recipient_restrictions перед check_policy_service добавь:\npermit_sasl_authenticated, Чтобы получилось:\nsmtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination, check_policy_service inet:127.0.0.1:10023 Перезагрузи:\nsudo postfix reload Типичные проблемы # ClamAV жрет всю память # ClamAV требует ~500-700MB RAM. Если сервер слабый:\nОткрой:\nsudo nano /etc/clamav/clamd.conf Уменьши:\nMaxThreads 10 MaxConnectionQueueLength 15 Перезапусти:\nsudo systemctl restart clamav-daemon Письма застревают в очереди # Проверь очередь:\nsudo postqueue -p Причина может быть в медленной проверке. Увеличь таймаут:\nsudo nano /etc/postfix/master.cf Найди smtp-amavis и увеличь:\nsmtp_data_done_timeout=1800 Перезагрузи:\nsudo postfix reload SpamAssassin не учится # Проверь права на базу Bayes:\nls -la /var/lib/amavis/.spamassassin/ Должен быть владелец amavis:amavis.\nИсправь:\nsudo chown -R amavis:amavis /var/lib/amavis/.spamassassin sudo chmod 700 /var/lib/amavis/.spamassassin Postgrey блокирует легальную почту # Добавь отправителя в белый список:\nsudo nano /etc/postgrey/whitelist_clients.local sender-domain.com Перезапусти:\nsudo systemctl restart postgrey Что получилось # Сейчас у тебя:\nРаботает:\nАнтивирусная проверка всех входящих писем (ClamAV) Антиспам с обучением (SpamAssassin + Bayes) Greylisting для новых отправителей (Postgrey) Защита от брутфорса SMTP/IMAP (Fail2ban) Потребление ресурсов:\nClamAV: ~500-700 MB RAM SpamAssassin: ~200-300 MB на процесс Amavis: ~50-100 MB Postgrey: ~10-20 MB Итого: +800MB-1.2GB RAM Проблемы:\nПароли все еще передаются открытым текстом (нет TLS) Нет DKIM подписей (письма могут улетать в спам) Нет веб-интерфейса Это защищенный сервер, но еще не production-ready.\nСледующий шаг # В следующей части настроим шифрование и репутацию:\nTLS через Let\u0026rsquo;s Encrypt (шифрование соединений) DKIM подписи (доверие к твоим письмам) SPF и DMARC записи (защита от подделки домена) После этого письма перестанут улетать в спам у Gmail/Outlook.\n","date":"16 марта 2026","externalUrl":null,"permalink":"/posts/mailserver-part-3-security/","section":"Статьи","summary":"","title":"Почтовый сервер на Debian 12: полное руководство от установки до production. Часть 3 - Защита от спама и вирусов","type":"posts"},{"content":"","date":"16 марта 2026","externalUrl":null,"permalink":"/categories/%D1%81%D0%B8%D1%81%D1%82%D0%B5%D0%BC%D0%BD%D0%BE%D0%B5-%D0%B0%D0%B4%D0%BC%D0%B8%D0%BD%D0%B8%D1%81%D1%82%D1%80%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5/","section":"Категории","summary":"","title":"Системное Администрирование","type":"categories"},{"content":"","date":"16 марта 2026","externalUrl":null,"permalink":"/posts/","section":"Статьи","summary":"","title":"Статьи","type":"posts"},{"content":"","date":"16 марта 2026","externalUrl":null,"permalink":"/tags/","section":"Теги","summary":"","title":"Теги","type":"tags"},{"content":"","date":"16 марта 2026","externalUrl":null,"permalink":"/categories/%D1%8D%D0%BB%D0%B5%D0%BA%D1%82%D1%80%D0%BE%D0%BD%D0%BD%D0%B0%D1%8F-%D0%BF%D0%BE%D1%87%D1%82%D0%B0/","section":"Категории","summary":"","title":"Электронная Почта","type":"categories"},{"content":"","date":"10 марта 2026","externalUrl":null,"permalink":"/tags/dovecot/","section":"Теги","summary":"","title":"Dovecot","type":"tags"},{"content":"","date":"10 марта 2026","externalUrl":null,"permalink":"/tags/imap/","section":"Теги","summary":"","title":"Imap","type":"tags"},{"content":"","date":"10 марта 2026","externalUrl":null,"permalink":"/tags/lmtp/","section":"Теги","summary":"","title":"Lmtp","type":"tags"},{"content":"","date":"10 марта 2026","externalUrl":null,"permalink":"/tags/postfix/","section":"Теги","summary":"","title":"Postfix","type":"tags"},{"content":"","date":"10 марта 2026","externalUrl":null,"permalink":"/tags/sasl/","section":"Теги","summary":"","title":"Sasl","type":"tags"},{"content":"","date":"10 марта 2026","externalUrl":null,"permalink":"/tags/self-hosting/","section":"Теги","summary":"","title":"Self-Hosting","type":"tags"},{"content":"","date":"10 марта 2026","externalUrl":null,"permalink":"/tags/smtp/","section":"Теги","summary":"","title":"Smtp","type":"tags"},{"content":" Postfix + Dovecot: установка и базовая настройка # Сейчас поставим два ключевых компонента почтового сервера: Postfix (прием/отправка) и Dovecot (доступ к ящикам). На выходе получишь работающую систему, через которую можно отправлять и получать почту.\nБез антивируса, без антиспама, без веб-морды - голый функционал. Но работающий.\nПеред началом # Эта инструкция подразумевает, что у тебя:\nDebian 12 с базовой настройкой безопасности Настроены SSH, firewall, fail2ban Система обновлена Если еще не настроил - сделай это сейчас:\nБезопасный production-сервер на Debian 12: пошаговая настройка 7 марта 2026\u0026middot;13 минут Linux Безопасность Debian Linux Security Безопасность Ssh Ufw Fail2ban Sysctl Linux-Admin Дальше считаем, что база готова.\nПодготовка для почтового сервера # Открываем порты в firewall # Почтовому серверу нужны специфичные порты:\nsudo ufw allow 25/tcp comment \u0026#39;SMTP\u0026#39; sudo ufw allow 587/tcp comment \u0026#39;SMTP Submission\u0026#39; sudo ufw allow 143/tcp comment \u0026#39;IMAP\u0026#39; sudo ufw allow 993/tcp comment \u0026#39;IMAPS\u0026#39; Проверь:\nsudo ufw status Должны появиться:\n25/tcp (SMTP) 587/tcp (Submission) 143/tcp (IMAP) 993/tcp (IMAPS) Проверяем DNS # Для почтового сервера DNS - критично. Без правильных записей письма не пойдут или улетят в спам.\nMX-запись:\ndig +short MX example.com Должно вернуть:\n10 mail.example.com. A-запись:\ndig +short mail.example.com Должен вернуть твой IP.\nPTR (обратная зона):\nhost твой-IP Должно вернуть:\nтвой-IP.in-addr.arpa domain name pointer mail.example.com. Если PTR не настроен - письма будут улетать в спам. Пиши в поддержку хостера, это делается с их стороны.\nУстановка Postfix # Ставим пакет # sudo apt install -y postfix Установщик запустит конфигуратор. Отменяй его - настроим вручную.\nВыбери:\nGeneral type of mail configuration: No configuration Нажми OK Создаем структуру для хранения почты # Проверяем где реальная директория\nls -ld /var/spool/mail Если это симлинк (обычно в Debian) - работаем с /var/mail:\nsudo mkdir -p /var/mail/example.com Замени example.com на свой домен.\nСоздай системную группу и пользователя для виртуальных ящиков:\nsudo groupadd -g 5000 vmail sudo useradd -g vmail -u 5000 -d /var/spool/mail -s /usr/sbin/nologin vmail Что сделали:\nСоздали группу vmail с GID 5000 Создали пользователя vmail с UID 5000 Домашняя директория /var/spool/mail Запрет на логин (/usr/sbin/nologin) Назначь владельца:\nsudo chown -R vmail:vmail /var/mail sudo chmod -R 770 /var/mail Базовая конфигурация Postfix # Создай главный конфиг:\nsudo nano /etc/postfix/main.cf Вставь:\n# Общие параметры smtpd_banner = $myhostname ESMTP biff = no append_dot_mydomain = no readme_directory = no compatibility_level = 3.6 # Имя хоста и домен myhostname = mail.example.com mydomain = example.com myorigin = $mydomain # Сеть inet_interfaces = all inet_protocols = ipv4 # Доверенные сети mynetworks = 127.0.0.0/8 # Локальная доставка отключена mydestination = localhost local_recipient_maps = # Виртуальные домены virtual_mailbox_domains = example.com virtual_mailbox_base = /var/spool/mail virtual_mailbox_maps = hash:/etc/postfix/vmailbox virtual_alias_maps = hash:/etc/postfix/virtual virtual_minimum_uid = 100 virtual_uid_maps = static:5000 virtual_gid_maps = static:5000 # Размер сообщений message_size_limit = 52428800 mailbox_size_limit = 0 # Очереди queue_directory = /var/spool/postfix Замени mail.example.com и example.com на свои значения.\nЧто настроили:\nОбщие параметры:\nsmtpd_banner - приветствие сервера biff = no - отключили уведомления comsat append_dot_mydomain = no - не добавлять домен автоматически compatibility_level = 3.6 - уровень совместимости Имя и домен:\nmyhostname - полное имя сервера mydomain - основной домен myorigin - от какого домена идут письма Сеть:\ninet_interfaces = all - слушаем на всех интерфейсах inet_protocols = ipv4 - только IPv4 (если есть IPv6 - используй all) Доверенные сети:\nmynetworks - только localhost (расширим позже) Локальная доставка:\nmydestination = localhost - только для локалхоста Остальное через виртуальные домены Виртуальные домены:\nvirtual_mailbox_domains - список доменов (пока один) virtual_mailbox_base - корневая директория почты virtual_mailbox_maps - файл с ящиками virtual_alias_maps - файл с псевдонимами virtual_uid_maps / virtual_gid_maps - от имени кого хранить файлы Лимиты:\nmessage_size_limit - максимальный размер письма (50 MB) mailbox_size_limit = 0 - без лимита на ящик Создаем файлы для виртуальных ящиков # Файл vmailbox - список почтовых ящиков:\nsudo nano /etc/postfix/vmailbox Добавь:\nadmin@example.com example.com/admin/ user@example.com example.com/user/ Формат: email путь_относительно_virtual_mailbox_base/\nФайл virtual - псевдонимы:\nsudo nano /etc/postfix/virtual Добавь:\npostmaster@example.com admin@example.com abuse@example.com admin@example.com Формат: псевдоним куда_перенаправлять\nСоздай hash-таблицы:\nsudo postmap /etc/postfix/vmailbox sudo postmap /etc/postfix/virtual Это создаст файлы .db - бинарные индексы для быстрого поиска.\nПроверь конфиг:\nsudo postfix check Если ошибок нет - молчит. Если есть - исправляй.\nЗапускаем Postfix # sudo systemctl enable postfix sudo systemctl start postfix sudo systemctl status postfix Должен быть active (running).\nПроверяем порты # sudo ss -tulnp | grep master Должно быть:\ntcp LISTEN 0 100 0.0.0.0:25 0.0.0.0:* users:((\u0026#34;master\u0026#34;,pid=...)) Postfix слушает порт 25.\nУстановка Dovecot # Ставим пакеты # sudo apt install -y \\ dovecot-core \\ dovecot-imapd \\ dovecot-lmtpd Что установили:\ndovecot-core - ядро dovecot-imapd - IMAP сервер dovecot-lmtpd - LMTP для приема почты от Postfix Основной конфиг Dovecot # Открой:\nsudo nano /etc/dovecot/dovecot.conf Приведи к виду:\n# Протоколы protocols = imap lmtp # Слушаем на всех интерфейсах IPv4 listen = * # Рабочая директория base_dir = /var/run/dovecot/ # Привет от сервера login_greeting = Dovecot ready. # Отключать клиентов при перезапуске shutdown_clients = yes # Подключаем конфиги из conf.d/ !include conf.d/*.conf Настройка аутентификации # Открой:\nsudo nano /etc/dovecot/conf.d/10-auth.conf Найди и измени:\n# Разрешаем plaintext auth (потом включим TLS) disable_plaintext_auth = no # Механизмы аутентификации auth_mechanisms = plain login В конце файла закомментируй:\n#!include auth-system.conf.ext И раскомментируй:\n!include auth-passwdfile.conf.ext Что сделали:\nОтключили системную аутентификацию (через /etc/passwd) Включили аутентификацию через файл паролей Настройка файла паролей # Открой:\nsudo nano /etc/dovecot/conf.d/auth-passwdfile.conf.ext Приведи к виду:\npassdb { driver = passwd-file args = scheme=CRYPT username_format=%u /etc/dovecot/users } userdb { driver = static args = uid=vmail gid=vmail home=/var/spool/mail/%d/%n } Что настроили:\npassdb - база паролей:\ndriver = passwd-file - из файла scheme=CRYPT - хеширование CRYPT username_format=%u - формат логина (email) /etc/dovecot/users - путь к файлу userdb - база пользователей:\ndriver = static - статические значения для всех uid=vmail gid=vmail - от имени кого работаем home=/var/spool/mail/%d/%n - путь к ящику Переменные:\n%u - полный email (user@example.com) %d - домен (example.com) %n - локальная часть (user) Настройка почтовых ящиков # Открой:\nsudo nano /etc/dovecot/conf.d/10-mail.conf Найди и измени:\n# Формат и расположение почты mail_location = maildir:/var/spool/mail/%d/%n # От имени кого работаем mail_uid = vmail mail_gid = vmail # Привилегированная группа mail_privileged_group = vmail # Разрешенные chroot-директории valid_chroot_dirs = /var/spool/mail Что настроили:\nmail_location - формат Maildir, путь к ящикам mail_uid / mail_gid - UID/GID для работы с файлами valid_chroot_dirs - где можно работать Настройка LMTP # Открой:\nsudo nano /etc/dovecot/conf.d/10-master.conf Найди блок service lmtp и измени:\nservice lmtp { unix_listener /var/spool/postfix/private/dovecot-lmtp { mode = 0600 user = postfix group = postfix } } Найди блок service auth и добавь:\nservice auth { # Postfix SMTP AUTH unix_listener /var/spool/postfix/private/auth { mode = 0666 user = postfix group = postfix } # Для самого Dovecot unix_listener auth-userdb { mode = 0600 user = vmail } } Что настроили:\nservice lmtp:\nСоздали UNIX-сокет для Postfix Postfix будет передавать письма через него service auth:\nСокет для SMTP AUTH (авторизация в Postfix) Сокет для внутренних нужд Dovecot Отключаем SSL (пока) # Открой:\nsudo nano /etc/dovecot/conf.d/10-ssl.conf Измени:\nssl = no Потом включим с Let\u0026rsquo;s Encrypt. Пока отключаем.\nСоздаем файл пользователей # sudo nano /etc/dovecot/users Формат файла:\nemail:password_hash:5000:5000:: Создадим пользователя admin@example.com с паролем SecurePass123:\nГенерируем хеш:\nsudo doveadm pw -s CRYPT -p SecurePass123 Вернет что-то вроде:\n{CRYPT}$6$random_salt$hash_here Копируй только часть после {CRYPT}, т.е. $6$random_salt$hash_here.\nЗапиши в /etc/dovecot/users:\nadmin@example.com:$6$random_salt$hash_here:5000:5000:: Создай еще пользователя user@example.com с паролем UserPass456:\nsudo doveadm pw -s CRYPT -p UserPass456 Добавь в файл:\nadmin@example.com:$6$hash1:5000:5000:: user@example.com:$6$hash2:5000:5000:: Формат строки:\nemail:hash:uid:gid:gecos:home:shell У нас home и shell пустые, т.к. используем static userdb.\nПрава на файл # sudo chmod 640 /etc/dovecot/users sudo chown root:dovecot /etc/dovecot/users Запускаем Dovecot # sudo systemctl enable dovecot sudo systemctl start dovecot sudo systemctl status dovecot Должен быть active (running).\nПроверяем порты # sudo ss -tulnp | grep dovecot Должно быть:\ntcp LISTEN 0 100 0.0.0.0:143 0.0.0.0:* users:((\u0026#34;dovecot\u0026#34;,pid=...)) Dovecot слушает порт 143 (IMAP).\nИнтеграция Postfix и Dovecot # Сейчас свяжем Postfix и Dovecot, чтобы:\nPostfix передавал письма в Dovecot через LMTP Postfix использовал Dovecot для SMTP AUTH Настраиваем Postfix на LMTP # Открой:\nsudo nano /etc/postfix/main.cf Добавь в конец:\n# Доставка через Dovecot LMTP virtual_transport = lmtp:unix:private/dovecot-lmtp # SMTP AUTH через Dovecot smtpd_sasl_type = dovecot smtpd_sasl_path = private/auth smtpd_sasl_auth_enable = yes smtpd_sasl_security_options = noanonymous broken_sasl_auth_clients = yes Что добавили:\nvirtual_transport:\nПисьма для виртуальных доменов отправляем в Dovecot через LMTP SMTP AUTH:\nИспользуем Dovecot для проверки логинов/паролей noanonymous - запрет анонимного доступа broken_sasl_auth_clients - поддержка старых клиентов Настраиваем правила приема/отправки # Добавь туда же:\n# Требовать HELO/EHLO smtpd_helo_required = yes # Правила HELO smtpd_helo_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, reject_unknown_helo_hostname # Правила получателя smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination, reject_non_fqdn_recipient, reject_unknown_recipient_domain # Правила отправителя smtpd_sender_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_non_fqdn_sender, reject_unknown_sender_domain Что добавили:\nsmtpd_helo_required:\nТребуем команду HELO при подключении smtpd_helo_restrictions:\npermit_mynetworks - свои пропускаем permit_sasl_authenticated - авторизованных пропускаем reject_invalid_helo_hostname - отклоняем невалидные имена reject_non_fqdn_helo_hostname - отклоняем неполные имена reject_unknown_helo_hostname - отклоняем неизвестные имена smtpd_recipient_restrictions:\nСвои и авторизованных пропускаем reject_unauth_destination - отклоняем для чужих доменов (защита от relay) reject_non_fqdn_recipient - отклоняем неполные адреса получателей reject_unknown_recipient_domain - отклоняем неизвестные домены smtpd_sender_restrictions:\nПроверяем адреса отправителей Настраиваем порт submission (587) # Открой:\nsudo nano /etc/postfix/master.cf Найди строку submission (должна быть закомментирована) и раскомментируй/измени:\nsubmission inet n - y - - smtpd -o syslog_name=postfix/submission -o smtpd_tls_security_level=may -o smtpd_sasl_auth_enable=yes -o smtpd_reject_unlisted_recipient=no -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject Что настроили:\nПорт 587 для отправки от клиентов Требуется авторизация TLS опционален (пока, потом сделаем обязательным) Перезагружаем Postfix # sudo postfix reload Проверь статус:\nsudo systemctl status postfix Проверь порты:\nsudo ss -tulnp | grep master Должно быть:\ntcp LISTEN 0 100 0.0.0.0:25 ... tcp LISTEN 0 100 0.0.0.0:587 ... Первый тест # Сейчас проверим, работает ли базовая функциональность.\nТест 1: Отправка письма через telnet # Подключись к SMTP:\ntelnet localhost 25 Увидишь:\n220 mail.example.com ESMTP Выполни команды:\nEHLO test.local MAIL FROM:\u0026lt;admin@example.com\u0026gt; RCPT TO:\u0026lt;user@example.com\u0026gt; DATA Subject: Test mail This is a test message. . QUIT Пояснение:\nEHLO test.local - представляемся MAIL FROM - от кого письмо RCPT TO - кому письмо DATA - начало тела письма . (точка на отдельной строке) - конец письма QUIT - завершение сессии Если все ОК, увидишь:\n250 2.0.0 Ok: queued as 2CAB723BB4 Тест 2: Проверка доставки # Проверь логи Postfix:\nsudo tail -f /var/log/mail.log Ищи строки вроде этой:\nMar 16 19:54:34 mail postfix/lmtp[24548]: 634D423BB4: to=\u0026lt;user@example.com\u0026gt;, relay=mail.example.com[private/dovecot-lmtp], delay=21, delays=21/0.02/0.02/0.01, dsn=2.0.0, status=sent (250 2.0.0 \u0026lt;user@example.com\u0026gt; mPzKDMo1uGnlXwAAt3BTQQ Saved) Проверь файловую систему:\nsudo ls -la /var/spool/mail/example.com/user/new/ Должен появиться файл письма.\ndrwx--S--- 2 vmail vmail 4096 Mar 16 19:57 . drwx--S--- 5 vmail vmail 4096 Mar 16 19:57 .. -rw------- 1 vmail vmail 537 Mar 16 19:52 \u0026#39;1773679952.M948512P24098.mail.example.com,S=537,W=554\u0026#39; -rw------- 1 vmail vmail 537 Mar 16 19:54 \u0026#39;1773680074.M217408P24549.mail.example.com,S=537,W=554\u0026#39; -rw------- 1 vmail vmail 537 Mar 16 19:57 \u0026#39;1773680252.M311184P24696.mail.example.com,S=537,W=554\u0026#39; Посмотри содержимое:\ncat /var/spool/mail/example.com/user/new/* Должен быть текст письма. Например:\nReturn-Path: \u0026lt;admin@example.com\u0026gt; Delivered-To: user@example.com Received: from mail.example.com by mail.example.com with LMTP id /8HxEHw2uGl4YAAAt3BTQQ (envelope-from \u0026lt;admin@example.com\u0026gt;) for \u0026lt;user@example.com\u0026gt;; Mon, 16 Mar 2026 19:57:32 +0300 Received: from test.local (localhost [127.0.0.1]) by mail.example.com (Postfix) with ESMTP id 06AAD23BBA for \u0026lt;user@example.com\u0026gt;; Mon, 16 Mar 2026 18:37:51 +0300 (MSK) Subject: Test mail Message-Id: \u0026lt;20260316153757.06AAD23BBA@mail.example.com\u0026gt; Date: Mon, 16 Mar 2026 18:37:51 +0300 (MSK) From: admin@example.com This is a test message. Тест 3: Проверка IMAP # Подключись к Dovecot:\ntelnet localhost 143 Увидишь:\n* OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE LITERAL+ AUTH=PLAIN AUTH=LOGIN] Dovecot ready. Выполни:\na1 LOGIN user@example.com UserPass456 a2 SELECT INBOX a3 FETCH 1 BODY[] a4 LOGOUT Пояснение:\na1 LOGIN - авторизация (логин пароль) a2 SELECT INBOX - открыть папку INBOX a3 FETCH 1 BODY[] - получить тело первого письма a4 LOGOUT - выход Если авторизация прошла:\na1 OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE SORT SORT=DISPLAY THREAD=REFERENCES THREAD=REFS THREAD=ORDEREDSUBJECT MULTIAPPEND URL-PARTIAL CATENATE UNSELECT CHILDREN NAMESPACE UIDPLUS LIST-EXTENDED I18NLEVEL=1 CONDSTORE QRESYNC ESEARCH ESORT SEARCHRES WITHIN CONTEXT=SEARCH LIST-STATUS BINARY MOVE SNIPPET=FUZZY PREVIEW=FUZZY STATUS=SIZE SAVEDATE LITERAL+ NOTIFY SPECIAL-USE] Logged in Если письмо есть:\n* 1 FETCH (FLAGS (\\Seen \\Recent) BODY[] {554} Return-Path: \u0026lt;admin@example.com\u0026gt; Delivered-To: user@example.com Received: from mail.example.com by mail.example.com with LMTP id jdVeOFA1uGkiXgAAt3BTQQ (envelope-from \u0026lt;admin@example.com\u0026gt;) for \u0026lt;user@example.com\u0026gt;; Mon, 16 Mar 2026 19:52:32 +0300 Received: from test.local (localhost [127.0.0.1]) by mail.example.com (Postfix) with ESMTP id 2CAB723BB4 for \u0026lt;user@example.com\u0026gt;; Mon, 16 Mar 2026 18:33:46 +0300 (MSK) Subject: Test mail Message-Id: \u0026lt;20260316153352.2CAB723BB4@mail.example.com\u0026gt; Date: Mon, 16 Mar 2026 18:33:46 +0300 (MSK) From: admin@example.com This is a test message. ) Тест 4: Отправка с авторизацией # Для теста SMTP AUTH нужен base64 логина и пароля.\nГенерируем:\necho -ne \u0026#39;\\000admin@example.com\\000SecurePass123\u0026#39; | base64 Где:\n\\000 - нулевой байт (NULL, символ с кодом 0) username - логин (admin@example.com) \\000 - еще один NULL password - пароль (SecurePass123) Вернет что-то вроде:\nAGFkbWluQGV4YW1wbGUuY29tAFNlY3VyZVBhc3MxMjM= Подключись к порту 587:\ntelnet localhost 587 Выполни:\nEHLO test.local AUTH PLAIN AGFkbWluQGV4YW1wbGUuY29tAFNlY3VyZVBhc3MxMjM= MAIL FROM:\u0026lt;admin@example.com\u0026gt; RCPT TO:\u0026lt;user@example.com\u0026gt; DATA Subject: Auth test Test with auth. . QUIT Если авторизация прошла:\n235 2.7.0 Authentication successful Письмо должно быть доставлено.\nЧто получилось # Сейчас у тебя:\nРаботает:\nПрием почты на порт 25 Отправка почты через порт 587 с авторизацией Доступ к ящикам через IMAP на порту 143 Виртуальные домены и ящики Псевдонимы Не работает:\nШифрование (все по открытому каналу) Проверка на вирусы Проверка на спам Защита от брутфорса Веб-интерфейс DKIM подписи Это рабочий прототип, но не production-ready решение.\nТипичные проблемы # Postfix не стартует # Проверь логи:\njournalctl -u postfix -n 50 Частые причины:\nОпечатка в main.cf Порт 25 занят другим процессом Нет прав на директории Проверь конфиг:\npostfix check Dovecot не стартует # Проверь логи:\njournalctl -u dovecot -n 50 Частые причины:\nОпечатка в конфигах Неправильные права на /etc/dovecot/users Порт 143 занят Проверь конфиг:\ndoveconf -n Письма не доставляются # Проверь очередь Postfix:\npostqueue -p Если письма застряли - смотри причину:\ntail -f /var/log/mail.log Частые причины:\nНеправильный путь в vmailbox Нет прав на /var/spool/mail LMTP сокет не создан Проверь сокет:\nls -la /var/spool/postfix/private/dovecot-lmtp Должен быть.\nПисьма не доставляются: Permission denied # Ошибка в логах:\n# Permission denied (euid=5000(vmail) missing +x perm: /var/spool/mail) Проверь владельца реальной директории\nls -ld /var/spool/mail # Может быть симлинк ls -ld /var/mail # Реальная директория Исправь права\nsudo chown -R vmail:vmail /var/mail sudo chmod 755 /var/mail Проверь вложенные директории\nls -la /var/mail/ IMAP не работает # Проверь авторизацию вручную:\ndoveadm auth test user@example.com UserPass456 Должно вернуть:\npassdb: user@example.com auth succeeded userdb: user@example.com Если не работает:\nПроверь хеш пароля в /etc/dovecot/users Проверь права на файл Проверь логи Dovecot SMTP AUTH не работает # Проверь сокет:\nls -la /var/spool/postfix/private/auth Должен быть с правами 666 и владельцем postfix:postfix.\nПроверь логи:\ngrep \u0026#34;sasl\u0026#34; /var/log/mail.log Следующий шаг # В следующей части настроим защиту:\nАнтивирус Amavis + ClamAV Антиспам SpamAssassin GreyListing через Postgrey Fail2ban против брутфорса После этого сервер станет безопаснее и перестанет пропускать вирусы и спам.\n","date":"10 марта 2026","externalUrl":null,"permalink":"/posts/mailserver-part-2-postfix-dovecot/","section":"Статьи","summary":"","title":"Почтовый сервер на Debian 12: полное руководство от установки до production. Часть 2 - Postfix и Dovecot","type":"posts"},{"content":"","date":"7 марта 2026","externalUrl":null,"permalink":"/tags/debian/","section":"Теги","summary":"","title":"Debian","type":"tags"},{"content":"","date":"7 марта 2026","externalUrl":null,"permalink":"/categories/linux/","section":"Категории","summary":"","title":"Linux","type":"categories"},{"content":"","date":"7 марта 2026","externalUrl":null,"permalink":"/tags/linux/","section":"Теги","summary":"","title":"Linux","type":"tags"},{"content":"","date":"7 марта 2026","externalUrl":null,"permalink":"/tags/linux-admin/","section":"Теги","summary":"","title":"Linux-Admin","type":"tags"},{"content":"","date":"7 марта 2026","externalUrl":null,"permalink":"/tags/security/","section":"Теги","summary":"","title":"Security","type":"tags"},{"content":"","date":"7 марта 2026","externalUrl":null,"permalink":"/tags/ssh/","section":"Теги","summary":"","title":"Ssh","type":"tags"},{"content":"","date":"7 марта 2026","externalUrl":null,"permalink":"/tags/sysctl/","section":"Теги","summary":"","title":"Sysctl","type":"tags"},{"content":"","date":"7 марта 2026","externalUrl":null,"permalink":"/tags/ufw/","section":"Теги","summary":"","title":"Ufw","type":"tags"},{"content":"","date":"7 марта 2026","externalUrl":null,"permalink":"/categories/%D0%B1%D0%B5%D0%B7%D0%BE%D0%BF%D0%B0%D1%81%D0%BD%D0%BE%D1%81%D1%82%D1%8C/","section":"Категории","summary":"","title":"Безопасность","type":"categories"},{"content":"","date":"7 марта 2026","externalUrl":null,"permalink":"/tags/%D0%B1%D0%B5%D0%B7%D0%BE%D0%BF%D0%B0%D1%81%D0%BD%D0%BE%D1%81%D1%82%D1%8C/","section":"Теги","summary":"","title":"Безопасность","type":"tags"},{"content":"Поставил чистый Debian 12 и думаешь сразу накатывать приложения? Не торопись. Сейчас потратишь час на базовую настройку безопасности - сэкономишь недели на разгребание последствий взлома.\nЭта статья - универсальная база для любого production-сервера. Не важно, будешь ты крутить почту, веб или базу данных - эти настройки нужны всем. Что будем делать # Превратим голый Debian в сервер, который:\nНе пустит первого попавшегося ботнет Автоматически обновляет критичные патчи Не упадет от fork-бомбы Защищен от базовых сетевых атак Имеет бэкапы конфигов для восстановления Логирует подозрительную активность Без фанатизма и паранойи. Только то, что реально защищает.\nИсходные данные # Что есть:\nЧистый Debian 12 (Bookworm) Root-доступ по SSH Статический IP-адрес Что НЕ рассматриваю:\nDocker/Podman контейнеры (это отдельная тема) Кластерные конфигурации Специфичные настройки приложений Обновление системы # Первым делом - обновить все до актуального состояния.\napt update apt upgrade -y apt dist-upgrade -y Что делают команды:\napt update - обновить списки пакетов apt upgrade - обновить установленные пакеты apt dist-upgrade - обновить с разрешением зависимостей (может удалить/добавить пакеты) Если спросит про перезагрузку сервисов - соглашайся.\nПроверь, нужна ли перезагрузка:\n[ -f /var/run/reboot-required ] \u0026amp;\u0026amp; echo \u0026#34;Reboot needed\u0026#34; || echo \u0026#34;No reboot needed\u0026#34; Если нужна - перезагружаемся:\nreboot Базовые утилиты # Поставь то, что понадобится для работы и отладки:\napt install -y \\ sudo \\ curl \\ wget \\ git \\ htop \\ iotop \\ iftop \\ net-tools \\ dnsutils \\ telnet \\ tcpdump \\ screen \\ tmux \\ rsync \\ unzip \\ ca-certificates \\ gnupg2 \\ lsb-release Что установили:\nМониторинг: htop, iotop, iftop Сеть: net-tools, dnsutils, telnet, tcpdump Сессии: screen, tmux Утилиты: curl, wget, git, rsync, unzip Настройка системы # Hostname # Установи правильное имя сервера:\nhostnamectl set-hostname srv01.example.ru Замени srv01.example.ru на свое.\nЗапусти новую ssh сессию и проверь:\nhostname -f Должно вернуть полное имя (FQDN).\nФайл /etc/hosts # Открой:\nnano /etc/hosts Приведи к виду:\n127.0.0.1 localhost твой-IP srv01.example.ru srv01 # IPv6 ::1 localhost ip6-localhost ip6-loopback ff02::1 ip6-allnodes ff02::2 ip6-allrouters Замени:\nтвой-IP - на реальный IP сервера srv01.example.ru - на свое имя Timezone # Установи правильную временную зону:\ntimedatectl set-timezone Europe/Moscow Посмотри доступные зоны:\ntimedatectl list-timezones | grep Moscow Проверь текущее время:\ntimedatectl Должно показать правильный timezone и время.\nЛокаль # Проверь текущую локаль:\nlocale Если видишь ошибки или не UTF-8 - настрой:\napt install -y locales dpkg-reconfigure locales Выбери:\nen_US.UTF-8 ru_RU.UTF-8 (если нужна кириллица) По умолчанию поставь en_US.UTF-8.\nПроверь:\nlocale Должно быть LANG=en_US.UTF-8.\nSSH hardening # SSH - главный вход на сервер. Если его взломают - все остальное не имеет смысла.\nСоздание пользователя (не root) # Работать от root - плохая идея. Создай обычного пользователя:\nadduser admin Введи пароль (временный, потом отключим).\nДобавь в sudo:\nusermod -aG sudo admin Проверь:\ngroups admin Должно быть: admin : admin sudo\nSSH ключи # На своей рабочей машине (возможно это Windows) сгенерируй ключ:\nssh-keygen -t ed25519 -C \u0026#34;admin@srv01\u0026#34; или так\ncd c:\\users\\$env:username ssh-keygen Если спросит путь - жми Enter (по умолчанию ~/.ssh/id_ed25519).\nЕсли спросит passphrase - на твое усмотрение (дополнительная защита ключа).\nСкопируй публичный ключ на сервер:\nWindows Linux type $env:userprofile\\.ssh\\id_ed25519.pub | ssh admin@твой-IP \u0026#34;if [ ! -d ~/.ssh ]; then mkdir -m 700 ~/.ssh; fi; if [ ! -f ~/.ssh/authorized_keys ]; then touch ~/.ssh/authorized_keys; chmod 600 ~/.ssh/authorized_keys; fi; cat \u0026gt;\u0026gt; ~/.ssh/authorized_keys\u0026#34; Команда проверяет наличие ~/.ssh/authorized_keys и если ее нет - создает с нужными правами.\nssh-copy-id admin@твой-IP Введи пароль пользователя admin.\nПроверь вход по ключу:\nssh admin@твой-IP Не должен спрашивать пароль (только passphrase ключа, если установил).\nЕсли работает - отлично. Теперь настроим сервер.\nНастройка sshd # Залогинься на сервер под admin:\nssh admin@твой-IP Открой конфиг SSH:\nsudo nano /etc/ssh/sshd_config Найди и измени (или добавь):\n# Базовые настройки Port 2222 PermitRootLogin no PasswordAuthentication no PubkeyAuthentication yes AuthorizedKeysFile .ssh/authorized_keys # Ограничения MaxAuthTries 3 MaxSessions 2 LoginGraceTime 30 # Отключаем лишнее PermitEmptyPasswords no ChallengeResponseAuthentication no UsePAM yes X11Forwarding no PrintMotd no AcceptEnv LANG LC_* # Таймауты ClientAliveInterval 300 ClientAliveCountMax 2 # Только IPv4 (если не используешь IPv6) AddressFamily inet Что настроили:\nPort 2222:\nНестандартный порт вместо 22 Отсекает 90% ботов ВАЖНО: Запомни новый порт! PermitRootLogin no:\nЗапрет входа под root Обязательно использовать обычного пользователя + sudo PasswordAuthentication no:\nОтключаем пароли Только SSH-ключи MaxAuthTries 3:\nМаксимум 3 попытки входа LoginGraceTime 30:\n30 секунд на авторизацию ClientAliveInterval 300:\nПинговать клиента каждые 5 минут Отключать неактивные сессии Проверь конфиг:\nsudo sshd -t Если ошибок нет - ничего не выводит.\nКРИТИЧЕСКИ ВАЖНО: Перед перезапуском SSH открой вторую сессию и не закрывай её: ssh admin@твой-IP Это страховка. Если что-то пойдет не так - сможешь исправить через вторую сессию.\nПерезапусти SSH в первой сессии:\nsudo systemctl restart sshd Во второй сессии проверь новое подключение:\nssh -p 2222 admin@твой-IP Если работает - отлично. Можешь закрыть старые сессии.\nЕсли не работает - исправляй через вторую (старую) сессию.\nSSH banner # Добавь предупреждение при входе.\nСоздай файл:\nsudo nano /etc/ssh/banner Запиши:\n############################################################################### # ВНИМАНИЕ! # # # # Доступ к этой системе разрешен только авторизованным пользователям. # # Все действия логируются и могут быть использованы в качестве # # доказательств при расследовании инцидентов. # # # # Несанкционированный доступ преследуется по закону. # # # ############################################################################### Добавь в /etc/ssh/sshd_config:\nBanner /etc/ssh/banner Перезапусти SSH:\nsudo systemctl restart sshd При следующем входе увидишь баннер.\nFirewall (ufw) # Firewall - первая линия защиты от сетевых атак.\nУстановка ufw # sudo apt install -y ufw Базовые правила # ВАЖНО: Сначала настроим правила, потом включим. Иначе можешь заблокировать себя. Политика по умолчанию - блокировать все входящее:\nsudo ufw default deny incoming sudo ufw default allow outgoing Разрешаем SSH на новом порту:\nsudo ufw allow 2222/tcp comment \u0026#39;SSH\u0026#39; Замени 2222 на свой порт из sshd_config.\nНе открывай порты, которые не нужны! Добавишь потом по мере необходимости.\nRate limiting для SSH # Защита от брутфорса на уровне firewall:\nsudo ufw limit 2222/tcp Это ограничит количество подключений (максимум 6 попыток за 30 секунд).\nВключаем firewall # Проверь правила:\nsudo ufw show added Должно быть что-то вроде:\nufw allow 2222/tcp Включаем:\nsudo ufw enable Спросит: Command may disrupt existing ssh connections. Proceed with operation (y|n)?\nт.е. Выполнение команды может прервать существующие SSH-соединения. Продолжить операцию? (y|n)\nЖми y.\nПроверь статус:\nsudo ufw status verbose Должно быть:\nStatus: active Logging: on (low) Default: deny (incoming), allow (outgoing), disabled (routed) To Action From -- ------ ---- 2222/tcp LIMIT Anywhere Включи автозапуск:\nsudo systemctl enable ufw Fail2ban # Firewall блокирует порты, Fail2ban блокирует IP-адреса с подозрительной активностью.\nУстановка # sudo apt install -y fail2ban Базовая настройка # Создай конфиг:\nsudo nano /etc/fail2ban/jail.local Внеси:\n[DEFAULT] # Время бана (10 минут) bantime = 600 # Окно наблюдения (10 минут) findtime = 600 # Количество попыток maxretry = 5 # Игнорируемые IP (свои сети) ignoreip = 127.0.0.1/8 ::1 # Действие при бане banaction = ufw action = %(action_mwl)s [sshd] enabled = true port = 2222 logpath = /var/log/auth.log maxretry = 3 Что настроили:\nDEFAULT:\nbantime = 600 - бан на 10 минут findtime = 600 - смотрим на последние 10 минут maxretry = 5 - максимум 5 попыток ignoreip - список IP, которые не баним (добавь свои) banaction = ufw - блокировать через ufw action = %(action_mwl)s - бан + письмо (если настроена почта) sshd:\nЗащита SSH Порт 2222 (твой порт) Максимум 3 попытки (строже чем default) Запуск # sudo systemctl enable fail2ban sudo systemctl start fail2ban sudo systemctl status fail2ban Должен быть active (running).\nПроверка # Посмотри статус тюрем:\nsudo fail2ban-client status Должно быть:\nStatus |- Number of jail: 1 `- Jail list: sshd Статус конкретной тюрьмы:\nsudo fail2ban-client status sshd Покажет:\nКоличество забаненных IP Список IP Тест # С другого IP попробуй подключиться с неправильным паролем 3 раза подряд:\nssh fake@твой-IP -p 2222 После 3-й попытки IP должен быть забанен.\nПроверь:\nsudo fail2ban-client status sshd Должен появиться IP в Banned IP list.\nРазбань (для теста):\nsudo fail2ban-client set sshd unbanip IP-адрес Kernel hardening (sysctl) # Настроим ядро для защиты от сетевых атак.\nОткрой:\nsudo nano /etc/sysctl.d/99-hardening.conf Внеси:\n# IP Forwarding (отключаем если не роутер) net.ipv4.ip_forward = 0 net.ipv6.conf.all.forwarding = 0 # Защита от IP spoofing net.ipv4.conf.all.rp_filter = 1 net.ipv4.conf.default.rp_filter = 1 # Игнорировать ICMP redirects net.ipv4.conf.all.accept_redirects = 0 net.ipv4.conf.default.accept_redirects = 0 net.ipv4.conf.all.secure_redirects = 0 net.ipv4.conf.default.secure_redirects = 0 net.ipv6.conf.all.accept_redirects = 0 net.ipv6.conf.default.accept_redirects = 0 # Не отправлять ICMP redirects net.ipv4.conf.all.send_redirects = 0 net.ipv4.conf.default.send_redirects = 0 # Игнорировать ICMP broadcast net.ipv4.icmp_echo_ignore_broadcasts = 1 # Игнорировать bogus ICMP errors net.ipv4.icmp_ignore_bogus_error_responses = 1 # Логировать подозрительные пакеты net.ipv4.conf.all.log_martians = 1 net.ipv4.conf.default.log_martians = 1 # Защита от SYN flood net.ipv4.tcp_syncookies = 1 net.ipv4.tcp_max_syn_backlog = 2048 net.ipv4.tcp_synack_retries = 2 net.ipv4.tcp_syn_retries = 5 # Отключить source routing net.ipv4.conf.all.accept_source_route = 0 net.ipv4.conf.default.accept_source_route = 0 net.ipv6.conf.all.accept_source_route = 0 net.ipv6.conf.default.accept_source_route = 0 # Оптимизация TCP net.ipv4.tcp_fin_timeout = 15 net.ipv4.tcp_keepalive_time = 300 net.ipv4.tcp_keepalive_probes = 5 net.ipv4.tcp_keepalive_intvl = 15 # Увеличить диапазон портов net.ipv4.ip_local_port_range = 1024 65535 # Защита от TIME_WAIT net.ipv4.tcp_rfc1337 = 1 # Увеличить буферы net.core.rmem_max = 16777216 net.core.wmem_max = 16777216 net.ipv4.tcp_rmem = 4096 87380 16777216 net.ipv4.tcp_wmem = 4096 65536 16777216 # Файловая система fs.file-max = 65535 fs.inotify.max_user_watches = 524288 Что настроили:\nЗащита от spoofing:\nReverse path filtering Проверка source routing Защита от ICMP атак:\nИгнорируем redirects Игнорируем broadcast ping Защита от SYN flood:\nSYN cookies Ограничение backlog Оптимизация TCP:\nТаймауты соединений Размеры буферов Диапазон портов Применяем:\nsudo sysctl -p /etc/sysctl.d/99-hardening.conf Проверяем:\nsudo sysctl net.ipv4.tcp_syncookies Должно вернуть: net.ipv4.tcp_syncookies = 1\nSwap # Если RAM мало (меньше 4GB) - настрой swap.\nПроверка наличия swap # free -h Если в строке Swap везде нули - нет swap.\nТакже проверь:\nswapon --show Если пусто - нет swap.\nСоздание swap-файла # Создаем файл на 2GB:\nsudo fallocate -l 2G /swapfile Если fallocate не работает:\nsudo dd if=/dev/zero of=/swapfile bs=1M count=2048 Права:\nsudo chmod 600 /swapfile Форматируем:\nsudo mkswap /swapfile Включаем:\nsudo swapon /swapfile Проверяем:\nfree -h Должен появиться swap.\nДелаем постоянным (добавляем в fstab):\necho \u0026#39;/swapfile none swap sw 0 0\u0026#39; | sudo tee -a /etc/fstab Настройка swappiness # Swappiness - насколько активно использовать swap.\nПроверь текущее значение:\ncat /proc/sys/vm/swappiness По умолчанию: 60 (агрессивно).\nДля серверов лучше 10:\nsudo nano /etc/sysctl.d/99-swappiness.conf Запиши:\nvm.swappiness = 10 Применяем:\nsudo sysctl -p /etc/sysctl.d/99-swappiness.conf Проверяем:\ncat /proc/sys/vm/swappiness Должно быть: 10\nОграничение ресурсов # Защита от fork-бомб и исчерпания дескрипторов.\nfork-бомба - это программа (вредоносная или написанная по ошибке), которая бесконечно создаёт собственные копии через системный вызов fork(), пока ресурсы системы полностью не иссякнут.\nОткрой:\nsudo nano /etc/security/limits.conf Добавь в конец:\n# Ограничения для обычных пользователей * soft nofile 65535 * hard nofile 65535 * soft nproc 32768 * hard nproc 32768 # Ограничения для root root soft nofile 65535 root hard nofile 65535 root soft nproc 32768 root hard nproc 32768 Что настроили:\nnofile:\nМаксимум открытых файлов/сокетов 65535 достаточно для большинства случаев nproc:\nМаксимум процессов пользователя 32768 защитит от fork-бомбы Также настрой PAM:\nsudo nano /etc/pam.d/common-session Добавь в конец:\nsession required pam_limits.so Перелогинься и проверь:\nulimit -n ulimit -u Должно быть:\nulimit -n → 65535 ulimit -u → 32768 Автообновления безопасности # Критичные патчи должны ставиться автоматически.\nУстановка # sudo apt install -y unattended-upgrades apt-listchanges Настройка # Открой:\nsudo nano /etc/apt/apt.conf.d/50unattended-upgrades Найди и раскомментируй/измени:\nUnattended-Upgrade::Origins-Pattern { // Только security-обновления: \u0026#34;origin=Debian,codename=${distro_codename},label=Debian-Security\u0026#34;; \u0026#34;origin=Debian,codename=${distro_codename}-security,label=Debian-Security\u0026#34;; }; Unattended-Upgrade::AutoFixInterruptedDpkg \u0026#34;true\u0026#34;; Unattended-Upgrade::MinimalSteps \u0026#34;true\u0026#34;; Unattended-Upgrade::Remove-Unused-Kernel-Packages \u0026#34;true\u0026#34;; Unattended-Upgrade::Remove-Unused-Dependencies \u0026#34;true\u0026#34;; Unattended-Upgrade::Automatic-Reboot \u0026#34;false\u0026#34;; Unattended-Upgrade::Automatic-Reboot-Time \u0026#34;03:00\u0026#34;; Что настроили:\nAllowed-Origins:\nТолько обновления безопасности Не обновляем все подряд AutoFixInterruptedDpkg:\nАвтоматически чинить прерванные установки Remove-Unused-Kernel-Packages:\nУдалять старые ядра Remove-Unused-Dependencies:\nУдалять неиспользуемые зависимости Automatic-Reboot:\nfalse - не перезагружаться автоматически Если хочешь автоперезагрузку → true (в 3 ночи) Включаем:\nsudo dpkg-reconfigure -plow unattended-upgrades Выбери Yes.\nПроверь статус:\nsudo systemctl status unattended-upgrades Должен быть active.\nПроверь логи (через некоторое время):\ncat /var/log/unattended-upgrades/unattended-upgrades.log Отключение ненужных сервисов # Меньше сервисов - меньше поверхность атаки. Арендованные VPS обычно используют специальные версии операционных систем - в них и так все по-минимуму. В остальных случаях:\nСписок запущенных сервисов # systemctl list-units --type=service --state=running Что можно отключить # Bluetooth (на сервере не нужен):\nsudo systemctl stop bluetooth.service sudo systemctl disable bluetooth.service sudo systemctl mask bluetooth.service ModemManager (если нет модема):\nsudo systemctl stop ModemManager.service sudo systemctl disable ModemManager.service Avahi (mDNS, обычно не нужен):\nsudo systemctl stop avahi-daemon.service sudo systemctl disable avahi-daemon.service Cups (печать, на сервере не нужна):\nsudo systemctl stop cups.service sudo systemctl disable cups.service ВАЖНО: Не отключай:\nsshd - без него не зайдешь systemd-* - системные сервисы cron - для задач по расписанию rsyslog - логирование Проверь, что отключилось:\nsystemctl list-units --type=service --state=running Логирование # Настройка rsyslog # Открой:\nsudo nano /etc/rsyslog.conf Убедись, что есть:\n# Логи аутентификации auth,authpriv.* /var/log/auth.log # Системные логи *.*;auth,authpriv.none -/var/log/syslog # Cron cron.* /var/log/cron.log # Ядро kern.* -/var/log/kern.log # Почта (если будет) mail.* -/var/log/mail.log mail.err /var/log/mail.err Перезапусти:\nsudo systemctl restart rsyslog Ротация логов # Открой:\nsudo nano /etc/logrotate.conf Убедись, что есть:\n# Ротация раз в неделю weekly # Хранить 4 недели rotate 4 # Создавать новые файлы create # Сжимать старые compress Настрой индивидуальную ротацию:\nsudo nano /etc/logrotate.d/rsyslog /var/log/syslog /var/log/mail.log /var/log/mail.err /var/log/auth.log { rotate 7 daily missingok notifempty compress delaycompress sharedscripts postrotate /usr/lib/rsyslog/rsyslog-rotate endscript } Что настроили:\nРотация каждый день Хранить 7 дней Сжимать старые логи Проверь ротацию вручную:\nsudo logrotate -f /etc/logrotate.conf Автоматическая очистка # Старые ядра # Debian накапливает старые версии ядер. Очистка:\nsudo apt autoremove --purge -y Автоматизируем в unattended-upgrades (уже настроили выше):\nUnattended-Upgrade::Remove-Unused-Kernel-Packages \u0026#34;true\u0026#34;; Кэш пакетов # Очистка вручную:\nsudo apt clean sudo apt autoclean Автоматизация:\nsudo nano /etc/apt/apt.conf.d/10periodic APT::Periodic::Update-Package-Lists \u0026#34;1\u0026#34;; APT::Periodic::Download-Upgradeable-Packages \u0026#34;1\u0026#34;; APT::Periodic::AutocleanInterval \u0026#34;7\u0026#34;; APT::Periodic::Unattended-Upgrade \u0026#34;1\u0026#34;; Что настроили:\nОбновлять списки пакетов ежедневно Скачивать обновления Очищать кэш раз в неделю Ставить обновления безопасности Journald # Ограничиваем размер логов systemd:\nsudo nano /etc/systemd/journald.conf Раскомментируй/измени:\nSystemMaxUse=500M SystemMaxFileSize=100M MaxRetentionSec=1month Перезапусти:\nsudo systemctl restart systemd-journald Проверь размер:\nsudo journalctl --disk-usage Должно быть в пределах 500M.\nБазовый бэкап # Что бэкапить # Критично:\n/etc/ - все конфиги системы /root/.ssh/ - SSH ключи root /home/*/. ssh/ - SSH ключи пользователей Список установленных пакетов Важно (если есть):\n/var/www/ - веб-сайты /var/spool/mail/ - почта Базы данных Скрипт бэкапа конфигов # Создай:\nsudo nano /root/backup-configs.sh #!/bin/bash BACKUP_DIR=\u0026#34;/root/backups\u0026#34; DATE=$(date +%Y%m%d_%H%M%S) BACKUP_FILE=\u0026#34;$BACKUP_DIR/config_backup_$DATE.tar.gz\u0026#34; # Создаем директорию если нет mkdir -p $BACKUP_DIR # Список установленных пакетов dpkg --get-selections \u0026gt; /root/installed-packages.txt # Архивируем конфиги tar -czf $BACKUP_FILE \\ /etc/ \\ /root/.ssh/ \\ /home/*/.ssh/ \\ /root/installed-packages.txt \\ 2\u0026gt;/dev/null # Удаляем бэкапы старше 30 дней find $BACKUP_DIR -name \u0026#34;config_backup_*.tar.gz\u0026#34; -mtime +30 -delete echo \u0026#34;Backup created: $BACKUP_FILE\u0026#34; ls -lh $BACKUP_FILE Права на выполнение:\nsudo chmod +x /root/backup-configs.sh Запусти:\nsudo /root/backup-configs.sh Проверь:\nls -lh /root/backups/ Автоматизация бэкапа # Добавь в cron (раз в неделю):\nsudo crontab -e Добавь:\n0 3 * * 0 /root/backup-configs.sh \u0026gt;\u0026gt; /var/log/backup-configs.log 2\u0026gt;\u0026amp;1 Это запустит скрипт каждое воскресенье в 3 ночи.\nВосстановление # Если нужно восстановить конфиги:\nsudo tar -xzf /root/backups/config_backup_ДАТА.tar.gz -C / Если нужно восстановить пакеты:\nsudo dpkg --set-selections \u0026lt; /root/installed-packages.txt sudo apt-get dselect-upgrade Мониторинг # Проверка диска # Установи smartmontools:\nsudo apt install -y smartmontools Проверь диск:\nsudo smartctl -H /dev/sda Должно вернуть: SMART Health Status: OK\nЕсли FAILED - диск умирает.\nПосмотри детали:\nsudo smartctl -a /dev/sda Алерты на критичные события # Для работы почтовых уведомлений должен быть настроен MTA (например Postfix). Создай скрипт проверки:\nsudo nano /root/health-check.sh #!/bin/bash ALERT_EMAIL=\u0026#34;admin@example.ru\u0026#34; HOSTNAME=$(hostname) # Проверка места на диске DISK_USAGE=$(df -h / | awk \u0026#39;NR==2 {print $5}\u0026#39; | sed \u0026#39;s/%//\u0026#39;) if [ $DISK_USAGE -gt 90 ]; then echo \u0026#34;ALERT: Disk usage is ${DISK_USAGE}% on $HOSTNAME\u0026#34; | mail -s \u0026#34;Disk Alert\u0026#34; $ALERT_EMAIL fi # Проверка RAM MEM_USAGE=$(free | awk \u0026#39;NR==2 {printf \u0026#34;%.0f\u0026#34;, $3/$2*100}\u0026#39;) if [ $MEM_USAGE -gt 90 ]; then echo \u0026#34;ALERT: Memory usage is ${MEM_USAGE}% on $HOSTNAME\u0026#34; | mail -s \u0026#34;Memory Alert\u0026#34; $ALERT_EMAIL fi # Проверка swap SWAP_USAGE=$(free | awk \u0026#39;NR==3 {if ($2 \u0026gt; 0) printf \u0026#34;%.0f\u0026#34;, $3/$2*100; else print 0}\u0026#39;) if [ $SWAP_USAGE -gt 80 ]; then echo \u0026#34;ALERT: Swap usage is ${SWAP_USAGE}% on $HOSTNAME\u0026#34; | mail -s \u0026#34;Swap Alert\u0026#34; $ALERT_EMAIL fi # Проверка load average LOAD=$(uptime | awk -F\u0026#39;load average:\u0026#39; \u0026#39;{print $2}\u0026#39; | awk \u0026#39;{print $1}\u0026#39; | sed \u0026#39;s/,//\u0026#39;) CORES=$(nproc) LOAD_INT=$(echo $LOAD | awk \u0026#39;{printf \u0026#34;%.0f\u0026#34;, $1}\u0026#39;) if [ $LOAD_INT -gt $((CORES * 2)) ]; then echo \u0026#34;ALERT: Load average is $LOAD on $HOSTNAME ($CORES cores)\u0026#34; | mail -s \u0026#34;Load Alert\u0026#34; $ALERT_EMAIL fi ВАЖНО: Замени admin@example.ru на свой email. Права:\nsudo chmod +x /root/health-check.sh Добавь в cron (каждые 15 минут):\nsudo crontab -e Если отправка почты настроена:\n*/15 * * * * /root/health-check.sh Если отправки почты нет - можешь логировать:\n*/15 * * * * /root/health-check.sh \u0026gt;\u0026gt; /var/log/health-check.log 2\u0026gt;\u0026amp;1 Тестирование # Проверь с внешнего IP:\n# Проверка SSH ssh -p 2222 admin@твой-IP # Сканирование портов (с другого сервера) nmap -p 1-65535 твой-IP # Проверка firewall telnet твой-IP 22 # Должен быть недоступен telnet твой-IP 2222 # Должен быть доступен Что дальше # Сервер готов к установке приложений. Теперь можешь:\nСтавить веб-сервер (Nginx/Apache) Разворачивать почтовый сервер (Postfix/Dovecot) Устанавливать базы данных (PostgreSQL/MySQL) Крутить любые сервисы Все специфичные настройки делаются поверх этой базы.\nГлавное: Не забывай регулярно проверять логи и обновлять систему. Безопасность - это процесс, а не состояние.\n","date":"7 марта 2026","externalUrl":null,"permalink":"/posts/tips-debian-12-hardening/","section":"Статьи","summary":"","title":"Безопасный production-сервер на Debian 12: пошаговая настройка","type":"posts"},{"content":"Решил поднять свой почтовый сервер? Отлично. Сейчас объясню, почему это одновременно лучшее и худшее решение, которое ты можешь принять для своей инфраструктуры.\nЗачем вообще заморачиваться # Что говорят в интернете: \u0026ldquo;Gmail бесплатный и работает отлично, зачем изобретать велосипед?\u0026rdquo;\nКак есть на самом деле: # Gmail и прочие публичные сервисы - это хорошо до тех пор, пока:\nТебя не смущает, что твою переписку читают для таргетинга рекламы Ты не против того, что твой домен висит на чужой инфраструктуре Тебе не критично, когда Google решит внезапно заблокировать аккаунт без объяснений Ты готов платить за каждый ящик в корпоративном тарифе Тебе норм, что лимиты на размер ящика устанавливает кто-то другой Свой почтовый сервер дает:\nПолный контроль - ты сам решаешь кто, что и как Безлимитные ящики - сколько нужно, столько и создашь Любой размер - ограничение только в железе Свои правила - никаких внезапных \u0026ldquo;обновлений политики\u0026rdquo; Прозрачность - ты знаешь где лежит твоя почта и кто к ней имеет доступ Но есть нюанс.\nРеальность: во что ты ввязываешься # Первые 48 часов после запуска: # Час 1: Сервер работает, письма ходят, ты доволен собой.\nЧас 3: Gmail отправляет твои письма в спам. Начинаешь разбираться с SPF.\nЧас 6: SPF настроен. Письма все равно в спаме. Погружаешься в DKIM.\nЧас 12: DKIM работает. Половина писем доходит. Открываешь документацию DMARC.\nЧас 24: Понимаешь, что твой IP попал в какой-то DNSBL. Гуглишь что это вообще такое.\nЧас 48: Письма наконец-то доходят до inbox. Пользователь жалуется: \u0026ldquo;Я не получил письмо от клиента\u0026rdquo;. Начинаешь копаться в логах.\nНеделя 1: Обнаруживаешь, что сервер стал источником спама. Как это получилось - вопрос другой.\nМесяц 1: Осознаешь, что забыл настроить бэкапы. Молишься, чтобы диск не \u0026ldquo;помер\u0026rdquo;.\nЭто нормально # Я не пугаю. Я готовлю к реальности. Почтовый сервер - это не \u0026ldquo;поставил и забыл\u0026rdquo;. Это инфраструктура, которая требует:\nНачальной настройки (4-8 часов чистого времени) Отладки репутации (первые 2-4 недели) Регулярного мониторинга (15-30 минут в день) Периодического обслуживания (2-4 часа в месяц) Но когда оно работает - работает как часы.\nЧто ты получишь в итоге # Техническая часть: # ТВОЙ ПОЧТОВЫЙ СЕРВЕР │ ├─ Прием и отправка почты (Postfix) ├─ Доступ к ящикам через IMAP (Dovecot) ├─ Веб-интерфейс (RoundCube) ├─ Защита от вирусов (ClamAV) ├─ Защита от спама (SpamAssassin) ├─ Защита от взлома (Fail2ban) ├─ Шифрование (Let\u0026#39;s Encrypt) ├─ Подписи писем (DKIM) └─ Мониторинг и логи (Pflogsumm) Функциональная часть: # Отправка почты:\nС любого почтового клиента (Thunderbird, Outlook, Apple Mail, K-9) Через веб-интерфейс из любой точки мира С защищенным соединением (TLS) С цифровой подписью (твои письма не подделать) Прием почты:\nОт любых отправителей С проверкой на вирусы С фильтрацией спама С пользовательскими правилами (Sieve) Управление:\nНеограниченное количество доменов Неограниченное количество ящиков Псевдонимы (алиасы) Пересылки Автоответчики Квоты на размер (если нужно) Безопасность:\nШифрование при передаче (никто не прочитает по пути) Защита от подбора паролей Блокировка спамеров Проверка отправителей Из чего это состоит: анатомия почтового сервера # Почтовый сервер - это не одна программа. Это экосистема из нескольких компонентов, каждый из которых делает свою работу.\nPostfix - почтальон # Что делает: Принимает письма от отправителей и доставляет их получателям.\nКак работает:\nКто-то отправляет письмо на user@твой-домен.ru Postfix принимает письмо на порт 25 Проверяет: \u0026ldquo;А должен ли я вообще принимать почту для этого домена?\u0026rdquo; Проверяет отправителя через кучу правил Если все ОК - передает письмо дальше (Dovecot или другому Postfix) Если что-то не так - отклоняет с кодом ошибки Конфиг: /etc/postfix/main.cf - туда ты будешь лезть чаще всего.\nDovecot - хранитель ящиков # Что делает: Дает доступ к почтовым ящикам по протоколам IMAP/POP3.\nКак работает:\nПочтовый клиент подключается к Dovecot (порт 143/993) Вводит логин и пароль Dovecot проверяет учетные данные Открывает доступ к почтовому ящику Клиент качает письма Дополнительно:\nРаботает как LDA (Local Delivery Agent) - складывает письма в ящики Поддерживает Sieve - пользовательские фильтры Управляет квотами Конфиг: /etc/dovecot/dovecot.conf и куча подфайлов в /etc/dovecot/conf.d/.\nAmavis + ClamAV - санитары # Что делает Amavis: Прослойка между Postfix и антивирусом/антиспамом.\nЧто делает ClamAV: Сканирует вложения на вирусы.\nКак работает:\nPostfix получает письмо Отправляет его в Amavis (порт 10024) Amavis передает в ClamAV ClamAV сканирует Если вирус найден - письмо в карантин Если чисто - Amavis возвращает в Postfix (порт 10025) Postfix доставляет получателю Конфиг: /etc/amavis/conf.d/50-user\nSpamAssassin - фильтр # Что делает: Определяет спам по куче признаков.\nКак работает:\nАнализирует заголовки письма Проверяет тело письма Смотрит IP отправителя в черных списках (DNSBL) Применяет эвристические правила Использует Bayesian фильтр (обучаемый) Выставляет баллы Если баллов больше порога (обычно 5) - помечает как SPAM Обучение:\nПоказываешь примеры спама → он учится Показываешь примеры нормальной почты → он учится Со временем точность растет Конфиг: /etc/spamassassin/local.cf\nPostgrey - вышибала # Что делает: Временно отклоняет письма от новых отправителей.\nКак работает:\nПисьмо приходит от нового сервера Postgrey: \u0026ldquo;Приходи через 5 минут\u0026rdquo; Легальный почтовый сервер вернется через 5 минут Спам-бот не вернется (ему некогда) При повторной попытке - пропускает Эффективность: Отсекает ~70% спама вообще без анализа.\nКонфиг: /etc/default/postgrey\nFail2ban - охрана # Что делает: Блокирует IP-адреса, которые брутфорсят(взламывают методом перебора парольных комбинаций) пароли.\nКак работает:\nСледит за логами Видит неудачные попытки входа Считает количество попыток Если больше 3-5 за короткое время - бан IP через iptables Через час-два-пять(как настроишь) разбанивает (если не повторится) Защищает:\nSMTP AUTH (порт 25, 587) IMAP/POP3 (порт 143, 993, 110, 995) Веб-интерфейс RoundCube Конфиг: /etc/fail2ban/jail.local\nLet\u0026rsquo;s Encrypt - замок # Что делает: Выдает бесплатные SSL-сертификаты.\nЗачем: Шифрование соединений между клиентом и сервером.\nКак работает:\nЗапускаешь Certbot Он доказывает, что домен принадлежит тебе Let\u0026rsquo;s Encrypt выдает сертификат на 90 дней Certbot автоматически продлевает каждые 60 дней Без этого: Все пароли и письма идут открытым текстом по сети.\nКонфиг: Автоматический, сертификаты в /etc/letsencrypt/live/\nOpenDKIM - печать # Что делает: Подписывает исходящие письма цифровой подписью.\nЗачем: Доказывает, что письмо действительно от твоего домена.\nКак работает:\nГенеришь пару ключей (открытый + закрытый) Открытый публикуешь в DNS OpenDKIM подписывает каждое исходящее письмо закрытым ключом Получатель проверяет подпись открытым ключом из DNS Если подпись валидна - письмо не подделано Без этого: Gmail/Outlook 100% отправят твои письма в спам.\nКонфиг: /etc/opendkim.conf\nRoundCube - веб-морда # Что делает: Веб-интерфейс для работы с почтой.\nЗачем: Читать/писать письма через браузер без настройки почтового клиента.\nВозможности:\nЧтение/отправка писем Адресная книга Настройка фильтров (через плагин Sieve) Смена паролей Управление папками Поиск по почте Конфиг: /etc/roundcube/config.inc.php\nPostgreSQL - база данных # Что делает: Хранит данные.\nЧто хранит:\nБазу RoundCube (сессии, адресная книга, кэш) Опционально: список пользователей и паролей Опционально: псевдонимы и пересылки Почему PostgreSQL, а не MySQL:\nСтроже к типам данных → меньше косяков Лучше работает с UTF-8 Проще репликация В Debian 12 отличная интеграция Конфиг: /etc/postgresql/15/main/postgresql.conf\nApache + PHP # Что делает: Крутит RoundCube.\nApache: Веб-сервер, принимает HTTP-запросы.\nPHP: Интерпретатор, выполняет код RoundCube.\nАльтернатива: Nginx + PHP-FPM (быстрее, но сложнее, может в будущем рассмотрю и такой подход).\nПочему Apache: Работает из коробки, тупо проще с той же эффективностью.\nКонфиг: /etc/apache2/sites-available/\nPflogsumm # Что делает: Анализирует логи Postfix и делает отчеты.\nПоказывает:\nСколько писем отправлено/получено Сколько отклонено Топ отправителей/получателей Ошибки доставки Статистику по доменам Использование:\npflogsumm /var/log/mail.log Автоматизация: Настроишь cron - каждый день отчет на почту.\nNetdata - приборная панель # Что делает: Мониторинг в реальном времени.\nПоказывает:\nЗагрузка CPU, RAM, Disk Очереди Postfix Соединения Dovecot Запросы к PostgreSQL Запросы к Apache Температура (если есть датчики) Интерфейс: Веб на порту 19999.\nПотребление: ~100MB RAM.\nКак все это работает вместе # Сценарий 1: Получение письма # 1. example.ru отправляет письмо на user@твой-домен.ru ↓ 2. DNS: \u0026#34;MX-запись для твой-домен.ru → mail.твой-домен.ru\u0026#34; ↓ 3. Письмо приходит на твой сервер (Postfix, порт 25) ↓ 4. Postfix: \u0026#34;Проверю отправителя...\u0026#34; - SPF проверка - DNSBL проверка - Greylisting (Postgrey) ↓ 5. Postfix: \u0026#34;Отправлю на проверку в Amavis\u0026#34; ↓ 6. Amavis → ClamAV: \u0026#34;Есть вирусы?\u0026#34; ClamAV: \u0026#34;Чисто\u0026#34; ↓ 7. Amavis → SpamAssassin: \u0026#34;Это спам?\u0026#34; SpamAssassin: \u0026#34;3 балла из 5, норм\u0026#34; ↓ 8. Amavis возвращает в Postfix: \u0026#34;Все ок, доставляй\u0026#34; ↓ 9. Postfix → Dovecot (LMTP): \u0026#34;Положи в ящик user@твой-домен.ru\u0026#34; ↓ 10. Dovecot кладет в /var/spool/mail/твой-домен.ru/user/ ↓ 11. Пользователь открывает RoundCube или Thunderbird ↓ 12. Dovecot (IMAP) отдает письмо клиенту Сценарий 2: Отправка письма # 1. Пользователь пишет письмо в RoundCube ↓ 2. RoundCube → Postfix (порт 587, SMTP Submission) ↓ 3. Postfix: \u0026#34;Проверю авторизацию...\u0026#34; - SMTP AUTH через Dovecot ↓ 4. Postfix: \u0026#34;Пользователь свой, подпишу письмо\u0026#34; ↓ 5. OpenDKIM добавляет DKIM-подпись ↓ 6. Postfix отправляет письмо на mail.example.ru ↓ 7. example.ru получает и проверяет: - SPF (в DNS твоего домена) - DKIM (подпись валидна?) - DMARC (политика домена) ↓ 8. example.ru: \u0026#34;Все проверки пройдены\u0026#34; → Inbox Терминология: что есть что # MTA (Mail Transfer Agent) # Что: Программа, которая передает почту между серверами. Пример: Postfix, Sendmail, Exim.\nMDA (Mail Delivery Agent) # Что: Программа, которая кладет письма в почтовые ящики. Пример: Dovecot (в режиме LDA/LMTP).\nMUA (Mail User Agent) # Что: Почтовый клиент. Пример: Thunderbird, Outlook, RoundCube, K-9 Mail.\nSMTP (Simple Mail Transfer Protocol) # Что: Протокол отправки почты. Порты: 25 (сервер-сервер), 587 (клиент-сервер с авторизацией).\nIMAP (Internet Message Access Protocol) # Что: Протокол доступа к почте (с синхронизацией). Порты: 143 (открытый), 993 (с TLS). Особенность: Письма хранятся на сервере.\nPOP3 (Post Office Protocol) # Что: Протокол доступа к почте (скачивание). Порты: 110 (открытый), 995 (с TLS). Особенность: Письма скачиваются и удаляются с сервера. Статус: Устарел, не будем использовать.\nTLS/SSL # Что: Шифрование соединения. Зачем: Чтобы пароли и письма не перехватили. Пример: HTTPS для почты.\nSPF (Sender Policy Framework) # Что: DNS-запись, которая говорит \u0026ldquo;с этих IP можно слать почту от моего домена\u0026rdquo;. Пример: v=spf1 ip4:1.2.3.4 ~all Зачем: Защита от подделки отправителя.\nDKIM (DomainKeys Identified Mail) # Что: Цифровая подпись письма. Как: Закрытый ключ на сервере, открытый в DNS. Зачем: Доказать, что письмо не подделано.\nDMARC (Domain-based Message Authentication) # Что: Политика домена: что делать, если SPF или DKIM не прошли. Варианты: none (ничего), quarantine (в спам), reject (не принимать). Пример: v=DMARC1; p=quarantine; rua=mailto:dmarc@домен.ru\nDNSBL (DNS-based Blackhole List) # Что: Черные списки IP-адресов спамеров. Примеры: zen.spamhaus.org, bl.spamcop.net. Как работает: Postfix спрашивает DNSBL: \u0026ldquo;Этот IP спамер?\u0026rdquo; → DNSBL отвечает.\nGreylisting # Что: Временная задержка писем от новых отправителей. Логика: Спам-боты не повторяют попытки, легальные серверы - повторяют. Задержка: 5 минут (обычно).\nRelay # Что: Пересылка почты через промежуточный сервер. Пример: Твой сервер → SMTP провайдера → получатель. Зачем: Если твой IP в блэклистах или нет белого IP.\nOpen Relay # Что: Сервер, который пересылает почту от кого угодно. Статус: ЗЛО. Мгновенно попадешь в блэклисты. Защита: SMTP AUTH + правильные restrictions.\nMaildir vs Mbox # Mbox: Все письма в одном файле. Maildir: Каждое письмо - отдельный файл. Используем: Maildir (надежнее, быстрее).\nSieve # Что: Язык для создания почтовых фильтров. Пример: \u0026ldquo;Если тема содержит \u0026lsquo;счет\u0026rsquo;, переложить в папку \u0026lsquo;Финансы\u0026rsquo;\u0026rdquo;. Управление: Через плагин managesieve в RoundCube.\nQuota # Что: Ограничение на размер почтового ящика. Пример: 5GB на пользователя. Наш случай: Без ограничений (или устанавливаешь сам).\nСистемные требования # Минимальная конфигурация (1-50 пользователей): # CPU: 2 ядра (любой современный процессор) RAM: 2 GB Disk: 20 GB (система) + объем почты SSD рекомендуется Net: Стабильное подключение Белый IP (желательно) Открытые порты: 25, 587, 143, 993, 80, 443 Рекомендуемая конфигурация (50-200 пользователей): # CPU: 4 ядра RAM: 4 GB Disk: 50 GB (система) + объем почты SSD обязательно Net: 100 Мбит/с Белый статический IP Оптимальная конфигурация (200-500 пользователей): # CPU: 8 ядер RAM: 8 GB Disk: 100 GB (система) + объем почты NVMe SSD Net: 1 Гбит/с Резервный канал Расчет дискового пространства: # Средний пользователь: 1-5 GB в год Активный пользователь: 10-20 GB в год Очень активный: 50+ GB в год Пример на 100 пользователей: 100 × 5 GB = 500 GB + запас 20% = 600 GB + система 50 GB = 650 GB Итого: диск на 1 TB с запасом Что нужно ДО начала установки # 1. Домен # Зарегистрированный домен с доступом к управлению DNS.\nПример: example.ru\n2. Сервер # VPS/Dedicated с Debian 12 и белым IP.\nТребования:\nRoot-доступ Чистая установка Debian 12 Статический IP Обратная DNS (PTR) настроена на твое имя хоста Проверка PTR:\nhost твой-IP # Должно вернуть: mail.example.ru 3. DNS-записи (настроишь в процессе) # A-запись:\nmail.example.ru. IN A твой-IP MX-запись:\nexample.ru. IN MX 10 mail.example.ru. SPF-запись:\nexample.ru. IN TXT \u0026#34;v=spf1 mx ~all\u0026#34; DKIM и DMARC - настроим позже.\n4. Открытые порты # Обязательно:\n25 (SMTP) - прием почты от других серверов 587 (Submission) - отправка от клиентов 143 (IMAP) - доступ к ящикам 993 (IMAPS) - IMAP с TLS 80 (HTTP) - для Let\u0026rsquo;s Encrypt 443 (HTTPS) - для RoundCube Опционально:\n22 (SSH) - для администрирования 19999 (Netdata) - для мониторинга 5. Время # Реально необходимое:\nУстановка и базовая настройка: 4-6 часов Отладка доставки в Gmail/Outlook: 2-4 часа Настройка веб-интерфейса: 1-2 часа Тестирование и доработка: 2-4 часа Итого: Закладывай полноценные выходные.\nПроверка готовности # Прежде чем начинать, убедись:\n☐ Есть зарегистрированный домен ☐ Есть доступ к управлению DNS ☐ Есть VPS/Dedicated с Debian 12 ☐ Есть root-доступ к серверу ☐ Настроен PTR для твоего IP ☐ Открыты необходимые порты ☐ Есть понимание, сколько времени займет ☐ Есть план резервного копирования ☐ Есть готовность разбираться в проблемах ☐ Прочитал эту статью до конца Если все пункты отмечены - можешь начинать.\nЧто дальше # В следующей части разберем установку и базовую настройку Postfix + Dovecot - сердца почтового сервера.\nТы получишь работающую систему приема и отправки почты. Без защиты, без веб-интерфейса, без красивостей - но работающую.\nА потом будем навешивать остальное: антивирус, антиспам, шифрование, подписи и все остальное, что превращает голый сервер в production-ready решение.\nПоехали.\n","date":"5 марта 2026","externalUrl":null,"permalink":"/posts/mailserver-part-1-intro/","section":"Статьи","summary":"","title":"Почтовый сервер на Debian 12: полное руководство от установки до production. Часть 1 - Начало","type":"posts"},{"content":"","date":"23 февраля 2026","externalUrl":null,"permalink":"/tags/cheatsheet/","section":"Теги","summary":"","title":"Cheatsheet","type":"tags"},{"content":"","date":"23 февраля 2026","externalUrl":null,"permalink":"/tags/git/","section":"Теги","summary":"","title":"Git","type":"tags"},{"content":" Структура проекта # ~/projects/blog/ ← одна папка, две ветки ├── main ← production (blog.ru) └── dev ← development (dev.blog.ru) Принцип: Одна папка, две ветки. Переключение через git checkout, не через разные директории. Золотые правила # ✅ ВСЕ изменения только через dev\n✅ В main попадает только через git merge dev\n✅ Никогда не редактировать находясь на main\n✅ Всегда git pull перед началом работы\n✅ Проверяй на dev окружении перед публикацией\n❌ Не редактировать на ветке main\n❌ Не делать git push --force в main\n❌ Не забывать git pull перед merge\nПрефиксы для commit сообщений # Основные # feat: - новая функциональность\nfeat: новая статья про Kubernetes feat: добавил комментарии fix: - исправление ошибки\nfix: опечатка в статье fix: сломанная ссылка style: - форматирование, стили\nstyle: настроил CSS для тёмной темы style: исправил отступы docs: - изменения в документации\ndocs: обновил README refactor: - рефакторинг без изменения функций\nrefactor: упростил структуру конфигов chore: - рутинные задачи\nchore: обновил зависимости chore: удалил старые файлы revert: - откат коммита\nrevert: \u0026#34;feat: добавил комментарии\u0026#34; Дополнительные # perf: - улучшение производительности\ntest: - тесты\nbuild: - изменения сборки\nci: - изменения CI/CD\nСоздание новой статьи # cd ~/projects/blog # Переключаемся на dev git checkout dev git pull origin dev # Создаём статью hugo new content posts/название-статьи/index.md # Локальный предпросмотр hugo server -D --bind 0.0.0.0 # Редактируем, сохраняем, проверяем в браузере # Коммитим git add . git commit -m \u0026#34;feat: новая статья про X\u0026#34; git push origin dev # → Автосборка → dev.blog.ru Публикация в production # cd ~/projects/blog # Убеждаемся что dev актуален git checkout dev git pull origin dev # Переключаемся на main и мержим git checkout main git pull origin main git merge dev --no-edit git push origin main # → Автосборка → blog.ru Исправление опечатки # cd ~/projects/blog # ВСЕ изменения через dev! git checkout dev git pull origin dev # Исправляем nano content/posts/статья/index.md # Коммитим git add . git commit -m \u0026#34;fix: опечатка в статье\u0026#34; git push origin dev # Проверяем на dev, если ОК - публикуем git checkout main git merge dev --no-edit git push origin main Обновление темы (Git submodule) # cd ~/projects/blog git checkout dev git pull origin dev # Обновляем тему git submodule update --remote themes/название-темы # Коммитим git add themes/название-темы git commit -m \u0026#34;chore: обновил тему\u0026#34; git push origin dev # Проверяем на dev, если ОК - публикуем git checkout main git merge dev --no-edit git push origin main Откат изменений # Когда откатывать vs когда делать fix # ОТКАТ → Эксперимент провалился:\nФункция не работает Стили сломали дизайн Тестировал - не зашло НОВЫЙ КОММИТ → Исправление правильного:\nОпечатка в статье Обновление информации Улучшение формулировок Вариант 1: Git revert (безопасный) # # Смотрим последние коммиты git log --oneline -5 # Создаём коммит который отменяет изменения git revert HEAD git push origin dev Плюсы: История сохранена\nМинусы: Создаёт дополнительный коммит\nВариант 2: Git reset (жёсткий откат) # # Откатываемся на предыдущий коммит git log --oneline -5 git reset --hard HEAD~1 # Принудительно пушим git push origin dev --force Плюсы: Чистая история\nМинусы: Опасно, нужен --force\nОткат несохранённых изменений # # Откатить один файл git checkout -- путь/к/файлу # Откатить всё git reset --hard HEAD Проверка статуса # # На какой ветке? git branch # * dev ← текущая ветка # Что изменилось? git status # История коммитов git log --oneline -10 # Разница между dev и main git diff main..dev # График веток git log --oneline --graph --all -10 Типичные ошибки и их решения # Случайно начал работать на main # # Отменяем все изменения (НЕ коммитили) git checkout -- . # Переключаемся на dev git checkout dev Случайно сделал git add на main # # Убираем из staging git reset # Переключаемся на dev git checkout dev # Добавляем правильно git add . git commit -m \u0026#34;feat: ...\u0026#34; git push origin dev Случайно закоммитил в main # # Откатываем коммит (изменения остаются) git reset --soft HEAD~1 # Переключаемся на dev git checkout dev # Коммитим правильно git add . git commit -m \u0026#34;feat: ...\u0026#34; git push origin dev Случайно запушил в main # # Откатываем на main git checkout main git reset --hard HEAD~1 git push origin main --force # Переключаемся на dev и делаем правильно git checkout dev git add . git commit -m \u0026#34;feat: ...\u0026#34; git push origin dev Быстрые команды # # Переключиться на dev и подтянуть изменения git checkout dev \u0026amp;\u0026amp; git pull origin dev # Опубликовать dev в main git checkout main \u0026amp;\u0026amp; git pull origin main \u0026amp;\u0026amp; git merge dev --no-edit \u0026amp;\u0026amp; git push origin main # Проверить доступность сайта curl -I https://blog.ru curl -I https://dev.blog.ru Полезные алиасы для ~/.gitconfig # [alias] st = status co = checkout br = branch ci = commit unstage = reset HEAD -- last = log -1 HEAD visual = log --oneline --graph --all -10 dev = checkout dev prod = checkout main После этого можно:\ngit dev # → git checkout dev git prod # → git checkout main git visual # → git log --oneline --graph --all -10 Стандартный workflow # # 1. Начало работы cd ~/projects/blog git checkout dev git pull origin dev # 2. Создаём/редактируем контент hugo new content posts/название/index.md hugo server -D --bind 0.0.0.0 # 3. Коммитим git add . git commit -m \u0026#34;feat: новая статья\u0026#34; git push origin dev # 4. Проверяем на dev окружении # 5. Публикуем в production git checkout main git pull origin main git merge dev --no-edit git push origin main Важно: Dev для экспериментов, main для проверенного контента. Всегда тестируй перед публикацией. ","date":"23 февраля 2026","externalUrl":null,"permalink":"/cheatsheets/git-workflow/","section":"Шпаргалки","summary":"","title":"Git Workflow для Hugo блога","type":"cheatsheets"},{"content":"","date":"23 февраля 2026","externalUrl":null,"permalink":"/tags/hugo/","section":"Теги","summary":"","title":"Hugo","type":"tags"},{"content":"","date":"23 февраля 2026","externalUrl":null,"permalink":"/tags/workflow/","section":"Теги","summary":"","title":"Workflow","type":"tags"},{"content":"","date":"23 февраля 2026","externalUrl":null,"permalink":"/categories/%D1%88%D0%BF%D0%B0%D1%80%D0%B3%D0%B0%D0%BB%D0%BA%D0%B8/","section":"Категории","summary":"","title":"Шпаргалки","type":"categories"},{"content":"","date":"23 февраля 2026","externalUrl":null,"permalink":"/cheatsheets/","section":"Шпаргалки","summary":"","title":"Шпаргалки","type":"cheatsheets"},{"content":"","date":"18 февраля 2026","externalUrl":null,"permalink":"/tags/devops/","section":"Теги","summary":"","title":"Devops","type":"tags"},{"content":"","date":"18 февраля 2026","externalUrl":null,"permalink":"/categories/devops-%D0%BF%D1%80%D0%B0%D0%BA%D1%82%D0%B8%D0%BA%D0%B8/","section":"Категории","summary":"","title":"DevOps Практики","type":"categories"},{"content":"","date":"18 февраля 2026","externalUrl":null,"permalink":"/tags/gitea/","section":"Теги","summary":"","title":"Gitea","type":"tags"},{"content":"","date":"18 февраля 2026","externalUrl":null,"permalink":"/tags/homelab/","section":"Теги","summary":"","title":"Homelab","type":"tags"},{"content":"","date":"18 февраля 2026","externalUrl":null,"permalink":"/tags/k3s/","section":"Теги","summary":"","title":"K3s","type":"tags"},{"content":"","date":"18 февраля 2026","externalUrl":null,"permalink":"/categories/kubernetes/","section":"Категории","summary":"","title":"Kubernetes","type":"categories"},{"content":"","date":"18 февраля 2026","externalUrl":null,"permalink":"/tags/kubernetes/","section":"Теги","summary":"","title":"Kubernetes","type":"tags"},{"content":"","date":"18 февраля 2026","externalUrl":null,"permalink":"/tags/namespace/","section":"Теги","summary":"","title":"Namespace","type":"tags"},{"content":"","date":"18 февраля 2026","externalUrl":null,"permalink":"/series/%D0%B1%D0%BB%D0%BE%D0%B3-%D0%BD%D0%B0-hugo-%D0%B2-k3s/","section":"Series","summary":"","title":"Блог На Hugo В K3s","type":"series"},{"content":"В части 5 выяснили почему сайт отдавал 503 через раз. Виновник - брошенный namespace blog с нерабочим nginx, чей IngressRoute всё ещё висел на продакшн домене.\nМораль была проста: чисти за собой. Сегодня делаем именно это - переносим Gitea из старого namespace в oakazanin и удаляем старый namespace навсегда.\nЗаодно разберём универсальный алгоритм миграции, который работает для любого сервиса. Показываю на примере Gitea, но всё описанное применимо к любому stateful сервису - PostgreSQL, MySQL, Nextcloud, MinIO. Принципы одинаковые: экспортируй манифесты, поменяй namespace, создай новое перед удалением старого.\nПочему вообще нужна миграция между namespace # Namespace в Kubernetes - это логическая изоляция. Со временем они накапливаются: сначала default, потом blog, потом public, потом oakazanin. Каждый создавался \u0026ldquo;быстро, временно, потом разберёмся\u0026rdquo;.\nПроблемы начинаются когда:\nОдин домен прописан в IngressRoute двух разных namespace Старый сервис давно не работает, но его ресурсы висят и путают Непонятно где искать логи - в blog или oakazanin? Решение - собрать связанные сервисы в один namespace. В нашем случае всё что относится к блогу и его инфраструктуре живёт в oakazanin.\nТипы сервисов: stateless и stateful # Перед миграцией важно понять с чем имеешь дело.\nStateless сервисы - nginx, hugo-builder, любой под без постоянных данных. Миграция тривиальна: меняешь namespace в манифесте, применяешь, удаляешь старое. Данные не теряются потому что их нет.\nStateful сервисы - Gitea, базы данных, всё что хранит данные на диске. Здесь нужна осторожность: данные живут на PersistentVolume, который привязан к конкретному namespace через PersistentVolumeClaim.\nНаша Gitea - stateful. Репозитории лежат на NFS:\n192.168.11.30:/export/gitea-data └── /data/git/repositories/ ← Git репозитории └── /data/gitea/gitea.db ← База данных SQLite Данные никуда не переносятся - они остаются на NFS. Мы просто создаём новый PV/PVC в целевом namespace, который указывает на тот же NFS путь.\nШаг 1: Убеждаемся что данные целы # Прежде чем трогать что-либо - проверяем что данные на месте:\n# Заходим в работающий под и проверяем репозитории kubectl exec -n blog deployment/gitea -- find /data -maxdepth 3 -type d Видим структуру:\n/data/git/repositories/ /data/gitea/gitea.db /data/gitea/conf Репозитории есть - можно двигаться дальше. Также фиксируем NFS путь:\n# Смотрим откуда PV берёт данные kubectl get pv gitea-pv -o yaml | grep -A3 \u0026#34;nfs:\u0026#34; # Вывод # nfs: # path: /export/gitea-data # server: 192.168.11.30 Шаг 2: Экспортируем текущие манифесты # # Создаём папку для бэкапов mkdir -p ~/k8s-manifests/gitea-migration # Экспортируем все ресурсы из старого namespace kubectl get deployment gitea -n blog -o yaml \u0026gt; ~/k8s-manifests/gitea-migration/deployment.yaml kubectl get service gitea -n blog -o yaml \u0026gt; ~/k8s-manifests/gitea-migration/service.yaml kubectl get configmap gitea-config -n blog -o yaml \u0026gt; ~/k8s-manifests/gitea-migration/configmap.yaml kubectl get pvc gitea-pvc -n blog -o yaml \u0026gt; ~/k8s-manifests/gitea-migration/pvc.yaml kubectl get pv gitea-pv -o yaml \u0026gt; ~/k8s-manifests/gitea-migration/pv.yaml kubectl get ingressroute gitea -n blog -o yaml \u0026gt; ~/k8s-manifests/gitea-migration/ingressroute.yaml Это страховка - если что-то пойдёт не так, есть откуда восстановиться.\nШаг 3: Готовим чистый манифест для нового namespace # Экспортированные манифесты содержат мусор: uid, resourceVersion, creationTimestamp, status. Всё это нужно убрать и поменять namespace.\nДля PV и PVC дополнительно меняем имена - убираем префиксы старого namespace:\nblog-gitea-pv → gitea-pv blog-gitea-pvc → gitea-pvc Собираем всё в один файл gitea.yaml:\n# PersistentVolume - тот же NFS путь, новое имя apiVersion: v1 kind: PersistentVolume metadata: name: gitea-pv spec: accessModes: - ReadWriteMany capacity: storage: 10Gi mountOptions: - nfsvers=3 - hard - timeo=600 - retrans=2 nfs: path: /export/gitea-data # ← тот же путь! server: 192.168.11.30 persistentVolumeReclaimPolicy: Retain claimRef: apiVersion: v1 kind: PersistentVolumeClaim name: gitea-pvc namespace: oakazanin # ← новый namespace --- # PersistentVolumeClaim - новый namespace, привязан к gitea-pv apiVersion: v1 kind: PersistentVolumeClaim metadata: name: gitea-pvc namespace: oakazanin spec: accessModes: - ReadWriteMany resources: requests: storage: 10Gi storageClassName: \u0026#34;\u0026#34; volumeName: gitea-pv --- # Certificate - cert-manager выпустит новый apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: gitea-tls namespace: oakazanin spec: dnsNames: - git.example.com issuerRef: kind: ClusterIssuer name: letsencrypt-prod secretName: gitea-tls # ... остальные ресурсы: ConfigMap, Deployment, Service, IngressRoute Важный момент про SSL сертификат # Есть два подхода:\nСкопировать существующий секрет:\n# Копируем секрет из старого namespace в новый kubectl get secret gitea-tls -n blog -o yaml | \\ sed \u0026#39;s/namespace: blog/namespace: oakazanin/\u0026#39; | \\ kubectl apply -f - Плюс - мгновенно, нет даунтайма по SSL. Минус - тащим старый секрет.\nДать cert-manager выпустить новый: Просто создаём Certificate объект в новом namespace. cert-manager сам выпустит сертификат через Let\u0026rsquo;s Encrypt за 30-60 секунд.\nВыбираем второй вариант - чистое решение без наследия.\nШаг 4: Применяем в новом namespace # # Применяем манифест kubectl apply -f gitea.yaml Проверяем что всё поднялось:\n# PVC привязался к PV? kubectl get pv gitea-pv kubectl get pvc gitea-pvc -n oakazanin # Pod запустился с данными? kubectl get pods -n oakazanin | grep gitea # Сертификат выпущен? kubectl get certificate gitea-tls -n oakazanin Ожидаемый результат:\ngitea-pv Bound oakazanin/gitea-pvc gitea-pvc Bound gitea-pv gitea-... 1/1 Running gitea-tls True Проверяем что Gitea открывается и данные на месте:\n# Проверяем доступность curl -s -o /dev/null -w \u0026#34;%{http_code}\u0026#34; https://git.example.com # 200 Шаг 5: Останавливаем старый сервис # Только после того как убедились что новый работает:\n# Останавливаем Gitea в старом namespace (не удаляем - просто 0 реплик) kubectl scale deployment gitea -n blog --replicas=0 # Ждём несколько минут, проверяем что сайт всё ещё работает curl -s -o /dev/null -w \u0026#34;%{http_code}\u0026#34; https://git.example.com # 200 - трафик идёт через новый namespace Масштабирование до 0 реплик - страховка. Если что-то пошло не так, поднимаем обратно за секунду:\nkubectl scale deployment gitea -n blog --replicas=1 Шаг 6: Зачищаем старый namespace # Когда убедились что всё работает - удаляем в правильном порядке:\n# Сначала удаляем workloads kubectl delete deployment gitea -n blog kubectl delete service gitea -n blog kubectl delete configmap gitea-config -n blog kubectl delete secret gitea-tls -n blog kubectl delete ingressroute gitea gitea-http -n blog # Потом PVC (он держит PV) kubectl delete pvc gitea-pvc -n blog # Потом PV kubectl delete pv blog-gitea-pv # Последним - namespace kubectl delete namespace blog Почему такой порядок? # PVC нельзя удалить пока его использует Pod - K8s заблокирует операцию через finalizer kubernetes.io/pvc-protection. Поэтому сначала удаляем Deployment, ждём пока Pod завершится, потом PVC.\nPV с политикой Retain после удаления переходит в статус Released, но данные на NFS остаются нетронутыми. Это страховка от случайного удаления.\nФинальная проверка # # Namespace удалён? kubectl get namespace | grep blog # (пусто) # Старый PV удалён? kubectl get pv | grep blog # (пусто) # Новый PV работает? kubectl get pv gitea-pv # gitea-pv Bound oakazanin/gitea-pvc # Все сервисы живые? curl -s -o /dev/null -w \u0026#34;%{http_code}\u0026#34; https://blog.example.com # 200 curl -s -o /dev/null -w \u0026#34;%{http_code}\u0026#34; https://git.example.com # 200 Универсальность подхода # Этот алгоритм работает не только для Gitea. Те же шаги применимы к любым stateful сервисам:\nPostgreSQL/MySQL:\nЭкспортируешь Deployment, Service, PVC Меняешь namespace PV указывает на тот же NFS путь с базой данных Данные остаются нетронутыми Nextcloud:\nАналогично - файлы на NFS не переносятся Только манифесты меняют namespace Zero downtime если создаёшь новое до удаления старого MinIO (S3-хранилище):\nStateful, работает через PV/PVC Те же принципы - новый namespace, тот же NFS путь Stateless сервисы (nginx, API):\nЕщё проще - нет PV/PVC вообще Только Deployment + Service, меняешь namespace, готово Главный принцип: данные живут на PV, который привязан к NFS. Namespace меняется, путь на NFS остаётся.\nУниверсальный чеклист миграции # Подготовка [ ] Проверить данные в поде (find /data) [ ] Зафиксировать NFS путь (kubectl get pv -o yaml) [ ] Экспортировать манифесты в отдельную папку Создание в новом namespace [ ] Убрать служебные поля (uid, resourceVersion, status) [ ] Поменять namespace во всех манифестах [ ] Переименовать PV/PVC (убрать старые префиксы) [ ] Добавить Certificate объект (не копировать секрет) [ ] Применить манифест [ ] Проверить PVC Bound, Pod Running, Certificate True Переключение [ ] Убедиться что новый сервис работает (curl HTTP 200) [ ] Остановить старый (scale --replicas=0) [ ] Подождать 5 минут, проверить снова Зачистка [ ] Удалить Deployment, Service, ConfigMap, Secret [ ] Удалить IngressRoute [ ] Удалить PVC [ ] Удалить PV [ ] Удалить namespace [ ] Финальная проверка всех сервисов Итог # Вся миграция Gitea заняла около 10 минут. Даунтайм - 0 секунд: новый под поднялся раньше чем остановили старый, сертификат выпустился за ~32 секунды, данные подхватились с NFS автоматически.\nГлавные принципы которые работают:\nОдин namespace - один проект. Все сервисы блога живут в одном namespace. Никакого разброса по трём разным местам.\nСначала создай, потом удаляй. Никогда не удаляй старое до того как убедился что новое работает.\nRetain политика для PV. Данные на NFS переживут любые эксперименты с namespace.\nЧисти за собой. Брошенные IngressRoute и namespace - источник неочевидных проблем в самый неподходящий момент.\nЧто дальше # Блог работает, два окружения настроены, проблемы диагностируются за минуты, namespace чистый.\nВ следующей части добавим лайки и просмотры через Firebase Realtime Database и Cloud Firestore, исправим все ошибки интеграции с Blowfish, и обновим Hugo до 0.155 чтобы inline partials заработали.\nСтек этой части:\nKubernetes 1.30 (K3s) Gitea 1.21.11 NFS для статичных данных cert-manager + Let\u0026rsquo;s Encrypt kubectl для миграции ","date":"18 февраля 2026","externalUrl":null,"permalink":"/posts/blog-part-6-namespace-migration/","section":"Статьи","summary":"","title":"Блог на Hugo в K3s: часть 6 - миграция между namespace","type":"posts"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/tags/debugging/","section":"Теги","summary":"","title":"Debugging","type":"tags"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/tags/nginx/","section":"Теги","summary":"","title":"Nginx","type":"tags"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/tags/traefik/","section":"Теги","summary":"","title":"Traefik","type":"tags"},{"content":"","date":"17 февраля 2026","externalUrl":null,"permalink":"/tags/troubleshooting/","section":"Теги","summary":"","title":"Troubleshooting","type":"tags"},{"content":"В части 4 мы разобрались с Git workflow. Всё работает: пушишь в dev - видишь на тестовом окружении, мержишь в main - публикуется на production.\nА потом в один прекрасный день открываешь свой сайт и видишь 503 Service Temporarily Unavailable.\nВчера же все работало! Ты ничего не менял. Что произошло?\nДобро пожаловать в мир эксплуатации Kubernetes, где проблемы тоже возникают и требуют системного подхода без паники.\nЭта статья - алгоритм диагностики от DNS до пода. Проходишь по шагам сверху вниз, находишь проблему за 5 минут. Не гадаешь, не тыкаешь наугад - работаешь по системе.\nАнатомия HTTP запроса в K3s # Прежде чем искать проблему, нужно понять путь запроса от браузера до nginx:\nБраузер ↓ DNS запрос DNS сервер (провайдер или Cloudflare) ↓ Возвращает IP адрес Роутер/Файрвол (OPNsense, MikroTik) ↓ Port Forward 443 → K3s node MetalLB LoadBalancer ↓ External IP Traefik Ingress Controller ↓ IngressRoute matching Kubernetes Service ↓ Endpoint selection Pod (Nginx контейнер) ↓ Volume mount NFS хранилище Проблема может быть на любом из этих уровней. Секрет эффективной диагностики - проверять снаружи внутрь, последовательно исключая рабочие компоненты.\nКогда тыкаешь наугад, проверяя сначала поды, потом DNS, потом снова поды - тратишь время. Когда идёшь по алгоритму - находишь проблему за минуты.\nШаг 1: DNS - доходит ли домен до твоего IP # Первым делом проверяем что домен резолвится в правильный IP. Без этого дальше проверять бессмысленно - браузер просто не знает куда направлять запрос.\nПроверка # # Проверяем DNS резолв (используй свой домен) dig blog.example.com +short # Альтернатива если dig не установлен nslookup blog.example.com Ожидаемый результат # 77.37.XXX.XXX Должен вернуться твой публичный IP адрес (тот который прописан в A-записи у DNS провайдера).\nЧто может пойти не так # Симптом Причина Как проверить Решение Возвращается старый IP DNS кеш не обновился dig blog.example.com @8.8.8.8 Подожди TTL (обычно 300-3600 сек) NXDOMAIN ошибка Домен не делегирован Проверь NS записи у регистратора Настрой DNS правильно Возвращается 127.0.0.1 Локальный override cat /etc/hosts | grep blog Удали строку из /etc/hosts Возвращается несколько IP Round-robin DNS Проверь все ли IP твои Удали лишние A-записи Если DNS правильный - идём дальше.\nШаг 2: Внешний доступ - доходит ли запрос до сервера # Теперь проверяем что запрос физически доходит до сервера. DNS может быть правильным, но файрвол может блокировать трафик.\nПроверка # # Пробуем подключиться извне (важно - НЕ из локальной сети!) curl -v https://blog.example.com 2\u0026gt;\u0026amp;1 | head -30 Важно: Запускай эту команду с внешнего сервера или используй мобильный интернет. Тест из локальной сети ничего не докажет - можешь обходить файрвол.\nСитуация А: Connection refused или timeout # curl: (7) Failed to connect to blog.example.com port 443: Connection refused Запрос вообще не дошёл до сервера. Проблема на сетевом уровне.\nВозможные причины:\n1. Порт 443 закрыт на файрволе/роутере\nПроверь Port Forward правила на OPNsense/MikroTik. Должно быть:\nWAN:443 → 192.168.X.X:443 (IP любой K3s ноды) 2. MetalLB не назначил External IP для Traefik\n# Проверяем MetalLB kubectl get svc -n traefik traefik # Ожидаемый результат NAME TYPE EXTERNAL-IP PORT(S) traefik LoadBalancer 192.168.X.X 80:30080/TCP,443:30443/TCP Если EXTERNAL-IP показывает \u0026lt;pending\u0026gt; - MetalLB не работает или пул IP адресов не настроен.\n3. Traefik под не запущен\n# Проверяем что Traefik работает kubectl get pods -n traefik # Должны быть все Running NAME READY STATUS traefik-xxxxxxxxxx-xxxxx 1/1 Running Ситуация Б: TLS handshake прошёл, но 503 # \u0026lt; HTTP/2 503 \u0026lt; content-type: text/plain; charset=utf-8 \u0026lt; content-length: 20 no available server Отлично - наша ситуация! Traefik работает, SSL сертификат отдаёт, но дальше запрос упирается в стену.\nСообщение no available server означает что Traefik нашёл роутер, но не нашёл живой бэкенд за ним.\nПроблема внутри кластера. Идём глубже.\nСитуация В: SSL certificate problem # curl: (60) SSL certificate problem: unable to get local issuer certificate Сертификат невалидный или не выпущен.\n# Проверяем Certificate объект (используй свой namespace) kubectl get certificate -n blog # Должно быть READY=True NAME READY SECRET AGE blog-tls True blog-tls 2d Если READY=False - cert-manager не смог выпустить сертификат.\n# Смотрим что пошло не так kubectl describe certificate blog-tls -n blog # Ищем секцию Events внизу вывода - там описание проблемы Главное: Запрос доходит до Traefik # # Проверка (с ВНЕШНЕГО сервера!) curl -I https://blog.example.com # Ожидаемый результат (любой из двух) HTTP/2 200 # Всё работает HTTP/2 503 # Traefik работает, но бэкенд недоступен # Если connection refused/timeout - проблема в сети (см. выше) Шаг 3: Traefik - правильно ли маршрутизируется трафик # Traefik получил запрос на твой домен. Что он с ним делает? Смотрим логи.\nПроверка логов Traefik # # Смотрим последние 50 строк логов Traefik kubectl logs -n traefik deployment/traefik --tail=50 # Фильтруем только свой домен (убираем шум от других сервисов) kubectl logs -n traefik deployment/traefik --tail=100 | grep blog.example Что искать в логах # Нормальный запрос:\n{ \u0026#34;request\u0026#34;: \u0026#34;GET / HTTP/2.0\u0026#34;, \u0026#34;status\u0026#34;: 200, \u0026#34;size\u0026#34;: 8994, \u0026#34;router\u0026#34;: \u0026#34;blog-blog-https-xxxxx@kubernetescrd\u0026#34;, \u0026#34;service\u0026#34;: \u0026#34;blog-nginx-blog@kubernetescrd\u0026#34;, \u0026#34;backend\u0026#34;: \u0026#34;http://10.42.2.40:80\u0026#34;, \u0026#34;duration\u0026#34;: 12 } Ключевые поля:\nrouter: Traefik нашёл нужный IngressRoute (blog-blog-https) backend: IP пода nginx куда проксируется запрос (10.42.2.40:80) status: HTTP код ответа от nginx (200 = всё хорошо) Проблемный запрос:\n{ \u0026#34;request\u0026#34;: \u0026#34;GET / HTTP/2.0\u0026#34;, \u0026#34;status\u0026#34;: 503, \u0026#34;router\u0026#34;: \u0026#34;blog-blog-https-xxxxx@kubernetescrd\u0026#34;, \u0026#34;error\u0026#34;: \u0026#34;no available server\u0026#34; } Traefik нашёл роутер, но поле backend отсутствует - под недоступен или не существует.\nПроверяем список IngressRoute # # Смотрим все IngressRoute в кластере kubectl get ingressroute -A # Фильтруем только свой домен kubectl get ingressroute -A | grep blog.example Важный момент: Если один и тот же домен прописан в двух разных IngressRoute из разных namespace - Traefik будет балансировать между ними.\nНапример:\nNAMESPACE NAME AGE blog blog-https 10d # СТАРЫЙ namespace blog-new blog-https 2d # НОВЫЙ namespace Оба IngressRoute имеют match: Host('blog.example.com'). Traefik видит оба, честно балансирует трафик 50/50.\nЕсли один из бэкендов мёртв - половина запросов уходит в пустоту. 503 через раз.\nРешение: Удалить старый IngressRoute:\n# Удаляем дубль из старого namespace kubectl delete ingressroute blog-https blog-http -n blog Проверяем синтаксис match # Traefik очень требователен к синтаксису. Частая ошибка - забыть backticks или скобки.\nНеправильно:\nmatch: Host(blog.example.com) # Нет backticks match: Host `blog.example.com` # Нет скобок вокруг Host match: Host(\u0026#34;blog.example.com\u0026#34;) # Двойные кавычки вместо backticks Правильно:\nmatch: Host(`blog.example.com`) Проверяем:\n# Смотрим манифест IngressRoute kubectl get ingressroute blog-https -n blog -o yaml | grep match: # Должно быть со скобками и backticks match: Host(`blog.example.com`) Главное: Traefik нашёл роутер # # Проверка kubectl logs -n traefik deployment/traefik --tail=50 | grep blog.example # Ожидаемый результат - есть строки с \u0026#34;router\u0026#34;: \u0026#34;blog-blog-https\u0026#34; # Если router не найден - проблема в IngressRoute match синтаксисе Шаг 4: Service - видит ли он поды # Traefik нашёл роутер, проксирует трафик на Service. Но Service может не видеть поды если selector неправильный.\nПроверка endpoints # # Смотрим endpoints для Service (используй своё имя Service) kubectl get endpoints nginx -n blog # Ожидаемый результат - НЕ пустой список IP NAME ENDPOINTS nginx 10.42.0.44:80,10.42.2.40:80 Если видишь \u0026lt;none\u0026gt; - Service не нашёл ни одного пода. Две возможные причины.\nПричина 1: Selector не совпадает с labels # # Смотрим selector у Service kubectl get svc nginx -n blog -o yaml | grep -A3 \u0026#34;selector:\u0026#34; # Вывод selector: app: nginx # Смотрим labels у подов kubectl get pods -n blog --show-labels | grep nginx # Вывод nginx-xxxxxxxxxx-xxxxx 1/1 Running app=nginx-old Видишь проблему? Service ищет app: nginx, а под помечен app: nginx-old. Не совпадает.\nРешение: Исправить Deployment или Service чтобы labels совпадали.\nПричина 2: Поды не Running # # Смотрим статус подов kubectl get pods -n blog # Видим NAME READY STATUS nginx-xxxxxxxxxx-xxxxx 0/1 CreateContainerError Под существует, но не работает. Service правильно не включает его в endpoints. Идём в следующий шаг - разбираемся почему под не запускается.\nГлавное: Service видит поды # # Проверка kubectl get endpoints nginx -n blog # Ожидаемый результат - НЕ пустой nginx 10.42.0.44:80,10.42.2.40:80 # Если \u0026lt;none\u0026gt; - проблема в селекторах или поды не Running Шаг 5: Pod - что происходит внутри контейнера # Самый глубокий уровень. Под не запускается или падает в цикле перезапусков.\nПроверка статуса подов # # Смотрим все поды в namespace kubectl get pods -n blog # Фильтруем только nginx kubectl get pods -n blog | grep nginx Возможные статусы проблем:\nCreateContainerError # Контейнер вообще не может стартануть. Обычно проблема с volumes или образом.\n# Смотрим детали пода (используй своё имя пода) kubectl describe pod nginx-xxxxxxxxxx-xxxxx -n blog | tail -30 Ищем секцию Events внизу вывода. Там будет описание проблемы:\nПример 1: PVC не примонтировался\nEvents: Warning FailedMount MountVolume.SetUp failed for volume \u0026#34;blog-public-pvc\u0026#34;: mount failed: mount.nfs: Connection timed out NFS хранилище недоступно. Возможные причины:\nNFS сервер выключен или перезагружается Неправильный IP или путь в PersistentVolume Файрвол блокирует NFS трафик (порт 2049) Пример 2: Образ не скачался\nEvents: Warning Failed Failed to pull image \u0026#34;nginx:latest\u0026#34;: rpc error: code = Unknown Контейнер не может скачать образ. Обычно это означает что imagePullPolicy: Never, а образ не импортирован на ноду.\n# Проверяем что образ есть на ноде (используй IP своей worker ноды) ssh user@192.168.X.X \u0026#34;sudo k3s crictl images | grep nginx\u0026#34; Если образа нет - импортируй его через k3s ctr images import.\nПример 3: ConfigMap не найден\nEvents: Warning FailedMount ConfigMap \u0026#34;nginx-config\u0026#34; not found Deployment ссылается на несуществующий ConfigMap.\n# Проверяем что ConfigMap существует kubectl get configmap -n blog | grep nginx-config Если нет - создай или исправь имя в Deployment.\nCrashLoopBackOff # Контейнер запускается, но сразу падает. Смотрим логи предыдущего запуска:\n# Логи последнего упавшего контейнера kubectl logs nginx-xxxxxxxxxx-xxxxx -n blog --previous Пример: Nginx падает из-за неправильного конфига\nnginx: [emerg] unexpected \u0026#34;}\u0026#34; in /etc/nginx/nginx.conf:15 nginx: configuration file /etc/nginx/nginx.conf test failed Синтаксическая ошибка в nginx.conf. Проверяем ConfigMap:\n# Смотрим содержимое конфига kubectl get configmap nginx-config -n blog -o yaml Находим ошибку, исправляем, применяем. Под перезапустится автоматически.\nГлавное: Под работает # # Проверка kubectl get pods -n blog | grep nginx # Ожидаемый результат - все Running nginx-xxxxxxxxxx-xxxxx 1/1 Running 0 2d # Если не Running - смотри troubleshooting выше Шаг 6: Контент - есть ли файлы для отдачи # Под работает, Service видит его, Traefik проксирует трафик. Но сайт отдаёт 404 Not Found или пустую страницу.\nПроблема: Hugo Builder не записал файлы на NFS или записал не туда.\nПроверка # # Заходим в под nginx (используй своё имя пода) kubectl exec -it nginx-xxxxxxxxxx-xxxxx -n blog -- sh # Внутри пода смотрим что примонтировалось ls -la /usr/share/nginx/html/ # Должен быть index.html и папки posts, tags, etc Если директория пустая - Hugo Builder не сработал. Проверяем его логи:\n# Логи Hugo Builder kubectl logs -n blog deployment/hugo-builder-prod --tail=50 Ищем строку Build successful! и список созданных файлов. Если её нет:\nWebhook не сработал - проверь настройки webhook в Gitea Hugo упал с ошибкой - читай логи выше, смотри на что ругается Собрал в другую директорию - проверь переменную OUTPUT_DIR в build.sh Главное: Контент на месте # # Проверка (используй своё имя пода) kubectl exec -it nginx-xxxxxxxxxx-xxxxx -n blog -- ls /usr/share/nginx/html/ | head -5 # Ожидаемый результат index.html posts/ tags/ categories/ # Если пусто - Hugo Builder не отработал (см. выше) Быстрый чеклист для любой проблемы # Сохрани эту последовательность - она работает для 95% проблем:\n[ ] DNS: dig домен → правильный IP? [ ] Сеть: curl -v https://домен → доходит до Traefik? [ ] MetalLB: kubectl get svc -n traefik → External IP назначен? [ ] Traefik: kubectl get pods -n traefik → Running? [ ] IngressRoute: kubectl get ingressroute -A | grep домен → нет дублей? [ ] Match синтаксис: Host(`домен`) со скобками и backticks? [ ] Endpoints: kubectl get endpoints -n namespace → не пустые? [ ] Selector: labels подов совпадают с selector Service? [ ] Pods: kubectl get pods -n namespace → все Running? [ ] PVC: kubectl get pvc -n namespace → все Bound? [ ] Контент: kubectl exec ls /usr/share/nginx/html → файлы есть? Проходишь по списку сверху вниз. Останавливаешься на первом [ ] где что-то не так. Чинишь. Проверяешь снова.\nНе прыгай хаотично между уровнями. Алгоритм экономит время.\nРеальный пример: 503 через раз # Мой сайт отдавал 503 примерно в 50% запросов. Половина запросов работала, половина нет.\nПрошёл по алгоритму:\n✅ DNS - правильный IP ✅ Сеть - Traefik отвечает ✅ MetalLB - External IP назначен ✅ Traefik - поды Running ❌ IngressRoute - два роутера на один домен kubectl get ingressroute -A | grep oakazanin NAMESPACE NAME blog blog-https # СТАРЫЙ, бэкенд в CreateContainerError oakazanin blog-https # НОВЫЙ, работает Traefik видел два роутера, честно балансировал трафик 50/50. Каждый второй запрос улетал в мёртвый blog/nginx.\nДиагноз поставлен за 3 минуты. Лечение - одна команда:\n# Удаляем дубль из старого namespace kubectl delete ingressroute blog-https blog-http -n blog Мораль: всегда чисти за собой. Старые namespace с нерабочими сервисами - источник неочевидных проблем.\nОткат и cleanup # Если в процессе диагностики что-то сломал ещё больше - откатываемся:\n# Восстанавливаем предыдущую версию манифеста kubectl apply -f nginx-deployment.yaml # Перезапускаем поды принудительно kubectl rollout restart deployment/nginx -n blog # Смотрим что изменения применились kubectl rollout status deployment/nginx -n blog Золотое правило: Перед экспериментами делай бэкапы манифестов:\n# Экспортируем текущее состояние с датой kubectl get deployment,service,ingressroute -n blog -o yaml \u0026gt; backup-$(date +%Y%m%d).yaml Что дальше # Ты умеешь диагностировать проблемы. Но лучше их вообще не создавать.\nВ следующей части покажу как правильно мигрировать сервисы между namespace - без даунтайма, дублей IngressRoute и других сюрпризов которые приводят к 503.\nРазберём реальный пример: переносим Gitea между namespace с NFS данными, получаем новый SSL за 32 секунды, и удаляем старый namespace навсегда.\nСтек этой части:\nTraefik 2.11 IngressRoute Kubernetes 1.30 (K3s) kubectl CLI curl для внешних проверок dig для DNS диагностики ","date":"17 февраля 2026","externalUrl":null,"permalink":"/posts/blog-part-5-debugging/","section":"Статьи","summary":"","title":"Блог на Hugo в K3s: часть 5 - что делать когда всё внезапно сломалось","type":"posts"},{"content":"В части 3 мы развернули два окружения - production и development. Один репозиторий, две ветки (main и dev), два пайплайна.\nТеперь встаёт вопрос: как организовать работу локально?\nПроблема # У нас есть:\nРепозиторий в Gitea Две ветки: main (production) и dev (development) Необходимость постоянно переключаться между ними Как это делать на локальной машине? Два варианта.\nВариант А: Две отдельные папки # Клонируем репозиторий дважды - в разные папки:\n~/projects/ ├── blog/ ← ветка main (production) └── blog-dev/ ← ветка dev (development) Логика: Хочу работать с dev - иду в blog-dev. Хочу что-то проверить в production - иду в blog. Без переключения веток.\nКажущиеся преимущества # Параллельная работа. Можно держать открытыми два терминала - в одном hugo server для dev, в другом смотреть production код.\nИзоляция. Каждая папка - своя песочница. Изменения в одной не влияют на другую.\nПростота навигации. cd blog-dev вместо git checkout dev. Меньше команд.\nПривычный паттерн. Многие админы и разработчики держат несколько клонов для разных задач.\nРеальные проблемы # Проблема 1: Рассинхронизация локальных ветокin # Работаю в blog-dev - пишу статьи, коммичу, пушу в origin/dev. Всё хорошо.\nНо локальная ветка dev в папке blog при этом не обновляется. Она отстаёт от origin/dev.\nПриходишь делать merge:\ncd blog git checkout main git merge dev # Already up to date. ← НО есть НЮАНС! Git говорит \u0026ldquo;всё актуально\u0026rdquo;, имея в виду локальную ветку dev, которая отстала на три коммита. Статьи не попадают в production.\nПриходится помнить делать git pull origin dev перед каждым merge. Забыл - публикуешь устаревшую версию.\nПроблема 2: Конфликты при merge # Редактируешь config/params.toml в обеих папках независимо:\nВ blog-dev добавил Firebase конфиг В blog изменил название сайта При merge Git честно сообщает о конфликте:\nCONFLICT (content): Merge conflict in config/params.toml И это повторяется каждый раз когда трогаешь конфигурацию. Потому что две папки - это две независимые истории изменений одного файла.\nПроблема 3: Работа не в той папке # Несколько раз ловил себя на том что редактирую статьи прямо в blog - папке production. Это нарушает весь смысл раздельных окружений.\nПроблема 4: Умственная нагрузка # Постоянный вопрос \u0026ldquo;в какой папке я сейчас?\u0026rdquo; Для простого блога это лишняя когнитивная нагрузка.\nВариант Б: Одна папка с переключением веток # Один клон репозитория, работа через git checkout:\n~/projects/ └── blog/ ← одна папка, две ветки: main и dev Как это работает # Пишу статью:\ncd ~/projects/blog # Переключаюсь на dev git checkout dev # Проверяю что dev актуален git pull origin dev # Запускаю локальный сервер hugo server -D --bind 0.0.0.0 # Создаю статью hugo new content posts/название/index.md # Коммичу и пушу git add . git commit -m \u0026#34;feat: новая статья\u0026#34; git push origin dev Публикую:\n# Убеждаюсь что dev актуален git checkout dev git pull origin dev # Переключаюсь на main и мержу git checkout main git pull origin main git merge dev git push origin main Реальные преимущества # Никакой рассинхронизации. Все ветки в одном репозитории. git pull обновляет всё что нужно.\nНет конфликтов из-за независимых изменений. Когда работаешь в одной папке, params.toml существует в одном экземпляре. Все изменения делаются в dev, в main попадают только через merge.\nКонфликт возможен только если кто-то редактирует main напрямую - а это нарушение workflow.\nНевозможно ошибиться с веткой. git branch показывает где ты сейчас. Случайно отредактировать файлы в main - сложнее.\nМеньше места на диске. Один клон вместо двух. Один .git вместо двух.\nСравнение на практических примерах # Пример 1: Обновление темы Blowfish # Две папки:\ncd blog-dev git submodule update --remote themes/blowfish git add themes/blowfish git commit -m \u0026#34;update: Blowfish theme\u0026#34; git push origin dev # Проверяешь на dev.blog.ru # Если всё ок - мержишь cd ../blog git checkout dev git pull origin dev # ← ЛЕГКО ЗАБЫТЬ git checkout main git merge dev git push origin main Одна папка:\ncd blog git checkout dev git pull origin dev git submodule update --remote themes/blowfish git add themes/blowfish git commit -m \u0026#34;update: Blowfish theme\u0026#34; git push origin dev # Проверяешь на dev.blog.ru # Если всё ок - мержишь git checkout main git pull origin main git merge dev git push origin main Меньше команд, меньше переходов между папками, меньше шансов забыть git pull.\nПример 2: Правка опечатки в production # Нашёл опечатку на blog.ru. Нужно исправить быстро.\nДве папки:\nОпасность: хочется исправить прямо в blog (ветка main). Это нарушает workflow - все изменения должны идти через dev.\nПравильно:\ncd blog-dev git checkout dev # Исправляешь git commit -m \u0026#34;fix: опечатка\u0026#34; git push origin dev cd ../blog git checkout main git pull origin dev # ← опять легко забыть git merge dev git push origin main Одна папка:\ncd blog git checkout dev git pull origin dev # Исправляешь git commit -m \u0026#34;fix: опечатка\u0026#34; git push origin dev git checkout main git merge dev git push origin main Проще, меньше команд, понятнее.\nПример 3: Долгая работа над статьёй # Пишешь большую статью несколько дней. Между сеансами работы кто-то (или ты сам) запушил другие изменения в dev.\nДве папки:\ncd blog-dev # День 1: пишешь git add . git commit -m \u0026#34;wip: статья\u0026#34; # День 2: продолжаешь git pull origin dev # Подтягиваешь чужие изменения # Пишешь дальше git add . git commit -m \u0026#34;feat: закончил статью\u0026#34; git push origin dev Всё так же как и с одной папкой. Разницы нет.\nОдна папка:\ncd blog git checkout dev # День 1: пишешь git add . git commit -m \u0026#34;wip: статья\u0026#34; # День 2: продолжаешь git pull origin dev # Подтягиваешь чужие изменения # Пишешь дальше git add . git commit -m \u0026#34;feat: закончил статью\u0026#34; git push origin dev Идентично. Этот пример работает одинаково в обоих вариантах.\nЧто выбрать? # Если ты только начинаешь - сразу делай одну папку.\nДве папки кажутся удобными, но создают проблемы которые регулярно прерывают работу:\nРассинхронизация веток Конфликты при merge Когнитивная нагрузка Одна папка с переключением веток - стандартный Git workflow, проверенный миллионами разработчиков. Требует чуть больше дисциплины (git checkout dev вместо cd blog-dev), но избавляет от всех проблем выше.\nЗолотое правило: Никогда не редактировать файлы находясь на ветке main. Все изменения - через dev. Всегда.\nМиграция: если начал с двух папок # Если уже работаешь в двух папках - переход простой.\nШаг 1: Убеждаемся что всё запушено # # Проверяем обе папки cd ~/projects/blog-dev git status git push origin dev cd ~/projects/blog git status git push origin main Шаг 2: Синхронизируем ветку dev в основной папке # cd ~/projects/blog # Обновляем локальную ветку dev из remote git checkout dev git pull origin dev # Проверяем что всё актуально git log --oneline -5 # Возвращаемся на main git checkout main Шаг 3: Удаляем вторую папку # # Убеждаемся что в blog-dev нет несохранённых изменений cd ~/projects/blog-dev git status # Должно быть: nothing to commit, working tree clean # Удаляем папку cd ~/projects rm -rf blog-dev Шаг 4: Проверяем что всё работает # cd ~/projects/blog # Переключаемся на dev и запускаем сервер git checkout dev hugo server -D --bind 0.0.0.0 # Открываем http://localhost:1313/ # Видим dev версию сайта Новый workflow: шпаргалка # Создаю статью # cd ~/projects/blog git checkout dev hugo new content posts/название/index.md # Пишу, сохраняю, проверяю в hugo server git add . git commit -m \u0026#34;feat: название статьи\u0026#34; git push origin dev # → dev.blog.ru Публикую статью # git checkout dev git pull origin dev # Убеждаюсь что dev актуален git checkout main git pull origin main # Убеждаюсь что main актуален git merge dev git push origin main # → blog.ru Меняю конфигурацию # git checkout dev # ВСЕ изменения только через dev! nano config/params.toml git add . git commit -m \u0026#34;feat: изменил конфиг\u0026#34; git push origin dev # Проверяю на dev.blog.ru # Если всё ок git checkout main git merge dev git push origin main Что дальше # Workflow выбран, окружения работают. Можно писать статьи.\nНо есть ещё одна тема которую стоит разобрать - что делать когда что-то сломалось. Как диагностировать проблемы когда сайт вдруг начал отдавать 503, или SSL перестал работать, или webhook не срабатывает.\nВ следующей части покажу процесс диагностики на реальном примере - как я чинил blog.ru когда он внезапно стал недоступен из интернета.\nРекомендация этой части:\nОдна папка ~/projects/blog Переключение веток через git checkout Все изменения через dev → merge в main Никогда не редактировать находясь на main ","date":"16 февраля 2026","externalUrl":null,"permalink":"/posts/blog-part-4-git-workflow/","section":"Статьи","summary":"","title":"Блог на Hugo в K3s: часть 4 - выбор Git workflow","type":"posts"},{"content":"","date":"16 февраля 2026","externalUrl":null,"permalink":"/categories/%D0%B2%D0%B5%D0%B1-%D1%80%D0%B0%D0%B7%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D0%BA%D0%B0/","section":"Категории","summary":"","title":"Веб-Разработка","type":"categories"},{"content":"","date":"15 января 2026","externalUrl":null,"permalink":"/tags/basic-auth/","section":"Теги","summary":"","title":"Basic-Auth","type":"tags"},{"content":"В части 2 мы развернули production окружение для ветки main. Каждый пуш в main автоматически обновляет публичный сайт.\nПроблема: нельзя проверить как выглядит статья до публикации. Локальный hugo server показывает одно, а production может выглядеть по-другому из-за версий Hugo, конфигов, CSS.\nНужен второй пайплайн - тестовый контур где можно проверить изменения перед мержем в main.\nАрхитектура dev окружения # Git Push (dev branch) ↓ Gitea ↓ webhook Hugo Builder Dev ├→ Clone dev branch ├→ hugo --minify └→ Output → NFS (dev) ↓ /export/blog-public-dev/ ↓ Nginx Dev (1 реплика) ↓ Traefik Ingress ├→ Basic Auth Middleware └→ dev.blog.ru (SSL) Отличия от production:\nОтдельный Hugo Builder (переменная BRANCH=dev) Отдельный NFS volume (blog-public-dev) Отдельный Nginx (одна реплика вместо двух) Basic Auth - доступ только по логину и паролю Отдельный домен (dev.blog.ru) Всё это живёт в том же namespace что и production. Два независимых пайплайна, нулевое пересечение.\nШаг 1: Hugo Builder для dev # Используем тот же Docker образ что и для production. Разница - в переменной окружения BRANCH.\nФайл: 01-hugo-builder-dev.yaml\napiVersion: apps/v1 kind: Deployment metadata: name: hugo-builder-dev namespace: blog spec: replicas: 1 selector: matchLabels: app: hugo-builder-dev template: metadata: labels: app: hugo-builder-dev spec: containers: - name: hugo-builder image: hugo-builder:latest imagePullPolicy: Never env: - name: BRANCH value: \u0026#34;dev\u0026#34; # Главное отличие - используем dev ветку volumeMounts: - name: public mountPath: /mnt/blog-public resources: requests: cpu: 200m memory: 256Mi limits: cpu: 500m memory: 512Mi volumes: - name: public persistentVolumeClaim: claimName: blog-public-dev-pvc # Отдельный PVC --- apiVersion: v1 kind: Service metadata: name: hugo-builder-dev namespace: blog spec: selector: app: hugo-builder-dev ports: - port: 8080 targetPort: 8080 name: webhook # Применяем манифест kubectl apply -f 01-hugo-builder-dev.yaml # Проверяем что под запустился kubectl get pods -n blog | grep hugo-builder-dev # Смотрим логи kubectl logs -n blog deployment/hugo-builder-dev # Starting webhook listener on port 8080... Шаг 2: Nginx для dev # Одна реплика вместо двух - для тестового окружения высокая доступность не критична.\nФайл: 03-nginx-dev-deployment.yaml\napiVersion: apps/v1 kind: Deployment metadata: name: nginx-dev namespace: blog spec: replicas: 1 # Тестовому окружению достаточно одной реплики selector: matchLabels: app: nginx-dev template: metadata: labels: app: nginx-dev spec: containers: - name: nginx image: nginx:1.25-alpine ports: - containerPort: 80 volumeMounts: - name: html mountPath: /usr/share/nginx/html readOnly: true - name: config mountPath: /etc/nginx/nginx.conf subPath: nginx.conf resources: requests: cpu: 50m memory: 64Mi volumes: - name: html persistentVolumeClaim: claimName: blog-public-dev-pvc # Отдельный PVC - name: config configMap: name: nginx-dev-config --- apiVersion: v1 kind: Service metadata: name: nginx-dev namespace: blog spec: selector: app: nginx-dev ports: - port: 80 targetPort: 80 name: http # Применяем манифест kubectl apply -f 03-nginx-dev-deployment.yaml # Проверяем kubectl get pods -n blog | grep nginx-dev Шаг 3: Basic Auth через Traefik # Dev окружение должно быть закрыто от посторонних. Traefik поддерживает Basic Auth через Middleware.\nСоздаём пароль # # Генерируем htpasswd (логин: dev, пароль: ваш пароль) htpasswd -nb dev your-password # dev:$apr1$...хеш... # Кодируем в base64 для Kubernetes Secret echo -n \u0026#34;dev:$apr1$...хеш...\u0026#34; | base64 # ZGV2OiRhcHIxJC4uLg== Secret с паролем # Файл: 07-basic-auth-secret.yaml\n--- apiVersion: v1 kind: Secret metadata: name: dev-basic-auth namespace: blog type: Opaque data: users: ZGV2OiRhcHIxJC4uLg== # ваш base64 хеш # Применяем секрет kubectl apply -f 07-basic-auth-secret.yaml # Проверяем что секрет создался kubectl get secret -n blog | grep dev-basic-auth Middleware для Basic Auth # Файл: 08-basic-auth-middleware.yaml\n--- apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: name: dev-basic-auth namespace: blog spec: basicAuth: secret: dev-basic-auth removeHeader: true # Убираем заголовок Authorization после проверки # Применяем middleware kubectl apply -f 08-basic-auth-middleware.yaml # Проверяем kubectl get middleware -n blog # NAME AGE # dev-basic-auth 5s Шаг 4: IngressRoute с Basic Auth # Связываем всё вместе: домен → middleware → nginx-dev.\nФайл: 06-ingressroute-dev.yaml\n--- # HTTP (без SSL) apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: blog-dev-http namespace: blog spec: entryPoints: - web routes: - match: Host(`dev.blog.ru`) kind: Rule services: - name: nginx-dev port: 80 --- # HTTPS с Basic Auth apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: blog-dev-https namespace: blog spec: entryPoints: - websecure routes: - match: Host(`dev.blog.ru`) kind: Rule middlewares: - name: dev-basic-auth # Добавляем Basic Auth services: - name: nginx-dev port: 80 tls: secretName: blog-dev-tls # Применяем IngressRoute kubectl apply -f 06-ingressroute-dev.yaml # Проверяем kubectl get ingressroute -n blog | grep dev Шаг 5: SSL сертификат для dev # cert-manager выпустит отдельный сертификат для dev.blog.ru.\nНе забудьте: Добавить A-запись в DNS:\ndev.blog.ru A 77.37.XXX.XXX Файл: 04-certificate-dev.yaml\napiVersion: cert-manager.io/v1 kind: Certificate metadata: name: blog-dev-tls namespace: blog spec: secretName: blog-dev-tls issuerRef: name: letsencrypt-prod kind: ClusterIssuer dnsNames: - dev.blog.ru # Применяем манифест kubectl apply -f 04-certificate-dev.yaml # Ждём получения сертификата (30-60 секунд) kubectl get certificate -n blog # Должно быть READY=True # NAME READY SECRET AGE # blog-dev-tls True blog-dev-tls 45s Шаг 6: Webhook в Gitea для dev # Создаём второй webhook который триггерится на пуши в ветку dev.\nGitea → ваш репозиторий → Settings → Webhooks → Add Webhook → Gitea\nURL: http://hugo-builder-dev.blog.svc.cluster.local:8080 HTTP Method: POST Content Type: application/json Trigger On: Push events Branch filter: dev ← Главное отличие от prod Нажимаем \u0026ldquo;Test Delivery\u0026rdquo; → должен вернуть 200 OK.\nПроверяем логи:\n# Смотрим логи Hugo Builder Dev kubectl logs -n blog deployment/hugo-builder-dev -f # Должно появиться: # Webhook triggered, starting build... # Cloning repository (branch: dev)... # Build successful! Проверка работы # # Создаём тестовую статью в dev cd ~/projects/blog git checkout dev # Создаём статью hugo new content posts/test-dev/index.md echo \u0026#34;Тестовая статья в dev окружении\u0026#34; \u0026gt;\u0026gt; content/posts/test-dev/index.md # Коммитим и пушим git add . git commit -m \u0026#34;test: проверка dev окружения\u0026#34; git push origin dev # Следим за логами Hugo Builder Dev kubectl logs -n blog deployment/hugo-builder-dev -f # Через 5-7 секунд сборка завершится Открываем https://dev.blog.ru в браузере:\nБраузер запросит логин и пароль (Basic Auth) Вводим: логин dev, пароль который задали Видим тестовую статью Критично: Статья появилась на dev.blog.ru, но её нет на blog.ru - окружения изолированы.\nWorkflow: dev → prod # Типичный процесс работы:\n1. Пишу статью в dev:\ncd ~/projects/blog git checkout dev # Создаю статью hugo new content posts/kubernetes-intro/index.md # Пишу контент, коммичу git add . git commit -m \u0026#34;feat: статья про Kubernetes\u0026#34; git push origin dev # → автосборка → dev.blog.ru 2. Проверяю на dev.blog.ru:\nОткрываю https://dev.blog.ru (вводя логин/пароль), читаю статью, проверяю форматирование, ссылки, изображения.\nНахожу опечатку - исправляю локально, пушу в dev снова. Повторяю пока не доволен результатом.\n3. Публикую в production:\n# Всё отлично на dev - мержу в main git checkout main git merge dev git push origin main # → автосборка → blog.ru Статья появляется на публичном сайте.\nИтоговая архитектура # Два полностью независимых пайплайна в одном namespace:\nProduction: main branch → hugo-builder-prod → blog-public-pvc → nginx (x2) → blog.ru Development: dev branch → hugo-builder-dev → blog-public-dev-pvc → nginx-dev (x1) → dev.blog.ru + Basic Auth Общее:\nNamespace: blog Gitea репозиторий Docker образ Hugo Builder Traefik IngressRoute cert-manager Отдельное:\nDeployments Services PersistentVolumes SSL сертификаты Домены Что дальше # Два окружения работают. Можно писать статьи, проверять на dev, публиковать в production.\nНо есть проблема: я долго работал в двух папках - ~/projects/blog (main) и ~/projects/blog-dev (dev). Это создавало конфликты при merge, рассинхронизацию веток и головную боль.\nВ следующей части расскажу как я от этого избавился и почему одна папка с переключением веток лучше чем две отдельные папки.\nСтек этой части:\nHugo Builder Dev (та же версия Hugo) Nginx Dev (одна реплика) Traefik Basic Auth Middleware cert-manager (отдельный сертификат) NFS (отдельный volume) ","date":"15 января 2026","externalUrl":null,"permalink":"/posts/blog-part-3-dev-environment/","section":"Статьи","summary":"","title":"Блог на Hugo в K3s: часть 3 - development окружение","type":"posts"},{"content":"","date":"8 января 2026","externalUrl":null,"permalink":"/tags/cert-manager/","section":"Теги","summary":"","title":"Cert-Manager","type":"tags"},{"content":"","date":"8 января 2026","externalUrl":null,"permalink":"/tags/nfs/","section":"Теги","summary":"","title":"Nfs","type":"tags"},{"content":"В первой части мы запустили Hugo локально. Сайт работает пока открыт терминал. Закрыл терминал - сайт умер.\nПора переносить это в K3s.\nАрхитектура деплоя # Git Push ↓ Gitea (внутренний) ↓ webhook POST Hugo Builder ├→ git clone + submodule ├→ hugo --minify └→ output → NFS ↓ /export/blog-public/ ↓ Nginx (x2 реплики) ↓ Traefik Ingress ↓ your-blog.ru (SSL) Пять компонентов:\nNFS - хранилище для статики (OpenMediaVault) Hugo Builder - пересобирает сайт при каждом пуше Nginx - раздаёт статику с NFS cert-manager - автоматический SSL от Let\u0026rsquo;s Encrypt Traefik IngressRoute - маршрутизация с SSL терминацией Шаг 1: NFS хранилище # Hugo собирает статику в HTML/CSS/JS файлы. Nginx раздаёт эти файлы. Значит нужно общее хранилище куда Hugo пишет, а Nginx читает.\nNFS - самый простой вариант для homelab. У меня OpenMediaVault на отдельной машине.\nСоздаём директории на NAS # # Подключаемся к NAS (SSH на нестандартном порту для безопасности) ssh -p 33322 nasadmin@192.168.11.30 # Создаём папки для production и development окружений sudo mkdir -p /srv/storage/blog/blog-public sudo mkdir -p /srv/storage/blog/blog-public-dev # Выдаём права на запись (контейнеры пишут от root) sudo chmod -R 775 /srv/storage/blog/ Почему SSH на порту 33322? Стандартный порт 22 - первая цель сканеров и ботов. Нестандартный порт снижает шум в логах и количество brute-force попыток до нуля. Безопасность через скрытность работает для домашних серверов.\nНастраиваем NFS через OMV Web UI # Storage → Shared Folders → Create:\nName: blog-public Device: основной диск Path: /blog/blog-public Services → NFS → Shares → Create:\nShared folder: blog-public Client: 192.168.11.0/24 Privilege: Read/Write Extra options: rw,sync,no_subtree_check,no_root_squash То же для blog-public-dev.\nКритично: no_root_squash - без этого контейнеры не смогут записывать файлы (они пишут от root внутри контейнера).\nПроверяем экспорт # # Заходим на NAS ssh -p 33322 nasadmin@192.168.11.30 # Проверяем что NFS экспортирует наши шары sudo exportfs -v | grep blog # Ожидаемый вывод - две строки с настройками экспорта: # /export/blog-public 192.168.11.0/24(rw,sync,no_root_squash,...) # /export/blog-public-dev 192.168.11.0/24(rw,sync,no_root_squash,...) Шаг 2: PersistentVolumes в K3s # K3s нужно сказать где лежат NFS шары. Создаём манифест с PersistentVolume ресурсами.\nФайл: 02-pv.yaml\n--- apiVersion: v1 kind: PersistentVolume metadata: name: blog-public-pv spec: capacity: storage: 5Gi accessModes: - ReadWriteMany nfs: server: 192.168.11.30 # IP вашего NAS path: /export/blog-public mountOptions: - nfsvers=3 - hard --- apiVersion: v1 kind: PersistentVolume metadata: name: blog-public-dev-pv spec: capacity: storage: 5Gi accessModes: - ReadWriteMany nfs: server: 192.168.11.30 path: /export/blog-public-dev mountOptions: - nfsvers=3 - hard Почему NFSv3, а не NFSv4? Потому что NFSv4.2 в K3s не работал - поды виснут в ContainerCreating с ошибкой mount.nfs: No such file or directory. NFSv3 работает стабильно. Не надо усложнять то что работает.\n# Применяем манифест kubectl apply -f 02-pv.yaml # Проверяем что PV создались и привязались kubectl get pv | grep blog # blog-public-pv 5Gi RWX Bound blog/blog-public-pvc Шаг 3: Hugo Builder # Нужен контейнер который слушает webhook от Gitea, клонирует репозиторий и собирает Hugo.\nЗачем нужен Hugo Builder? # Проблема: Hugo генерирует статику командой hugo. Где её запускать? На локальной машине? Тогда нужно вручную заливать файлы на сервер после каждого изменения. Неудобно и ломает автоматизацию.\nРешение: Контейнер который живёт в K3s, слушает webhook от Gitea и автоматически пересобирает сайт при каждом git push.\nDockerfile # FROM alpine:3.19 # Устанавливаем всё что нужно Hugo и Git RUN apk add --no-cache \\ git nodejs npm bash curl wget \\ libc6-compat libstdc++ ca-certificates # Скачиваем Hugo Extended v0.155.3 WORKDIR /tmp RUN wget https://github.com/gohugoio/hugo/releases/download/v0.155.3/hugo_extended_0.155.3_linux-amd64.tar.gz \u0026amp;\u0026amp; \\ tar -xzf hugo_extended_0.155.3_linux-amd64.tar.gz \u0026amp;\u0026amp; \\ cp hugo /usr/bin/hugo \u0026amp;\u0026amp; \\ chmod +x /usr/bin/hugo \u0026amp;\u0026amp; \\ rm -rf /tmp/* WORKDIR /workspace # Копируем скрипты COPY webhook-listener.sh /usr/local/bin/ COPY build.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/*.sh EXPOSE 8080 CMD [\u0026#34;/usr/local/bin/webhook-listener.sh\u0026#34;] build.sh - скрипт сборки Hugo # Зачем: Отдельный скрипт сборки нужен чтобы его можно было запускать не только из webhook listener, но и вручную для тестирования. Один скрипт - одна ответственность.\n#!/bin/bash set -e # Остановиться при первой ошибке export GIT_TERMINAL_PROMPT=0 # Не запрашивать пароли интерактивно REPO_URL=\u0026#34;https://git.example.com/user/blog.git\u0026#34; # URL вашего Gitea репозитория BRANCH=\u0026#34;${BRANCH:-main}\u0026#34; # Ветка (передаётся через env) OUTPUT_DIR=\u0026#34;/mnt/blog-public\u0026#34; # Куда складывать собранную статику (NFS) WORK_DIR=\u0026#34;/tmp/build\u0026#34; # Временная папка для клонирования # Чистим рабочую директорию от прошлой сборки rm -rf ${WORK_DIR} mkdir -p ${WORK_DIR} # Клонируем репозиторий (только нужную ветку, без истории) cd ${WORK_DIR} git clone --branch ${BRANCH} --depth 1 ${REPO_URL} site 2\u0026gt;\u0026amp;1 cd site # Подтягиваем тему Blowfish как Git submodule git submodule update --init --recursive --depth 1 2\u0026gt;\u0026amp;1 # Собираем сайт (минифицируем CSS/JS/HTML) hugo --minify --destination ${OUTPUT_DIR} 2\u0026gt;\u0026amp;1 # Проверяем что сборка прошла успешно if [ -f \u0026#34;${OUTPUT_DIR}/index.html\u0026#34; ]; then echo \u0026#34;Build successful!\u0026#34; else echo \u0026#34;Build failed - index.html not found\u0026#34; exit 1 fi # Убираем за собой rm -rf ${WORK_DIR} webhook-listener.sh - слушатель webhook # Зачем: Gitea отправляет HTTP POST запрос при каждом git push. Нужен простой HTTP сервер который принимает этот запрос и запускает сборку. netcat - самый простой способ поднять HTTP listener без зависимостей.\n#!/bin/bash set -e echo \u0026#34;Starting webhook listener on port 8080...\u0026#34; while true; do # Принимаем HTTP запрос через netcat и сразу отвечаем 200 OK echo -e \u0026#34;HTTP/1.1 200 OK\\r\\n\\r\\nWebhook received\u0026#34; | nc -l -p 8080 # Запускаем сборку синхронно (чтобы видеть логи в kubectl logs) echo \u0026#34;$(date): Webhook triggered, starting build...\u0026#34; /usr/local/bin/build.sh echo \u0026#34;$(date): Build completed, waiting for next webhook...\u0026#34; done Сборка и деплой образа # # Собираем Docker образ docker build -t hugo-builder:latest . # Сохраняем в tar файл docker save hugo-builder:latest -o /tmp/hugo-builder.tar # Копируем на все K3s worker ноды for ip in 210 211; do scp /tmp/hugo-builder.tar k3s@192.168.11.$ip:/tmp/ # Импортируем образ в containerd K3s ssh k3s@192.168.11.$ip \u0026#34;sudo k3s ctr images import /tmp/hugo-builder.tar \u0026amp;\u0026amp; rm /tmp/hugo-builder.tar\u0026#34; done Deployment и Service # --- apiVersion: apps/v1 kind: Deployment metadata: name: hugo-builder-prod namespace: blog spec: replicas: 1 selector: matchLabels: app: hugo-builder-prod template: metadata: labels: app: hugo-builder-prod spec: containers: - name: hugo-builder image: hugo-builder:latest imagePullPolicy: Never # Образ локальный, не тянуть из registry env: - name: BRANCH value: \u0026#34;main\u0026#34; # Для prod используем main ветку volumeMounts: - name: public mountPath: /mnt/blog-public # NFS хранилище resources: requests: cpu: 200m memory: 256Mi limits: cpu: 500m memory: 512Mi volumes: - name: public persistentVolumeClaim: claimName: blog-public-pvc --- apiVersion: v1 kind: Service metadata: name: hugo-builder-prod namespace: blog spec: selector: app: hugo-builder-prod ports: - port: 8080 targetPort: 8080 name: webhook # Применяем манифест kubectl apply -f 01-hugo-builder-prod.yaml # Проверяем что под запустился kubectl get pods -n blog | grep hugo-builder # Смотрим логи - должна быть строка \u0026#34;Starting webhook listener\u0026#34; kubectl logs -n blog deployment/hugo-builder-prod Шаг 4: Nginx с Prometheus exporter # Nginx раздаёт статику с того же NFS где Hugo её собрал. Две реплики для минимальной доступности при обновлениях.\nБонус: sidecar контейнер с nginx-prometheus-exporter для мониторинга через Grafana.\n--- apiVersion: apps/v1 kind: Deployment metadata: name: nginx namespace: blog spec: replicas: 2 # Две реплики для доступности selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: # Основной контейнер - Nginx - name: nginx image: nginx:1.25-alpine ports: - containerPort: 80 volumeMounts: - name: html mountPath: /usr/share/nginx/html readOnly: true # Nginx только читает, не пишет - name: config mountPath: /etc/nginx/nginx.conf subPath: nginx.conf resources: requests: cpu: 50m memory: 64Mi # Sidecar - экспортер метрик для Prometheus - name: nginx-exporter image: nginx/nginx-prometheus-exporter:1.1.0 args: - -nginx.scrape-uri=http://localhost/nginx_status ports: - containerPort: 9113 name: metrics resources: requests: cpu: 10m memory: 16Mi volumes: - name: html persistentVolumeClaim: claimName: blog-public-pvc # NFS хранилище - name: config configMap: name: nginx-config --- apiVersion: v1 kind: Service metadata: name: nginx namespace: blog spec: selector: app: nginx ports: - port: 80 targetPort: 80 name: http - port: 9113 targetPort: 9113 name: metrics # Для Prometheus Шаг 5: SSL сертификаты # cert-manager автоматически получает сертификаты от Let\u0026rsquo;s Encrypt через HTTP-01 challenge.\nВажно: Сначала настрой A-запись у DNS провайдера:\nyour-blog.ru A 77.37.XXX.XXX (ваш внешний IP) www.your-blog.ru A 77.37.XXX.XXX Без этого Let\u0026rsquo;s Encrypt не сможет проверить что домен принадлежит вам.\napiVersion: cert-manager.io/v1 kind: Certificate metadata: name: blog-tls namespace: blog spec: secretName: blog-tls issuerRef: name: letsencrypt-prod kind: ClusterIssuer dnsNames: - your-blog.ru - www.your-blog.ru # Применяем манифест kubectl apply -f 04-certificate.yaml # Ждём 30-60 секунд пока cert-manager получит сертификат kubectl get certificate -n blog # Должно быть READY=True # NAME READY SECRET AGE # blog-tls True blog-tls 45s Шаг 6: IngressRoute через Traefik # Traefik маршрутизирует трафик на Nginx и делает SSL терминацию.\n--- # HTTP → HTTPS редирект (опционально) apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: blog-http namespace: blog spec: entryPoints: - web # Порт 80 routes: - match: Host(`your-blog.ru`) || Host(`www.your-blog.ru`) kind: Rule services: - name: nginx port: 80 --- # HTTPS с SSL apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: blog-https namespace: blog spec: entryPoints: - websecure # Порт 443 routes: - match: Host(`your-blog.ru`) || Host(`www.your-blog.ru`) kind: Rule services: - name: nginx port: 80 tls: secretName: blog-tls # Сертификат от cert-manager # Применяем манифест kubectl apply -f 05-ingressroute.yaml # Проверяем что сайт доступен curl -I https://your-blog.ru # HTTP/2 200 Шаг 7: Webhook в Gitea # Последний шаг - связать Gitea с Hugo Builder.\nGitea → ваш репозиторий → Settings → Webhooks → Add Webhook → Gitea\nURL: http://hugo-builder-prod.blog.svc.cluster.local:8080 HTTP Method: POST Content Type: application/json Trigger On: Push events Branch filter: main Нажимаем \u0026ldquo;Test Delivery\u0026rdquo; - должен вернуть 200 OK.\nПроверяем логи Hugo Builder:\n# Следим за логами в реальном времени kubectl logs -n blog deployment/hugo-builder-prod -f # Должно появиться: # Webhook triggered, starting build... # Cloning repository... # Initializing submodules... # Building Hugo site... # Build successful! Проверка работы # # Меняем статью cd ~/hugo-projects/blog git checkout main echo \u0026#34;## Тестовая правка\u0026#34; \u0026gt;\u0026gt; content/posts/hello-world/index.md # Коммитим и пушим git add . git commit -m \u0026#34;test: проверка автосборки\u0026#34; git push origin main # Следим за логами Hugo Builder kubectl logs -n blog deployment/hugo-builder-prod -f # Через 5-7 секунд сборка завершится # Проверяем что изменение попало на сайт curl -s https://your-blog.ru/posts/hello-world/ | grep \u0026#34;Тестовая правка\u0026#34; Если видите \u0026ldquo;Тестовая правка\u0026rdquo; - всё работает. Каждый git push автоматически обновляет сайт.\nЧто дальше # Production окружение развёрнуто. Но пока только для ветки main.\nВ следующей части добавим development окружение с отдельным Hugo Builder, Nginx и защитой через Basic Auth. Два независимых пайплайна в одном namespace.\nСтек этой части:\nK3s 1.30 NFS на OpenMediaVault Hugo Builder (Alpine + Hugo v0.155.3) Nginx 1.25 + Prometheus exporter cert-manager + Let\u0026rsquo;s Encrypt Traefik IngressRoute ","date":"8 января 2026","externalUrl":null,"permalink":"/posts/blog-part-2-k8s-deployment/","section":"Статьи","summary":"","title":"Блог на Hugo в K3s: часть 2 - деплой в кластер","type":"posts"},{"content":"","date":"3 января 2026","externalUrl":null,"permalink":"/tags/blowfish/","section":"Теги","summary":"","title":"Blowfish","type":"tags"},{"content":"Предыдущий блог жил на Jekyll. Жил - громко сказано. Скорее существовал, периодически ломаясь при обновлениях и требуя ритуальных танцев с Ruby каждый раз когда я садился за новую статью.\nОднажды я решил что хватит.\nПочему не Jekyll # Jekyll - зрелый инструмент с большим сообществом. Но у него есть фундаментальная проблема: он написан на Ruby.\nЗвучит невинно. На практике это означает:\nВерсионный ад. Ruby, Bundler, Gems - у каждого своя версия, и они регулярно конфликтуют друг с другом. Клонируешь репозиторий на новую машину - час уходит на то чтобы собрать рабочее окружение. Обновляешь Jekyll - ломаются плагины. Обновляешь плагины - ломается что-то ещё.\nЗависимости ради зависимостей. Простой блог тянет 1000+ gems, половина из которых устаревшая или находится в состоянии \u0026ldquo;поддерживается постольку-поскольку\u0026rdquo;. Каждый bundle install - это лотерея.\nЛишний мусор. Jekyll генерирует страницы без ссылок, которые непонятно зачем существуют. Приходится явно прописывать что не генерировать.\nHugo решает все эти проблемы радикально: это один бинарник на Go. Никаких зависимостей, никаких конфликтов версий, никакого bundle install. Скачал - работает. На любой машине, всегда.\nБинарник весит ~50MB. Собирает сайт из 40+ страниц за полторы секунды. Jekyll на том же контенте думал заметно дольше.\nПочему Blowfish # Все просто: понравилась.\nСмотрел темы несколько часов. Blowfish выглядела именно так как я хотел - чисто, без лишнего, с хорошей типографикой. Взял её.\nСтавится как Git submodule - обновляется одной командой, не засоряет репозиторий.\nАрхитектура: два окружения # У любого нормального инфраструктурного проекта есть тестовый и production контур. Блог - не исключение: обновления Hugo, изменения темы, новые статьи - всё это нужно проверять до того как это увидят читатели.\n┌────────────────────────────────────┐ │ Gitea (внутренний) │ │ репозиторий: blog │ │ │ │ ветка: main ветка: dev │ └───────┬────────────────────┬───────┘ │ │ ▼ ▼ ┌───────────────┐ ┌───────────────┐ │ hugo-builder │ │ hugo-builder │ │ prod │ │ dev │ └───────┬───────┘ └───────┬───────┘ │ │ ▼ ▼ ┌───────────────┐ ┌───────────────┐ │ nginx │ │ nginx-dev │ │ blog.ru │ │ dev.blog.ru │ │ (публичный) │ │ (Basic Auth) │ └───────────────┘ └───────────────┘ Production (blog.ru) - публичный. Ветка main.\nDevelopment (dev.blog.ru) - тестовый контур. Ветка dev. Закрыт Basic Auth через Traefik Middleware - без логина и пароля не войти.\nОба окружения в одном K3s namespace. Один репозиторий, две ветки, два независимых пайплайна.\nШаг 1: Создаём Hugo проект # # Создаём новый Hugo сайт cd ~/projects hugo new site blog cd blog # Инициализируем Git репозиторий git init git remote add origin https://git.example.com/user/blog.git # Добавляем тему Blowfish как Git submodule git submodule add -b main https://github.com/nunocoracao/blowfish.git themes/blowfish # Подтягиваем файлы submodule git submodule update --init --recursive # Удаляем дефолтный конфиг Hugo rm -f hugo.toml # Создаём структуру для конфигов mkdir -p config/_default # Копируем примеры конфигов из темы cp themes/blowfish/config/_default/*.toml config/_default/ Структура после инициализации:\nblog/ ├── content/ ← статьи в Markdown ├── static/ ← изображения, favicon ├── themes/ │ └── blowfish/ ← тема (Git submodule) └── config/ └── _default/ ├── hugo.toml ├── languages.ru.toml ├── menus.ru.toml ├── markup.toml └── params.toml Шаг 2: Минимальная конфигурация # hugo.toml # baseURL = \u0026#34;https://blog.ru/\u0026#34; theme = \u0026#34;blowfish\u0026#34; defaultContentLanguage = \u0026#34;ru\u0026#34; languages.ru.toml # # Переименовываем файл языка для русского mv config/_default/languages.en.toml config/_default/languages.ru.toml languageCode = \u0026#34;ru\u0026#34; languageName = \u0026#34;Русский\u0026#34; weight = 1 title = \u0026#34;Мой Блог\u0026#34; [params] displayName = \u0026#34;RU\u0026#34; isoCode = \u0026#34;ru\u0026#34; rtl = false dateFormat = \u0026#34;2 January 2006\u0026#34; [params.author] name = \u0026#34;Ваше Имя\u0026#34; headline = \u0026#34;DevOps Engineer | Kubernetes | Homelab\u0026#34; menus.ru.toml # # Переименовываем файл меню mv config/_default/menus.en.toml config/_default/menus.ru.toml [[main]] name = \u0026#34;Блог\u0026#34; pageRef = \u0026#34;posts\u0026#34; weight = 10 [[main]] name = \u0026#34;О сайте\u0026#34; pageRef = \u0026#34;about\u0026#34; weight = 20 Шаг 3: Первая статья # # Создаём новую статью hugo new content posts/hello-world/index.md --- title: \u0026#34;Hello World\u0026#34; date: 2026-02-13 draft: false description: \u0026#34;Первый пост на новом сайте\u0026#34; tags: [\u0026#34;test\u0026#34;] categories: [\u0026#34;Веб-разработка\u0026#34;] --- Это первый пост на blog.ru. Шаг 4: Проверяем локально # # Запускаем локальный сервер Hugo # -D: показывать черновики (draft: true) # --bind 0.0.0.0: доступен с любого IP в локальной сети hugo server -D --bind 0.0.0.0 Открываем http://192.168.11.10:1313/ (ваш IP) - сайт с Blowfish темой и первой статьёй.\nHugo автоматически пересобирает сайт при сохранении файлов - изменения видны сразу после обновления страницы в браузере.\nШаг 5: Пушим в Gitea # # Добавляем все файлы в Git git add . # Создаём первый коммит git commit -m \u0026#34;Initial commit: Hugo + Blowfish v2.98.0\u0026#34; # Переименовываем ветку в main (если по умолчанию master) git branch -M main # Пушим в Gitea git push -u origin main # Создаём ветку dev для development окружения git checkout -b dev git push -u origin dev # Возвращаемся на main git checkout main Workflow: как я пишу статьи # Пишу (ветка dev):\n# Переходим в папку проекта cd ~/projects/blog # Переключаемся на ветку dev git checkout dev # Запускаем локальный предпросмотр hugo server -D --bind 0.0.0.0 # Создаём новую статью hugo new content posts/название-статьи/index.md # Редактируем в любом редакторе, сохраняем, смотрим в браузере # Когда готово - коммитим git add . git commit -m \u0026#34;feat: новая статья про X\u0026#34; # Пушим в dev ветку git push origin dev # → webhook срабатывает → автосборка → dev.blog.ru Проверяю:\nОткрываю https://dev.blog.ru (вводя логин/пароль Basic Auth) - вижу статью как её увидят читатели.\nПубликую:\n# Переключаемся на main git checkout main # Мержим изменения из dev git merge dev # Пушим в production git push origin main # → webhook срабатывает → автосборка → blog.ru Золотое правило: все изменения только через ветку dev. В main - только через merge. Никогда не редактировать файлы находясь на main.\nПочему это важно - расскажу отдельно, когда дойдём до того как я нарушил это правило и что из этого вышло.\nЧто дальше # Локально всё работает. Но hugo server умрёт как только закрою терминал.\nНужно развернуть это в K3s: Hugo Builder который пересобирает сайт при каждом пуше, Nginx который раздаёт статику, SSL сертификаты, NFS хранилище. Об этом - в следующей части.\nСтек этой части:\nHugo v0.155.3 extended Blowfish v2.98.0 Gitea (внутренний) Рабочая станция: Debian/Ubuntu ","date":"3 января 2026","externalUrl":null,"permalink":"/posts/blog-part-1-architecture/","section":"Статьи","summary":"","title":"Блог на Hugo в K3s: часть 1 - архитектура и первый запуск","type":"posts"},{"content":"","date":"2 ноября 2025","externalUrl":null,"permalink":"/tags/etcd/","section":"Теги","summary":"","title":"Etcd","type":"tags"},{"content":"","date":"2 ноября 2025","externalUrl":null,"permalink":"/tags/ha/","section":"Теги","summary":"","title":"Ha","type":"tags"},{"content":"","date":"2 ноября 2025","externalUrl":null,"permalink":"/categories/homelab/","section":"Категории","summary":"","title":"Homelab","type":"categories"},{"content":"","date":"2 ноября 2025","externalUrl":null,"permalink":"/tags/installation/","section":"Теги","summary":"","title":"Installation","type":"tags"},{"content":"Инфраструктура готова: 5 VM работают, ОС настроена, порты открыты. Пора устанавливать K3s. Один curl-скрипт на каждую ноду - и через 15 минут у вас работающий HA-кластер.\nЗвучит слишком просто? Потому что сложная часть уже позади - в предыдущих статьях. Теперь осталось не перепутать флаги и порядок установки.\nРезультат: 5 нод в статусе Ready, etcd кластер 3/3 healthy, kubectl работает с локальной машины.\nДля кого это # Подходит:\nПрошёл статьи 1 и 2 (или имеешь готовые VM с настроенной ОС) Все 5 нод доступны по SSH Понимаешь разницу между master и worker Не подходит:\nVM ещё не созданы → статья 2 Не понимаешь зачем 3 master ноды → статья 1 Что понадобится # Компонент Значение K3s версия v1.31.4+k3s1 (или актуальная stable) Token Сгенерируем на первом шаге SSH доступ Ко всем 5 нодам Время ~15-20 минут Шаг 1: Сгенерировать token # Token - общий секрет для всех нод кластера. Без правильного token нода не присоединится.\nНа локальной машине:\n# Сгенерировать случайный token openssl rand -base64 32 Пример вывода:\nK10f8c9a7b6e5d4c3b2a1f0e9d8c7b6a5e4d3c2b1a0f9e8d7c6b5a4== Сохрани этот token - он понадобится для каждой ноды. Положи в менеджер паролей или временный файл.\n# Для удобства - сохранить в переменную (на время сессии) export K3S_TOKEN=\u0026#34;твой_сгенерированный_token\u0026#34; echo $K3S_TOKEN Шаг 2: Установить K3s на первую master ноду # Первая нода инициализирует etcd кластер. Она особенная - использует флаг --cluster-init.\nSSH на k3s-master-1:\nssh k3s@192.168.11.201 Установка:\n# Задать переменные export K3S_TOKEN=\u0026#34;твой_сгенерированный_token\u0026#34; export INSTALL_K3S_VERSION=\u0026#34;v1.31.4+k3s1\u0026#34; # Установить K3s curl -sfL https://get.k3s.io | sh -s - server \\ --cluster-init \\ --tls-san=192.168.11.201 \\ --disable=traefik \\ --disable=servicelb \\ --write-kubeconfig-mode=644 Разбор флагов:\nФлаг Зачем server Режим control plane (не agent) --cluster-init Ключевой! Инициализирует etcd. Только на первой ноде --tls-san=192.168.11.201 Добавить IP в сертификат API server --disable=traefik Отключить встроенный Traefik (установим свой через Helm) --disable=servicelb Отключить встроенный LB (установим MetalLB) --write-kubeconfig-mode=644 Разрешить чтение kubeconfig без sudo Установка займёт 1-2 минуты. K3s скачает бинарник (~50MB) и запустит все компоненты.\nПроверка # # 1. Статус сервиса sudo systemctl status k3s Ожидаемый результат:\n● k3s.service - Lightweight Kubernetes Loaded: loaded Active: active (running) since ... # 2. Статус ноды sudo k3s kubectl get nodes Ожидаемый результат:\nNAME STATUS ROLES AGE VERSION k3s-master-1 Ready control-plane,etcd,master 45s v1.31.4+k3s1 Checkpoint: Первая master работает # # Быстрая проверка sudo systemctl is-active k3s \u0026amp;\u0026amp; \\ sudo k3s kubectl get nodes | grep -q \u0026#34;Ready\u0026#34; \u0026amp;\u0026amp; \\ echo \u0026#34;✓ Master-1 готов\u0026#34; || echo \u0026#34;✗ Проблема\u0026#34; Если статус NotReady или сервис не запустился:\n# Смотреть логи sudo journalctl -u k3s -f --no-pager | tail -50 Ошибка в логах Причина Решение cgroup v1 is not supported Нужен cgroup v2 Вернись к статье 2, шаг 6.5 port 6443 already in use Что-то занимает порт sudo ss -tlnp | grep 6443 etcd failed to start Мало места на диске df -h, увеличь диск Шаг 3: Добавить вторую master ноду # Теперь присоединяем вторую master. Она подключается к существующему кластеру - без --cluster-init.\nSSH на k3s-master-2:\nssh k3s@192.168.11.202 Установка:\nexport K3S_TOKEN=\u0026#34;твой_сгенерированный_token\u0026#34; export INSTALL_K3S_VERSION=\u0026#34;v1.31.4+k3s1\u0026#34; curl -sfL https://get.k3s.io | sh -s - server \\ --server=https://192.168.11.201:6443 \\ --tls-san=192.168.11.202 \\ --disable=traefik \\ --disable=servicelb \\ --write-kubeconfig-mode=644 Ключевое отличие:\n❌ Нет --cluster-init - кластер уже инициализирован ✅ Есть --server=https://192.168.11.201:6443 - адрес существующего кластера Проверка # # На master-2 sudo k3s kubectl get nodes Ожидаемый результат:\nNAME STATUS ROLES AGE VERSION k3s-master-1 Ready control-plane,etcd,master 3m v1.31.4+k3s1 k3s-master-2 Ready control-plane,etcd,master 30s v1.31.4+k3s1 Две ноды - но кворума ещё нет. etcd требует большинство, а 2 из 3 - это ещё не \u0026ldquo;большинство от трёх\u0026rdquo;.\nШаг 4: Добавить третью master ноду # SSH на k3s-master-3:\nssh k3s@192.168.11.203 Установка (аналогично master-2):\nexport K3S_TOKEN=\u0026#34;твой_сгенерированный_token\u0026#34; export INSTALL_K3S_VERSION=\u0026#34;v1.31.4+k3s1\u0026#34; curl -sfL https://get.k3s.io | sh -s - server \\ --server=https://192.168.11.201:6443 \\ --tls-san=192.168.11.203 \\ --disable=traefik \\ --disable=servicelb \\ --write-kubeconfig-mode=644 Checkpoint: Control Plane HA готов # На любой master ноде:\n# 1. Все 3 master ноды Ready sudo k3s kubectl get nodes Ожидаемый результат:\nNAME STATUS ROLES AGE VERSION k3s-master-1 Ready control-plane,etcd,master 5m v1.31.4+k3s1 k3s-master-2 Ready control-plane,etcd,master 3m v1.31.4+k3s1 k3s-master-3 Ready control-plane,etcd,master 1m v1.31.4+k3s1 # 2. etcd кластер здоров sudo k3s kubectl exec -n kube-system \\ $(sudo k3s kubectl get pods -n kube-system -l component=etcd -o name | head -1) \\ -- etcdctl endpoint health --cluster Ожидаемый результат:\nhttps://192.168.11.201:2379 is healthy: successfully committed proposal: took = 2.1ms https://192.168.11.202:2379 is healthy: successfully committed proposal: took = 1.8ms https://192.168.11.203:2379 is healthy: successfully committed proposal: took = 2.3ms Теперь у вас настоящий HA. Можете остановить любую master ноду - кластер продолжит работать.\nТест отказоустойчивости (опционально) # Хотите убедиться, что HA работает? Проверьте:\n# С локальной машины - остановить master-2 ssh k3s@192.168.11.202 \u0026#34;sudo systemctl stop k3s\u0026#34; # Подождать 30-40 секунд, затем на master-1: ssh k3s@192.168.11.201 \u0026#34;sudo k3s kubectl get nodes\u0026#34; # master-2 станет NotReady, но кластер работает # Проверить etcd - 2/3 кворум есть ssh k3s@192.168.11.201 \u0026#34;sudo k3s kubectl exec -n kube-system \\ \\$(sudo k3s kubectl get pods -n kube-system -l component=etcd -o name | head -1) \\ -- etcdctl endpoint health --cluster\u0026#34; # 2 из 3 healthy - кворум есть # Вернуть master-2 ssh k3s@192.168.11.202 \u0026#34;sudo systemctl start k3s\u0026#34; Шаг 5: Добавить worker ноды # Worker ноды устанавливаются как agent - они не участвуют в etcd и не запускают control plane.\n5.1. Установить K3s agent на worker-1 # SSH на k3s-worker-1:\nssh k3s@192.168.11.210 Установка:\nexport K3S_TOKEN=\u0026#34;твой_сгенерированный_token\u0026#34; export INSTALL_K3S_VERSION=\u0026#34;v1.31.4+k3s1\u0026#34; curl -sfL https://get.k3s.io | sh -s - agent \\ --server=https://192.168.11.201:6443 Отличия от master:\nagent вместо server - режим worker Нет флагов --disable и --tls-san - они не нужны для worker Только --server - куда подключаться 5.2. Установить K3s agent на worker-2 # SSH на k3s-worker-2:\nssh k3s@192.168.11.211 Установка:\nexport K3S_TOKEN=\u0026#34;твой_сгенерированный_token\u0026#34; export INSTALL_K3S_VERSION=\u0026#34;v1.31.4+k3s1\u0026#34; curl -sfL https://get.k3s.io | sh -s - agent \\ --server=https://192.168.11.201:6443 Checkpoint: Все ноды в кластере # На любой master ноде:\nsudo k3s kubectl get nodes -o wide Ожидаемый результат:\nNAME STATUS ROLES AGE VERSION INTERNAL-IP k3s-master-1 Ready control-plane,etcd,master 10m v1.31.4+k3s1 192.168.11.201 k3s-master-2 Ready control-plane,etcd,master 8m v1.31.4+k3s1 192.168.11.202 k3s-master-3 Ready control-plane,etcd,master 6m v1.31.4+k3s1 192.168.11.203 k3s-worker-1 Ready \u0026lt;none\u0026gt; 2m v1.31.4+k3s1 192.168.11.210 k3s-worker-2 Ready \u0026lt;none\u0026gt; 1m v1.31.4+k3s1 192.168.11.211 Обрати внимание:\nMaster: роли control-plane,etcd,master Worker: роли \u0026lt;none\u0026gt; - только выполнение workloads Шаг 6: Настроить kubectl на локальной машине # Сейчас kubectl работает только на master нодах через sudo k3s kubectl. Настроим доступ с вашей рабочей машины.\n6.1. Скопировать kubeconfig # На локальной машине (не на ноде):\n# 1. Создать директорию mkdir -p ~/.kube # 2. Скопировать конфиг с master-1 scp k3s@192.168.11.201:/etc/rancher/k3s/k3s.yaml ~/.kube/config # 3. Заменить localhost на реальный IP sed -i \u0026#39;s/127.0.0.1/192.168.11.201/g\u0026#39; ~/.kube/config # Для macOS: # sed -i \u0026#39;\u0026#39; \u0026#39;s/127.0.0.1/192.168.11.201/g\u0026#39; ~/.kube/config # 4. Права доступа chmod 600 ~/.kube/config 6.2. Установить kubectl (если нет) # Linux:\ncurl -LO \u0026#34;https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl\u0026#34; sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl rm kubectl macOS:\nbrew install kubectl 6.3. Проверить подключение # # Версия kubectl version # Ноды kubectl get nodes # Все поды kubectl get pods -A Ожидаемый результат kubectl get pods -A:\nNAMESPACE NAME READY STATUS RESTARTS AGE kube-system coredns-xxx 1/1 Running 0 10m kube-system local-path-provisioner-xxx 1/1 Running 0 10m kube-system metrics-server-xxx 1/1 Running 0 10m Если kubectl не подключается:\nСимптом Причина Решение connection refused k3s не запущен ssh k3s@192.168.11.201 \u0026quot;sudo systemctl status k3s\u0026quot; connection timeout Firewall блокирует Проверь UFW на master: sudo ufw status | grep 6443 certificate signed by unknown authority Неправильный kubeconfig Скопируй заново с master ноды 6.4. Настроить автодополнение (опционально) # # Bash echo \u0026#39;source \u0026lt;(kubectl completion bash)\u0026#39; \u0026gt;\u0026gt; ~/.bashrc echo \u0026#39;alias k=kubectl\u0026#39; \u0026gt;\u0026gt; ~/.bashrc echo \u0026#39;complete -o default -F __start_kubectl k\u0026#39; \u0026gt;\u0026gt; ~/.bashrc source ~/.bashrc # Zsh echo \u0026#39;source \u0026lt;(kubectl completion zsh)\u0026#39; \u0026gt;\u0026gt; ~/.zshrc source ~/.zshrc Теперь работает k get nodes и Tab-автодополнение.\nФинальная проверка # Полный чеклист работоспособности кластера:\necho \u0026#34;=== Проверка K3s HA кластера ===\u0026#34; echo -n \u0026#34;1. Все ноды Ready: \u0026#34; [ $(kubectl get nodes --no-headers | grep -c \u0026#34;Ready\u0026#34;) -eq 5 ] \u0026amp;\u0026amp; echo \u0026#34;✓ (5/5)\u0026#34; || echo \u0026#34;✗\u0026#34; echo -n \u0026#34;2. Master ноды: \u0026#34; kubectl get nodes --no-headers | grep -c \u0026#34;control-plane\u0026#34; | xargs -I {} echo \u0026#34;✓ ({}/3)\u0026#34; echo -n \u0026#34;3. Worker ноды: \u0026#34; kubectl get nodes --no-headers | grep -c \u0026#34;\u0026lt;none\u0026gt;\u0026#34; | xargs -I {} echo \u0026#34;✓ ({}/2)\u0026#34; echo -n \u0026#34;4. etcd healthy: \u0026#34; kubectl exec -n kube-system \\ $(kubectl get pods -n kube-system -l component=etcd -o name | head -1) \\ -- etcdctl endpoint health --cluster 2\u0026gt;/dev/null | grep -c \u0026#34;is healthy\u0026#34; | xargs -I {} echo \u0026#34;✓ ({}/3)\u0026#34; echo -n \u0026#34;5. CoreDNS Running: \u0026#34; kubectl get pods -n kube-system -l k8s-app=kube-dns --no-headers | grep -q \u0026#34;Running\u0026#34; \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗\u0026#34; echo -n \u0026#34;6. Metrics Server Running: \u0026#34; kubectl get pods -n kube-system -l k8s-app=metrics-server --no-headers | grep -q \u0026#34;Running\u0026#34; \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗\u0026#34; echo \u0026#34;\u0026#34; echo \u0026#34;=== Информация о кластере ===\u0026#34; kubectl cluster-info Ожидаемый результат:\n=== Проверка K3s HA кластера === 1. Все ноды Ready: ✓ (5/5) 2. Master ноды: ✓ (3/3) 3. Worker ноды: ✓ (2/2) 4. etcd healthy: ✓ (3/3) 5. CoreDNS Running: ✓ 6. Metrics Server Running: ✓ === Информация о кластере === Kubernetes control plane is running at https://192.168.11.201:6443 CoreDNS is running at https://192.168.11.201:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy Metrics-server is running at https://192.168.11.201:6443/api/v1/namespaces/kube-system/services/https:metrics-server:https/proxy Тестовый деплой # Убедимся, что кластер может запускать приложения:\n# 1. Создать тестовый под kubectl run nginx-test --image=nginx:alpine --port=80 # 2. Подождать запуска kubectl wait --for=condition=Ready pod/nginx-test --timeout=60s # 3. Проверить на какой ноде запустился kubectl get pod nginx-test -o wide Ожидаемый результат:\nNAME READY STATUS RESTARTS AGE IP NODE nginx-test 1/1 Running 0 30s 10.42.1.5 k3s-worker-1 Под запустился на worker ноде - как и должно быть.\n# 4. Проверить доступность изнутри кластера kubectl run -it --rm debug --image=busybox --restart=Never -- wget -qO- nginx-test Ожидаемый результат: HTML-страница nginx.\n# 5. Удалить тестовые ресурсы kubectl delete pod nginx-test Troubleshooting # Нода не присоединяется к кластеру # Симптом: После установки нода не появляется в kubectl get nodes.\nПричина Диагностика Решение Неправильный token Логи: sudo journalctl -u k3s-agent -f Проверь token, переустанови Firewall блокирует curl -k https://192.168.11.201:6443 Открой порт 6443 на masters DNS не резолвит ping k3s-master-1 Проверь /etc/hosts или DNS etcd не формирует кворум # Симптом: etcdctl endpoint health показывает unhealthy.\n# Проверить членов etcd sudo k3s kubectl exec -n kube-system \\ $(sudo k3s kubectl get pods -n kube-system -l component=etcd -o name | head -1) \\ -- etcdctl member list --write-out=table Причина Диагностика Решение Порты 2379-2380 закрыты sudo ufw status sudo ufw allow 2379:2380/tcp Нода недоступна по сети ping 192.168.11.20X Проверь сеть, UFW etcd ещё стартует uptime на ноде Подожди 2-3 минуты Поды не запускаются на workers # Симптом: Все поды на master нодах, workers пустые.\n# Проверить taints kubectl describe node k3s-worker-1 | grep Taints Если есть taints - убрать:\nkubectl taint nodes k3s-worker-1 node.kubernetes.io/not-ready:NoSchedule- Откат: удаление K3s # Если нужно начать заново:\nНа master ноде:\nsudo /usr/local/bin/k3s-uninstall.sh На worker ноде:\nsudo /usr/local/bin/k3s-agent-uninstall.sh Что удаляется:\nБинарники K3s Systemd сервисы Данные из /var/lib/rancher/k3s/ etcd данные (на masters) Контейнеры и образы ⚠️ Внимание: etcd снапшоты тоже удаляются. Если нужен бэкап:\n# Перед удалением - сохранить снапшоты sudo cp -r /var/lib/rancher/k3s/server/db/snapshots/ ~/k3s-backup/ Итог # Что сделано:\n✅ Сгенерирован token для кластера ✅ Установлен K3s на 3 master ноды с embedded etcd ✅ Добавлены 2 worker ноды ✅ Настроен kubectl на локальной машине ✅ Проверена работоспособность кластера Что имеем:\nHA Control Plane - выдерживает падение 1 master ноды 2 worker ноды для приложений kubectl доступ с локальной машины Встроенные компоненты: CoreDNS, metrics-server, local-path storage Что ещё не настроено (следующие статьи):\nLoadBalancer (MetalLB) - для доступа к сервисам извне Ingress Controller (Traefik) - для HTTP/HTTPS routing SSL сертификаты (cert-manager) - для автоматического HTTPS Мониторинг (Prometheus/Grafana) - для наблюдения за кластером Что дальше # Кластер готов, но пока он изолирован от внешнего мира. Чтобы запускать реальные приложения с доступом извне, нужны:\nMetalLB - выдаёт IP-адреса для LoadBalancer сервисов Traefik - маршрутизирует HTTP/HTTPS трафик cert-manager - автоматически получает SSL сертификаты Это темы для следующей серии статей.\nА пока можно:\nПоэкспериментировать с kubectl Задеплоить тестовые приложения Изучить как работает scheduling между нодами ","date":"2 ноября 2025","externalUrl":null,"permalink":"/posts/k3s-part3-installation/","section":"Статьи","summary":"","title":"K3s HA для homelab: Ставим K3s HA кластер","type":"posts"},{"content":"","date":"2 ноября 2025","externalUrl":null,"permalink":"/series/k3s-ha-%D0%BA%D0%BB%D0%B0%D1%81%D1%82%D0%B5%D1%80-%D0%B4%D0%BB%D1%8F-homelab/","section":"Series","summary":"","title":"K3s HA Кластер Для Homelab","type":"series"},{"content":"","date":"21 октября 2025","externalUrl":null,"permalink":"/tags/infrastructure/","section":"Теги","summary":"","title":"Infrastructure","type":"tags"},{"content":"Архитектура спланирована, ресурсы посчитаны - пора создавать виртуальные машины. В этой статье подготовим 5 VM в Proxmox и настроим ОС так, чтобы K3s установился без сюрпризов.\nЗвучит просто? В теории - да. На практике: забытый swap, cgroup v1 вместо v2, закрытые порты firewall - и вы тратите час на отладку того, что должно было работать \u0026ldquo;из коробки\u0026rdquo;.\nРезультат: 5 VM (3 master + 2 worker) с Debian 12, настроенной сетью, отключённым swap и правильными параметрами ядра. SSH доступ работает, ноды видят друг друга.\nДля кого это # Подходит:\nПрочитал первую статью (или понимаешь архитектуру K3s HA) Есть Proxmox с 14+ vCPU и 56GB+ RAM свободных Умеешь работать в терминале Proxmox (или готов учиться) Не подходит:\nProxmox ещё не установлен - сначала разберись с ним Хочешь использовать LXC вместо VM - K3s в контейнерах работает, но с нюансами (не покрываем) Что понадобится # Компонент Значение Proxmox VE 7.x или 8.x Storage pool local-lvm или другой (минимум 200GB свободно) Сетевой bridge vmbr0 (или ваш) SSH-ключ Публичный ключ для доступа к VM ОС для VM Debian 12 cloud image Шаг 1: Скачать cloud image Debian 12 # Cloud image - готовый образ с поддержкой cloud-init. Не нужно проходить установщик вручную: задаёшь параметры (IP, пользователь, SSH-ключ) - VM стартует уже настроенной.\nНа Proxmox хосте (SSH или Shell в Web UI):\n# Перейти в директорию для образов cd /var/lib/vz/template/iso # Скачать Debian 12 cloud image wget https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2 # Проверить ls -lh debian-12-generic-amd64.qcow2 Ожидаемый результат:\n-rw-r--r-- 1 root root 521M ... debian-12-generic-amd64.qcow2 Если скачивание медленное: образ ~500MB, на слабом канале может занять время. Альтернатива - скачать на локальную машину и загрузить через Proxmox Web UI (Datacenter → Storage → Upload).\nШаг 2: Создать template VM # Template - шаблон VM, из которого будем клонировать все 5 нод. Настраиваем один раз, клонируем пять.\n2.1. Задать переменные # # Настрой под свою конфигурацию TEMPLATE_ID=9000 # ID для template (любой свободный) STORAGE=local-lvm # Твой storage pool BRIDGE=vmbr0 # Сетевой bridge SSH_KEY_PATH=~/.ssh/id_rsa.pub # Путь к публичному SSH-ключу Как узнать имя storage:\npvesm status Ожидаемый результат\nName Type Status Total Used Available % local dir active 229199360 9095552 220103808 3.97% local-lvm pool active 220103964 96 220103868 0.00% 2.2. Создать VM и импортировать диск # # 1. Создать пустую VM qm create $TEMPLATE_ID \\ --name debian-12-template \\ --memory 2048 \\ --cores 2 \\ --net0 virtio,bridge=$BRIDGE # 2. Импортировать скачанный образ как диск qm importdisk $TEMPLATE_ID \\ /var/lib/vz/template/iso/debian-12-generic-amd64.qcow2 \\ $STORAGE Ожидаемый результат:\nimporting disk \u0026#39;/var/lib/vz/template/iso/debian-12-generic-amd64.qcow2\u0026#39; to VM 9000 ... Successfully imported disk as \u0026#39;unused0:local-lvm:vm-9000-disk-0\u0026#39; 2.3. Настроить диск и загрузку # # 3. Подключить диск к VM qm set $TEMPLATE_ID \\ --scsihw virtio-scsi-pci \\ --scsi0 $STORAGE:vm-$TEMPLATE_ID-disk-0 # 4. Настроить загрузку и cloud-init qm set $TEMPLATE_ID \\ --boot c \\ --bootdisk scsi0 \\ --ide2 $STORAGE:cloudinit \\ --serial0 socket \\ --vga serial0 2.4. Настроить cloud-init # # 5. Пользователь, пароль, SSH-ключ qm set $TEMPLATE_ID \\ --ciuser k3s \\ --cipassword \u0026#34;ВашНадёжныйПароль\u0026#34; \\ --sshkeys $SSH_KEY_PATH \\ --ipconfig0 ip=dhcp # 6. Увеличить диск до 32GB (базовый размер для master) qm resize $TEMPLATE_ID scsi0 32G Замени:\nВашНадёжныйПароль - пароль для пользователя k3s (резервный доступ, если SSH не работает) $SSH_KEY_PATH - путь к твоему публичному ключу 2.5. Превратить в template # # 7. Конвертировать VM в template qm template $TEMPLATE_ID После этого VM 9000 станет шаблоном - её нельзя запустить, только клонировать.\nCheckpoint: Template создан # # Проверить что template существует qm list | grep template Ожидаемый результат:\n9000 debian-12-template stopped 2048 32.00 0 Если ошибка \u0026ldquo;disk import failed\u0026rdquo;:\nПроверь свободное место: pvesm status Проверь путь к образу: ls -l /var/lib/vz/template/iso/ Шаг 3: Клонировать master ноды # Теперь создаём 3 master ноды из template. Каждая получит свой IP, имя и ресурсы.\nTEMPLATE_ID=9000 # ───────────────────────────────────────────── # Master 1 # ───────────────────────────────────────────── qm clone $TEMPLATE_ID 201 --name k3s-master-1 --full qm set 201 --cores 2 --memory 8192 qm set 201 --ipconfig0 ip=192.168.11.201/24,gw=192.168.11.1 qm set 201 --nameserver 8.8.8.8 qm resize 201 scsi0 32G # ───────────────────────────────────────────── # Master 2 # ───────────────────────────────────────────── qm clone $TEMPLATE_ID 202 --name k3s-master-2 --full qm set 202 --cores 2 --memory 8192 qm set 202 --ipconfig0 ip=192.168.11.202/24,gw=192.168.11.1 qm set 202 --nameserver 8.8.8.8 qm resize 202 scsi0 32G # ───────────────────────────────────────────── # Master 3 # ───────────────────────────────────────────── qm clone $TEMPLATE_ID 203 --name k3s-master-3 --full qm set 203 --cores 2 --memory 8192 qm set 203 --ipconfig0 ip=192.168.11.203/24,gw=192.168.11.1 qm set 203 --nameserver 8.8.8.8 qm resize 203 scsi0 32G Параметры:\n--full - полное клонирование (не linked clone), VM независима от template --cores 2 --memory 8192 - 2 vCPU, 8GB RAM (как планировали) --ipconfig0 - статический IP через cloud-init --nameserver - DNS сервер (можешь указать свой) Адаптируй под свою сеть:\n192.168.11.0/24 → твоя подсеть 192.168.11.1 → твой gateway Шаг 4: Клонировать worker ноды # Workers получают больше ресурсов - здесь будут работать приложения.\n# ───────────────────────────────────────────── # Worker 1 # ───────────────────────────────────────────── qm clone $TEMPLATE_ID 210 --name k3s-worker-1 --full qm set 210 --cores 4 --memory 16384 qm set 210 --ipconfig0 ip=192.168.11.210/24,gw=192.168.11.1 qm set 210 --nameserver 8.8.8.8 qm resize 210 scsi0 50G # ───────────────────────────────────────────── # Worker 2 # ───────────────────────────────────────────── qm clone $TEMPLATE_ID 211 --name k3s-worker-2 --full qm set 211 --cores 4 --memory 16384 qm set 211 --ipconfig0 ip=192.168.11.211/24,gw=192.168.11.1 qm set 211 --nameserver 8.8.8.8 qm resize 211 scsi0 50G Отличия от master:\n4 vCPU вместо 2 16GB RAM вместо 8GB 50GB диск вместо 32GB Шаг 5: Запустить все VM # # Запустить все 5 VM for vmid in 201 202 203 210 211; do qm start $vmid echo \u0026#34;Запущена VM $vmid\u0026#34; sleep 3 done # Проверить статус qm list | grep k3s Ожидаемый результат:\n201 k3s-master-1 running 8192 32.00 12345 202 k3s-master-2 running 8192 32.00 12346 203 k3s-master-3 running 8192 32.00 12347 210 k3s-worker-1 running 16384 50.00 12348 211 k3s-worker-2 running 16384 50.00 12349 Checkpoint: VM работают # Подожди 1-2 минуты (cloud-init применяет настройки при первом запуске), затем проверь SSH:\n# Проверить доступность всех нод for ip in 192.168.11.201 192.168.11.202 192.168.11.203 192.168.11.210 192.168.11.211; do echo -n \u0026#34;Проверяю $ip... \u0026#34; ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no k3s@$ip \u0026#34;hostname\u0026#34; 2\u0026gt;/dev/null \u0026amp;\u0026amp; echo \u0026#34;OK\u0026#34; || echo \u0026#34;FAIL\u0026#34; done Ожидаемый результат:\nПроверяю 192.168.11.201... k3s-master-1 OK Проверяю 192.168.11.202... k3s-master-2 OK ... Если SSH не работает:\nСимптом Причина Решение Connection refused VM не загрузилась или SSH не запущен Открой консоль в Proxmox, проверь загрузку Connection timeout Неправильный IP или firewall Проверь IP в консоли: ip addr Permission denied Неправильный SSH-ключ Проверь ~/.ssh/authorized_keys на VM Host key verification failed Первое подключение Добавь -o StrictHostKeyChecking=no Шаг 6: Подготовить ОС на всех нодах # Теперь нужно настроить каждую ноду: обновить пакеты, отключить swap, настроить ядро. Команды одинаковые для всех 5 нод.\n6.1. Обновить систему # На каждой ноде (или через цикл):\n# Вариант 1: по одной ssh k3s@192.168.11.201 sudo apt update sudo apt upgrade -y sudo apt install -y curl wget vim htop iptables # Вариант 2: массово (с локальной машины) for ip in 192.168.11.{201..203} 192.168.11.{210..211}; do echo \u0026#34;=== Обновляю $ip ===\u0026#34; ssh k3s@$ip \u0026#34;sudo apt update \u0026amp;\u0026amp; sudo apt upgrade -y \u0026amp;\u0026amp; sudo apt install -y curl wget vim htop iptables\u0026#34; done 6.2. Отключить swap # Kubernetes не любит swap. При включённом swap поды ведут себя непредсказуемо - OOMKiller срабатывает не тогда, когда ожидаешь.\nНа всех нодах:\n# Отключить swap сейчас sudo swapoff -a # Отключить навсегда (закомментировать в fstab) sudo sed -i \u0026#39;/swap/s/^/#/\u0026#39; /etc/fstab # Проверить free -h | grep Swap Ожидаемый результат:\nSwap: 0B 0B 0B 6.3. Загрузить kernel-модули # K3s использует overlay filesystem и bridge netfilter. Без этих модулей - ошибки при старте.\nНа всех нодах:\n# Загрузить модули sudo modprobe overlay sudo modprobe br_netfilter # Настроить автозагрузку cat \u0026lt;\u0026lt;EOF | sudo tee /etc/modules-load.d/k3s.conf overlay br_netfilter EOF # Проверить lsmod | grep -E \u0026#39;overlay|br_netfilter\u0026#39; Ожидаемый результат:\noverlay 151552 0 br_netfilter 32768 0 6.4. Настроить sysctl # Параметры для сетевого взаимодействия между подами.\nНа всех нодах:\n# Создать конфиг cat \u0026lt;\u0026lt;EOF | sudo tee /etc/sysctl.d/k3s.conf net.ipv4.ip_forward = 1 net.bridge.bridge-nf-call-iptables = 1 net.bridge.bridge-nf-call-ip6tables = 1 EOF # Применить sudo sysctl --system # Проверить sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables Ожидаемый результат:\nnet.ipv4.ip_forward = 1 net.bridge.bridge-nf-call-iptables = 1 6.5. Проверить cgroup v2 # Debian 12 по умолчанию использует cgroup v2 - просто проверим.\nmount | grep cgroup Ожидаемый результат (cgroup v2):\ncgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot) Если видишь tmpfs on /sys/fs/cgroup type tmpfs - это cgroup v1. Нужно включить v2:\n# Добавить параметр ядра sudo sed -i \u0026#39;s|^GRUB_CMDLINE_LINUX_DEFAULT=\u0026#34;\\(.*\\)\u0026#34;|GRUB_CMDLINE_LINUX_DEFAULT=\u0026#34;\\1 systemd.unified_cgroup_hierarchy=1\u0026#34;|\u0026#39; /etc/default/grub # Обновить GRUB sudo update-grub # Перезагрузить sudo reboot # После reboot проверить mount | grep cgroup2 Шаг 7: Настроить firewall # UFW - простой интерфейс к iptables. Откроем только нужные порты.\n7.1. На master нодах (201, 202, 203) # # Установить UFW sudo apt install -y ufw # Базовые правила sudo ufw default deny incoming sudo ufw default allow outgoing # SSH (чтобы не потерять доступ) sudo ufw allow 22/tcp # Kubernetes API sudo ufw allow 6443/tcp # etcd (между masters) sudo ufw allow 2379:2380/tcp # Kubelet sudo ufw allow 10250/tcp # Flannel VXLAN sudo ufw allow 8472/udp # Включить sudo ufw --force enable # Проверить sudo ufw status 7.2. На worker нодах (210, 211) # sudo apt install -y ufw sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow 22/tcp # SSH sudo ufw allow 10250/tcp # Kubelet sudo ufw allow 8472/udp # Flannel VXLAN sudo ufw --force enable sudo ufw status Шаг 8: Настроить /etc/hosts # Не обязательно, но удобно - ноды смогут обращаться друг к другу по имени.\nНа всех нодах:\ncat \u0026lt;\u0026lt;EOF | sudo tee -a /etc/hosts # K3s Cluster 192.168.11.201 k3s-master-1 192.168.11.202 k3s-master-2 192.168.11.203 k3s-master-3 192.168.11.210 k3s-worker-1 192.168.11.211 k3s-worker-2 EOF Проверить:\nping -c 1 k3s-master-2 Финальная проверка # Перед переходом к установке K3s убедись, что всё готово. Запусти на любой ноде:\necho \u0026#34;=== Проверка готовности ноды ===\u0026#34; echo -n \u0026#34;1. Swap отключён: \u0026#34; [ $(free | grep Swap | awk \u0026#39;{print $2}\u0026#39;) -eq 0 ] \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;2. Модуль overlay: \u0026#34; lsmod | grep -q overlay \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;3. Модуль br_netfilter: \u0026#34; lsmod | grep -q br_netfilter \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;4. IP forwarding: \u0026#34; [ $(sysctl -n net.ipv4.ip_forward) -eq 1 ] \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;5. bridge-nf-call-iptables: \u0026#34; [ $(sysctl -n net.bridge.bridge-nf-call-iptables) -eq 1 ] \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;6. cgroup v2: \u0026#34; mount | grep -q \u0026#34;cgroup2\u0026#34; \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;7. UFW активен: \u0026#34; sudo ufw status | grep -q \u0026#34;Status: active\u0026#34; \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; echo -n \u0026#34;8. Пинг k3s-master-1: \u0026#34; ping -c 1 -W 1 k3s-master-1 \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 \u0026amp;\u0026amp; echo \u0026#34;✓\u0026#34; || echo \u0026#34;✗ ОШИБКА\u0026#34; Ожидаемый результат:\n=== Проверка готовности ноды === 1. Swap отключён: ✓ 2. Модуль overlay: ✓ 3. Модуль br_netfilter: ✓ 4. IP forwarding: ✓ 5. bridge-nf-call-iptables: ✓ 6. cgroup v2: ✓ 7. UFW активен: ✓ 8. Пинг k3s-master-1: ✓ Если где-то ✗ - вернись к соответствующему шагу.\nTroubleshooting # Симптом Причина Решение VM не получает IP cloud-init не отработал Проверь консоль, жди 2-3 минуты, перезагрузи VM SSH connection refused sshd не запущен Открой консоль, проверь systemctl status ssh Swap не отключается Строка не закомментирована в fstab cat /etc/fstab, проверь swap строку, reboot cgroup v1 после reboot GRUB не обновился Проверь /proc/cmdline, повтори update-grub UFW блокирует всё Забыл разрешить SSH до включения Через консоль Proxmox: ufw allow 22/tcp Ноды не пингуются UFW или неправильный IP Проверь ip addr, проверь правила UFW Итог # Что сделано:\n✅ Скачан Debian 12 cloud image ✅ Создан template VM с cloud-init ✅ Склонированы 5 VM (3 master + 2 worker) ✅ Настроены статические IP ✅ Подготовлена ОС (swap, modules, sysctl, cgroup v2) ✅ Настроен firewall с нужными портами ✅ SSH работает на все ноды Что дальше:\n👉 Следующая статья: \u0026ldquo;Установить K3s HA кластер\u0026rdquo;\nТам мы:\nСгенерируем token для кластера Установим K3s на первую master ноду Добавим ещё 2 master ноды (HA) Подключим worker ноды Настроим kubectl Проверим работу кластера ","date":"21 октября 2025","externalUrl":null,"permalink":"/posts/k3s-part2-infrastructure/","section":"Статьи","summary":"","title":"K3s HA для homelab: Готовим инфраструктуру в Proxmox","type":"posts"},{"content":"","date":"21 октября 2025","externalUrl":null,"permalink":"/tags/proxmox/","section":"Теги","summary":"","title":"Proxmox","type":"tags"},{"content":"","date":"14 октября 2025","externalUrl":null,"permalink":"/tags/architecture/","section":"Теги","summary":"","title":"Architecture","type":"tags"},{"content":"Kubernetes слишком тяжёлый, Docker Swarm мёртв, а хочется нормальный кластер для экспериментов. Знакомо? K3s решает эту проблему - полноценный Kubernetes в бинарнике на 50MB вместо 1.5GB зависимостей. Но без правильного планирования вы получите нестабильную конструкцию, которая падает в самый неподходящий момент.\nВ этой статье разберём архитектуру K3s HA кластера: почему именно 3 master ноды, зачем embedded etcd и сколько ресурсов закладывать. В конце - готовый план для установки.\nРезультат: понимание архитектуры + таблица ресурсов + сетевая схема. Всё, что нужно перед тем, как создавать VM.\nДля кого это # Подходит:\nЗнаком с базовыми концепциями Kubernetes (pod, service, deployment) Есть Proxmox с 14+ vCPU и 56GB+ RAM Хочешь понять что устанавливать, прежде чем устанавливать Не подходит:\nНужна одна нода для экспериментов - достаточно docker-compose или K3s single-node Ищешь managed Kubernetes для бизнеса - смотри в сторону Yandex Cloud или VK Cloud Хочешь сразу команды без теории - переходи к статье 2 K3s vs Kubernetes: в чём разница # Kubernetes (K8s) - оркестратор контейнеров, стандарт индустрии. Добро пожаловать в enterprise, где для запуска трёх контейнеров нужно поддерживать шесть виртуальных машин.\nK3s - тот же Kubernetes, но кто-то в Rancher (теперь SUSE) задумался: \u0026ldquo;А что если выкинуть всё, что нужно только Сберу и Yandex Cloud?\u0026rdquo;\nKubernetes K3s Что выкинули:\nИнтеграции с облачными провайдерами (вы же не в VK Cloud) Legacy API (вы же не мигрируете кластер 2016 года) Встроенные драйверы хранилищ на все случаи жизни (вы же не используете 47 типов СХД) Альфа/бета-функции (нестабильные эксперименты) Что осталось: полноценный Kubernetes, сертифицированный CNCF (Cloud Native Computing Foundation - организация, которая решает, что считать \u0026ldquo;настоящим\u0026rdquo; Kubernetes). Все манифесты работают. Helm работает. kubectl работает. Ответы со StackOverflow работают.\nСравнение в цифрах # Характеристика Kubernetes K3s Размер ~1.5GB образы 50MB бинарник RAM на control plane ~2GB на ноду ~500MB на ноду Установка kubeadm, 10+ шагов один curl-скрипт etcd Отдельный кластер (3+ VM) Встроенный CNI Нужно устанавливать Flannel из коробки Совместимость 100% 100% \u0026ldquo;Но я потеряю гибкость!\u0026rdquo; - скажете вы. Да, вы не сможете заменить сетевой плагин Flannel без пересборки. Это критично примерно для одного проекта из тысячи, и ваш homelab в их число не входит.\nВердикт: для homelab K3s - очевидный выбор. Теряем 5% гибкости, получаем 90% простоты.\nЧто такое High Availability и зачем оно вам # HA (High Availability) - способность системы продолжать работу при отказе компонентов. Звучит как enterprise-термин для больших компаний? На практике это разница между \u0026ldquo;кластер упал в субботу, но я починил в понедельник\u0026rdquo; и \u0026ldquo;кластер сам пережил падение ноды, пока я спал\u0026rdquo;.\nБез HA (single node) # ┌─────────────────┐ │ K3s Master │ ← Единственная точка отказа │ + Worker │ └─────────────────┘ Нода упала → кластер мёртв → ваши сервисы недоступны С HA (3+ master nodes) # ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Master 1 │ │ Master 2 │ │ Master 3 │ │ + etcd │ │ + etcd │ │ + etcd │ └──────────┘ └──────────┘ └──────────┘ ↓ ↓ ↓ ┌──────────┐ ┌──────────┐ │ Worker 1 │ │ Worker 2 │ └──────────┘ └──────────┘ Одна master упала → кластер работает Один worker упал → поды переехали на другой Сколько master нод нужно # Вот тут начинается интересное. Интуиция подсказывает: одна нода - плохо, две - уже лучше. Логично? Логично. И неправильно.\nMaster нод Выдержит отказов Кворум Вердикт 1 0 1/1 Нет HA, но честно 2 0 Ловушка! ⛔ Хуже, чем 1 3 1 2/3 ✅ Минимум для HA 5 2 3/5 Для критичных систем Почему 2 master ноды хуже, чем 1 # etcd (база данных кластера, где хранится вообще всё) работает по принципу голосования. Чтобы записать данные, нужно согласие большинства нод. Не \u0026ldquo;хотя бы одной\u0026rdquo; - именно большинства.\nСчитаем:\n1 нода: большинство = 1. Упала - кластер мёртв. Честная игра, вы знали на что шли. 2 ноды: большинство = 2. Упала одна - кворума нет, кластер мёртв. Сюрприз! 3 ноды: большинство = 2. Одна упала - две оставшиеся продолжают работать. Это как договор, требующий подписи обоих директоров - заболел один, и компания парализована.\nС двумя нодами вы не получили отказоустойчивость. Вы удвоили количество точек отказа и назвали это \u0026ldquo;высокой доступностью\u0026rdquo;.\nПравило: или 1 нода (и честное понимание рисков), или 3+ (и настоящий HA). Двойка - ловушка для тех, кто не дочитал документацию.\nEmbedded etcd vs External etcd # etcd - распределённое key-value хранилище. Единственный источник истины для всего состояния Kubernetes: все объекты (поды, сервисы, секреты), конфигурации, сетевые политики. Без etcd кластер не работает. Точка.\nЕсть два варианта архитектуры:\nExternal etcd (классический Kubernetes) # Control Plane (3 VM) etcd кластер (3 VM) ┌────────────────┐ ┌──────────────┐ │ API Server │ │ etcd-1 │ │ Scheduler │ ──────────────\u0026gt;│ (только etcd)│ │ Controller │ └──────────────┘ └────────────────┘ ┌──────────────┐ ┌────────────────┐ │ etcd-2 │ │ API Server │ ──────────────\u0026gt;│ (только etcd)│ │ Scheduler │ └──────────────┘ │ Controller │ ┌──────────────┐ └────────────────┘ │ etcd-3 │ ┌────────────────┐ │ (только etcd)│ │ API Server │ ──────────────\u0026gt;└──────────────┘ │ Scheduler │ │ Controller │ └────────────────┘ Итого: 6 виртуальных машин Embedded etcd (K3s) # ┌─────────────────────────┐ │ K3s Master 1 │ │ ┌───────────────────┐ │ │ │ API + Scheduler │ │ │ │ + Controller │ │ │ └───────────────────┘ │ │ ┌───────────────────┐ │ │ │ etcd (встроенный) │◄─┼──┐ │ └───────────────────┘ │ │ └─────────────────────────┘ │ Raft protocol ┌─────────────────────────┐ │ (синхронизация) │ K3s Master 2 │ │ │ etcd ◄────────────────────┤ └─────────────────────────┘ │ ┌─────────────────────────┐ │ │ K3s Master 3 │ │ │ etcd ◄────────────────────┘ └─────────────────────────┘ Итого: 3 виртуальные машины Сравнение подходов # Критерий External etcd Embedded etcd Количество VM 6 (3 master + 3 etcd) 3 (всё вместе) Сложность настройки Высокая Один флаг --cluster-init Сложность обновления Отдельно etcd и K8s Одна команда Производительность Чуть лучше Достаточно для homelab Масштаб \u0026gt;500 нод До 100-200 нод Для homelab embedded etcd - очевидный выбор. Теряем 5-10% производительности etcd, экономим 3 VM и часы настройки.\n\u0026ldquo;А если мне понадобится масштаб?\u0026rdquo; - официально embedded etcd поддерживает до 100 нод и 5000 подов. Для homelab это как ограничение скорости 300 км/ч на велосипеде.\nЗачем отдельные worker ноды # Worker ноды - машины для запуска ваших приложений (подов). На них не запускаются компоненты control plane.\n\u0026ldquo;А можно запускать приложения прямо на master нодах?\u0026rdquo;\nТехнически - да. K3s не ставит ограничений на master ноды (в отличие от обычного Kubernetes). Но это плохая идея:\nControl plane должен быть стабильным. Ваше приложение съело всю память → API server упал → кластер недоступен. etcd чувствителен к диску. База данных на той же ноде создаёт I/O нагрузку → etcd тормозит → весь кластер тормозит. Изоляция отказов. Проблема с приложением не должна убивать control plane. 2 worker ноды - минимум для HA приложений:\nМожно запускать 2 реплики (на разных нодах) При падении одного worker\u0026rsquo;а второй держит нагрузку Легко добавить третью, четвёртую ноду потом Архитектура нашего кластера # Вот что мы будем строить:\nКлючевые моменты:\nВсе master ноды равны - нет \u0026ldquo;главной\u0026rdquo;, kubectl подключается к любой. etcd синхронизируется через Raft - алгоритм консенсуса, гарантирует согласованность данных. Workers знают только про API - они не подключаются к etcd напрямую. Flannel создаёт overlay-сеть - все поды получают IP из 10.42.0.0/16, видят друг друга. Планирование ресурсов # Таблица VM # Hostname VM ID IP vCPU RAM Disk Роль k3s-master-1 201 192.168.11.201 2 8GB 32GB Control Plane + etcd k3s-master-2 202 192.168.11.202 2 8GB 32GB Control Plane + etcd k3s-master-3 203 192.168.11.203 2 8GB 32GB Control Plane + etcd k3s-worker-1 210 192.168.11.210 4 16GB 50GB Workloads k3s-worker-2 211 192.168.11.211 4 16GB 50GB Workloads Итого - - 14 56GB 196GB - Почему именно такие ресурсы # Master ноды (2 vCPU / 8GB RAM / 32GB Disk):\nРеальное потребление в idle:\nAPI server: ~200-300MB RAM etcd: ~100-200MB RAM (растёт со временем) Scheduler + Controller: ~150MB RAM Системные поды: ~100-200MB RAM Итого: ~600-900MB используется \u0026ldquo;Зачем тогда 8GB?\u0026rdquo; - запас для burst-нагрузки. Когда вы деплоите 50 подов одновременно, API server временно съедает больше. etcd при большом кластере может вырасти до 1-2GB. Golang GC работает лучше с запасом памяти.\nWorker ноды (4 vCPU / 16GB RAM / 50GB Disk):\nЗдесь будут ваши приложения. При 16GB можно запустить:\n5-10 средних приложений (256MB-2GB каждое) Или 2-3 базы данных (PostgreSQL любит память) Или комбинацию 50GB диска - под образы контейнеров (10-20GB), логи (5-10GB), временные данные.\nМожно ли меньше? # Минимальная конфигурация (для экспериментов):\nMaster: 1 vCPU / 4GB RAM / 20GB Disk Worker: 2 vCPU / 8GB RAM / 30GB Disk Итого: 9 vCPU / 36GB RAM Риски:\nМедленная работа API server OOM killer при нагрузке Нет запаса для burst Для production-like homelab рекомендую таблицу выше. Комфортный запас стоит дешевле, чем отладка странных падений.\nСетевая схема # IP-адреса (адаптируй под свою сеть) # 192.168.11.0/24 - Локальная сеть 192.168.11.1 - Gateway (роутер) 192.168.11.201-203 - Master ноды 192.168.11.210-211 - Worker ноды 192.168.11.220-230 - Резерв для MetalLB (статья 2) Kubernetes внутренние сети (создаются автоматически) # 10.42.0.0/16 - Pod network (Flannel VXLAN overlay) 10.43.0.0/16 - Service network (ClusterIP) 10.43.0.10 - CoreDNS Порты между нодами # Порт Протокол Направление Назначение 6443 TCP Master ← Worker Kubernetes API 2379-2380 TCP Master ↔ Master etcd (client + peer) 10250 TCP Master ↔ All Kubelet API 8472 UDP All ↔ All Flannel VXLAN Требования к железу и софту # Железо (Proxmox хост) # Минимум:\nCPU: 14 vCPU свободных RAM: 56GB свободных Disk: 200GB на SSD Network: 1 Gbit Рекомендуется:\nCPU: 18+ vCPU (запас для приложений) RAM: 64GB+ (базы данных прожорливые) Disk: NVMe для etcd Network: 2.5 Gbit (для NFS, если будете использовать) Софт # Компонент Версия Proxmox VE 7.x или 8.x K3s v1.31+ (stable) ОС на нодах Debian 12 или Ubuntu 22.04+ Kernel 5.15+ (для cgroup v2) Итог # Что мы спроектировали:\n5 VM: 3 master + 2 worker K3s с embedded etcd (HA без лишних VM) Отказоустойчивость: выдерживает падение 1 master и любого worker Ресурсы: 14 vCPU / 56GB RAM / 196GB Disk Что НЕ входит в эту серию (отдельные статьи):\nLoadBalancer (MetalLB) Ingress (Traefik) SSL (cert-manager) Мониторинг (Prometheus/Grafana) Что дальше # 👉 \u0026ldquo;Подготовить инфраструктуру для K3s в Proxmox\u0026rdquo;\nТам мы:\nСоздадим template VM с Debian 12 Склонируем 5 VM с правильными ресурсами Настроим статические IP Подготовим ОС (swap, cgroup v2, firewall) ","date":"14 октября 2025","externalUrl":null,"permalink":"/posts/k3s-part1-architecture/","section":"Статьи","summary":"","title":"K3s HA для homelab: архитектура без боли","type":"posts"},{"content":"Привет! Меня зовут Олег. За 20 лет в IT проблемы росли вместе со мной: от «почему не печатает принтер» до «почему три сервера не могут договориться, кто из них главный». Техподдержка научила терпению - там быстро понимаешь, что «не работает» может означать что угодно, от выключенного монитора до сбоя на другом континенте. Сети научили начинать диагностику с розетки, а не с теории заговора. Системное администрирование - что между «всё работает» и «всё сломалось» обычно стоит один непрочитанный warning в логах трёхнедельной давности.\nСейчас я инфраструктурный инженер. Строю и чиню серверную инфраструктуру: много enterprise, ещё больше Open Source, изрядно импортозамещённого - в общем, всё что работает на Linux, с Linux и благодаря Linux. Работаю в Главгосэкспертизе России. Общаюсь с серверами на Bash, Python и прочих машинопонятных языках. Иногда серверы даже отвечают. Живу в Москве.\nДома - лаборатория. Нет, не стойка в подвале (хотя идея заманчивая). Один сервер, но серьёзный: виртуализация, Kubernetes, хранилище, мониторинг - всё по-взрослому, но если что-то упало - нет многомиллионных издержек и мир не остановился. Идеальный полигон: придумал, реализовал, сломал, починил, задокументировал. Полный цикл.\nЭтот блог - не enterprise-гайды для внедрения в банке. Это production, но в человеческом масштабе: домашний сервер, небольшая компания, стартап на трёх разработчиках. Решения, которые реально работают - просто без бюджета на команду SRE из десяти человек - инженеров надёжности, чьи зарплаты съедают бюджет стартапа за квартал. Документирую грабли, чтобы вы на них не наступали. Чем больнее наступил сам - тем подробнее статья.\n","date":"1 октября 2025","externalUrl":null,"permalink":"/about/","section":"Олег Казанин","summary":"","title":"Кто это пишет и зачем","type":"page"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"}]