Make. Build. Break. Reflect.
969 subscribers
122 photos
1 video
1 file
130 links
Полезные советы, всратые истории, странные шутки и заметки на полях от @kruchkov_alexandr
Download Telegram
#cloudfront #aws #troubleshooting #одинденьизжизни

Однажды меня попросили сделать перенос старого SPA (Single Page Application)-приложения на новый S3 bucket в CloudFront.
Но не просто перенос, а хитрый: часть путей должна идти на новое приложение, а часть - оставаться на старом.
Legacy страницы типа /welcome, /login-start, /login-end пока не переписаны в новом коде, их надо сохранить.
А всё остальное - на свежий app-v2.
По сути частичная миграция на новое приложение, не ломая старое.

Казалось бы, что может пойти не так? 😁

Задача звучит просто:
- /* > новый bucket (app-v2)
- /welcome*, /login-start*, /login-end*, /help-bot* > старый bucket
- /assets/*, /sdk.js > старый bucket

Открываю Terraform, добавляю новый origin для app-v2 bucket.
Меняю default_cache_behavior на новый origin.
Добавляю ordered_cache_behavior для legacy путей на старый bucket.
Terraform plan - всё как задумано.
Apply. Жду. Готово.

Иду проверять.
- https://stage-app.example.com/ - работает, новое приложение грузится.
- https://stage-app.example.com/?appId=xxx - работает, редиректит.
- https://stage-app.example.com/welcome - ...

"Server Error. An unexpected error happened."


Чо. 🤡

Первая мысль - кэш CloudFront. Делаю invalidation. Жду. Проверяю.
Та же херня.

Вторая мысль - может origin неправильный?
Проверяю напрямую S3 bucket:
curl -I "http://stage-app.example.com.s3-website-us-east-1.amazonaws.com/index.html"
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 4755

Bucket работает. index.html есть. 4755 байт. Всё ок.

Третья мысль - может custom_error_response мешает?
В CloudFront есть глобальный обработчик 404 ошибок, который возвращает /index.html.
Думаю: "А, наверное S3 возвращает 404, а CloudFront берёт index.html с дефолтного origin!"
Убираю custom_error_response. Apply. Проверяю.
Та же херня. Даже хуже - теперь вместо "Server Error" просто "Not found".

Возвращаю custom_error_response. Сижу, чешу репу.

Ладно, поехали курлить по-взрослому.
curl -sI "https://stage-app.example.com/welcome"
HTTP/2 200
content-type: text/html
x-cache: Hit from cloudfront

Стоп. 200? Не 404? CloudFront отдаёт 200 и HTML?
Смотрю body:
curl -s "https://stage-app.example.com/welcome" | head -20

Да, это HTML старого приложения. webpackJsonstatham@app/web. Всё верно.
Так почему "Server Error" в браузере?

Если HTML грузится, значит проблема в JavaScript.
Приложение падает после загрузки.
Открываю DevTools > Network.

И тут я вижу ЭТО:
/static/js/main.a1b2c3d4.chunk.js → content-type: text/html 
/static/css/main.e5f6g7h8.chunk.css → content-type: text/html

JS файлы возвращают HTML вместо JavaScript.
Браузер пытается выполнить HTML как JavaScript.
Приложение падает с "Server Error".
Сука. 😁

Проверяю напрямую S3:
curl -sI "http://stage-app.example.com.s3-website-us-east-1.amazonaws.com/static/js/main.a1b2c3d4.chunk.js"
HTTP/1.1 200 OK
Content-Type: text/javascript
Content-Length: 1080675

S3 отдаёт правильно! 1MB JavaScript!

А через CloudFront:
curl -sI "https://stage-app.example.com/static/js/main.a1b2c3d4.chunk.js"
HTTP/2 200
content-type: text/html
x-cache: Error from cloudfront

CloudFront отдаёт HTML. И x-cache: Error from cloudfront. Красота.

Теперь понятно что происходит.
Сажусь рисовать на планшете путь запроса (а я всегда рисую).

Проблема:
1. Браузер > /welcome
2. CloudFront матчит /welcome* > origin: OLD bucket
3. S3 не находит файл /welcome (это SPA route, не файл)
4. S3 возвращает error_document = index.html
5. Браузер получает HTML старого приложения

6. HTML содержит: <script src="./static/js/main.a1b2c3d4.chunk.js">
7. Браузер резолвит ./static/... относительно /welcome
8. Браузер запрашивает /static/js/main.a1b2c3d4.chunk.js

9. CloudFront матчит /* (default) > origin: app-v2 bucket
10. В app-v2 bucket НЕТ файла main.a1b2c3d4.chunk.js!
11. S3 возвращает 404
12. CloudFront custom_error_response > /index.html
13. Браузер получает index.html (HTML) вместо JavaScript
14. JavaScript парсер: "че за херня, это не JS"
15. Приложение: "Server Error"🐒
Please open Telegram to view this post
VIEW IN TELEGRAM
👍8
#cloudfront #aws #troubleshooting #одинденьизжизни

Вот оно.
Два разных SPA с разными static assets в одном CloudFront distribution.
Legacy paths идут на старый bucket, но их ./static/* резолвится как /static/* и летит на default origin - новый bucket.
А там этих файлов нет.

Классика жанра: симптомы в одном месте, причина в другом.
Ошибка "Server Error" от JavaScript, а проблема в CloudFront routing.
Логи чистые, метрики зелёные, status code 200 - всё отлично, только ничего не работает.

Для визуалов:
ДО (работало):
══════════════════════════════════════════
CloudFront


┌─────────┐
│ OLD S3 │ ← всё идёт сюда
│ bucket │
└─────────┘

ПОСЛЕ (сломалось):
══════════════════════════════════════════
CloudFront

┌───────────┴───────────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ OLD S3 │ │ NEW S3 │ ← default
│ bucket │ │ bucket │
└─────────┘ └─────────┘
/welcome* /* (всё остальное)
/login-* включая /static/*


Ну или вот так, что не работало (красиво только на ПК):
  Browser                     CloudFront                        S3
| | |
| GET /welcome | |
|------------- req ---------->| |
| | /welcome* --> OLD bucket |
| |------------- req ---------->| OLD bucket
| | | (app-v1)
| |<------------ res -----------| index.html
|<----------- res ------------| HTML + <script> |
| | |
| GET /static/js/main.js | |
|------------- req ---------->| |
| | /* (default) --> NEW |
| |------------- req ---------->| NEW bucket
| | | (app-v2)
| | |
| | main.js NOT FOUND! |
| |<------------ 404 -----------|
| | |
| | custom_error_response |
| | 404 --> /index.html |
| |------------- req ---------->| NEW bucket
| |<------------ res -----------| index.html
|<----------- res ------------| HTML (not JS!) WRONG! | (app-v2!)
| | |
| "Server Error" | |
| JS parser: WTF?! | |


Мораль:
Нельзя без дополнительной логики безопасно смешать два SPA с разными static assets в одном CloudFront distribution, если они используют пересекающиеся пути (/static/*) и простой path-based routing.
Относительные пути (./static/...) превращаются в абсолютные (/static/...) и летят на default origin.
И не всегда виноват девопс, что криво пилит редирект или бихейвиор. 😀

Решения:
- отдельные CloudFront distributions для старого и нового приложения
- Cloudflare Workers / Lambda@Edge для умного роутинга
- добавить /static/* behavior на старый bucket (но тогда сломается новое приложение)
- не переключать default, тестировать новое приложение на отдельном домене

Я выбрал вариант 4 (вроде, уже не помню) и пошёл пить кофе.

А RFC переписали, убрав требование "переключить трафик через один CloudFront".
Иногда лучшее решение - не решать задачу в лоб.
Please open Telegram to view this post
VIEW IN TELEGRAM
9👍4
Make. Build. Break. Reflect.
#пятница #байки #troubleshooting Задолго до работы в айти я был простым советским инженером спутниковой связи. Тарелки, антенны, модемы, кабели, бесконечные командировки. В то время случалось масса странных историй. Мы разрабатывали спутниковое оборудование…
#байки #пятница #tcp #networking

Когда-то давно я работал в компании, которая делала железки для спутниковой связи.
Не те, что вы вешаете на дачу для ТВ, а серьёзные штуки - морские терминалы VSAT, Inmarsat-модемы для судов, станции для нефтянки в тайге.
Короче, интернет там, где провода не дотягиваются.

И вот первое, что узнаёшь в этой индустрии:
всё, что ты знал о сетях - забудь. 🙃

⚠️ Важно: всё, что ниже - про геостационарные спутники (GEO).
Это классический VSAT, Inmarsat, морская и промышленная связь.

Starlink, OneWeb и прочие LEO-созвездия - совсем другая история.
Там спутники летают на 550 км вместо 36 000, RTT составляет 20-40 мс, и проблемы с TCP практически отсутствуют.
Но это технология 2020-х, а я работал в эпоху, когда GEO был единственным вариантом.


Геостационарный спутник висит на высоте ~36 000 км.
Сигнал от твоего модема до спутника и обратно - это примерно 72 000 км.
Дальше сигнал летит на наземную станцию (HUB), там обрабатывается, и только потом идёт в интернет.
А ответ - всё в обратном порядке.
                        🛰 СПУТНИК
(36 000 км)
/ \
/ \
↗️ / \ ↘️
/ ~36 000 км \
/ в каждую \
/ сторону \
/ \
┌──────────────┐ ┌──────────────┐
│ ТВОЙ МОДЕМ │ │ HUB │
│ (корабль, │ │ (наземная │
│ платформа) │ │ станция) │
└──────────────┘ └──────┬───────┘

│ ③ оптика

┌──────────────┐
│ ИНТЕРНЕТ │
└──────────────┘


Суммарное расстояние: ~144 000 км (минимум, на экваторе).
Скорость света в вакууме: 299 792 км/с.
RTT = 144000 / 299792 ≈ 480 мс (только свет!)


Добавь обработку на спутнике, на HUB'е, в модеме - получаешь 600-800 мс RTT.
И это не баг. Это физика. Скорость света не обманешь.

Классический звонок от клиента:
- Алло, вы нам поставили модем на 10 мегабит, а у нас файлы качаются со скоростью 500 килобит!
- А какой размер файла?
- 100 мегабайт!
- А пинг какой?
- 700 миллисекунд...
- (вздох) Сейчас объясню.


И начинается лекция по TCP, которую никто не хотел слышать.
Но вам я расскажу.

Bandwidth-Delay Product: труба, которую надо заполнить
TCP работает так: отправил данные > ждёшь ACK > отправляешь ещё.
Сколько данных можно "запихнуть" в сеть до получения первого ACK, ограничено TCP Window.

А сколько данных должно лететь одновременно, чтобы канал был загружен?
Это называется BDP (Bandwidth-Delay Product):
BDP = Bandwidth × RTT

Считаем для нашего случая (все каналы 10 Мбит/с):
LAN (RTT 1 мс):
BDP = 10 000 бит ≈ 1.25 КБ
WAN (RTT 50 мс):
BDP = 500 000 бит ≈ 62.5 КБ
Спутник (RTT 700 мс):
BDP = 7 000 000 бит ≈ 875 КБ

Видишь разницу?

Чтобы полностью загрузить 10 Мбит спутниковый канал, надо держать почти мегабайт данных "в полёте" одновременно.
А дефолтное TCP окно в старой винде - 64 КБ. 😭

Математика боли
Максимальный throughput TCP (без потерь) приблизительно равен:
Throughput ≈ Window Size / RTT

Подставляем:
Window = 64 КБ = 524 288 бит
RTT = 0.7 с
Throughput = 524288 / 0.7 ≈ 749 000 бит/с ≈ 730 Кбит/с

730 Кбит/с на 10 Мбит канале.

Семь процентов утилизации.

Клиент платит за 10 мегабит, а получает 730 килобит.
И это не наш модем плохой. Это TCP.
Хотя клиент думает обратно. 😀

А давайте потюним TCP!
Конечно, никто ж до этого не догадался же, ага.
Ладно, мы ж не дураки. Знаем про sysctl, знаем про window scaling.

На Linux:
sysctl -w net.core.rmem_max=16777216
sysctl -w net.core.wmem_max=16777216
sysctl -w net.ipv4.tcp_rmem="4096 87380 16777216"
sysctl -w net.ipv4.tcp_wmem="4096 65536 16777216"
sysctl -w net.ipv4.tcp_window_scaling=1
Please open Telegram to view this post
VIEW IN TELEGRAM
👍13🔥4
Make. Build. Break. Reflect.
#пятница #байки #troubleshooting Задолго до работы в айти я был простым советским инженером спутниковой связи. Тарелки, антенны, модемы, кабели, бесконечные командировки. В то время случалось масса странных историй. Мы разрабатывали спутниковое оборудование…
#байки #пятница #tcp #networking

На Windows (реестр, куда ж без него):
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters
TcpWindowSize = 65535 (или больше с window scaling)
Tcp1323Opts = 3 (timestamps + window scaling)


Помогает? Да, частично.
Но есть нюансы:
- Обе стороны должны поддерживать большие окна.
А сервер где-нибудь в Китае - фиг знает как настроен.
- Потери убивают всё.
При 1% потерь и 700 мс RTT скорость падает катастрофически.
- Slow Start
TCP начинает медленно и наращивает окно раз в RTT. При 700 мс это оооочень долго.

Короче, тюнинг помогает, но не спасает. Нужен костыль уровня "бог".

TCP Acceleration: красивый обман
И тут на сцену выходит PEP - Performance Enhancing Proxy.
В народе - TCP Accelerator. Маркетологи любят это слово.

Идея гениальна в своей наглости.
БЕЗ PEP (честный TCP):

Client Satellite (700ms) Server
│ │
│───── DATA ───────────────────────────────>│
│ 700 ms │
│<─────────────────────────────────── ACK ──│
│ 700 ms │
│───── DATA ───────────────────────────────>│
│ Total: очень медленно │


С PEP (хитрый обман):

Client Local PEP Satellite Remote PEP Server
│ │ 700ms │ │
│── DATA ─>│ │ │
│<── ACK ──│ (мгновенно!) │ │
│── DATA ─>│ │ │
│<── ACK ──│ │ │
│── DATA ─>│ │ │
│<── ACK ──│─────── DATA ───────────>│ │
│ │ (много данных сразу!) │── DATA ──>│
│ │ │<── ACK ───│
│ │ │ │
│ Client думает, На самом деле данные │
│ что всё уже ещё летят по спутнику, │
│ доставлено! но клиент уже шлёт ещё! │

Как это работает:
1. На стороне клиента стоит модем с PEP (наша железка).
2. На стороне HUB'а - ответная часть PEP.
3. Клиент отправляет TCP-пакет.
4. Локальный PEP сразу отвечает ACK, не дожидаясь ответа с сервера.
5. Клиент думает "ура, данные доставлены!" и шлёт ещё.
6. Тем временем PEP буферизирует данные и гонит их по спутнику *своим протоколом* (оптимизированным под high latency).
7. Удалённый PEP получает данные и уже по-честному передаёт серверу.

По сути, мы разрываем TCP-сессию на два локальных сегмента с низким RTT, а между ними гоним трафик специальным протоколом.

Клиент видит: "пинг 700 мс, но скорость 10 мегабит!" 🎉
Магия? Нет, честный обман. 😁

Конечно, есть ограничения:
- Память не бесконечна.
Каждая "ускоренная" сессия жрёт буфер. Модем на 100 сессий - это одно, на 10 000 - совсем другие деньги.
- При потере связи - боль.
Если спутник моргнул, а PEP уже насобирал мегабайт данных "в кредит" - это всё надо переслать. А клиент уже уверен, что данные доставлены.
- Шифрование.
HTTPS, VPN, IPsec - PEP не может влезть в сессию, не может подменить ACK. Приходится либо терминировать SSL на PEP (что не всегда возможно), либо PEP работает только на транспортном уровне и не так эффективен.
- Оборудование на обеих сторонах.
Если у тебя PEP только на модеме, а на HUB'е нет - толку мало. Поэтому это работает в контролируемых сетях VSAT.

Вместо морали
Работая со спутниковой связью, начинаешь по-другому смотреть на сети.
Когда твой пинг 700 мс, ты понимаешь, что TCP придумали для LAN.
Твой клиент на корабле посреди Тихого океана жалуется на скорость, ты не можешь сказать "перезагрузите роутер".
Тебе надо найти решение.

Если вы думали, что всё знаете о сетях и TCP просто представьте:
Вы на нефтяной платформе в Северном море.
С ноутбуком 2005 года.
И Windows XP.
И TCP-окном в 64 КБ.
И RTT 800 мс.
А на улице идёт дождь, который дарит помехи.
И надо срочно скачать огромный файл.

Вы знаете о TCP и сетях не всё 😀
Please open Telegram to view this post
VIEW IN TELEGRAM
👍40🔥54
#aws

Есть у меня личный паблик EKS (да, я плачу ~93-95 долларов ежемесячно).

Решил я обновить версию 1.33 на версию 1.34:
- control plane отлично обновился
- одна нод группа на t3.micro обновилась так же на отлично
- вторая нод группа из одной ноды на t3.small почему-то пыталась обновится до 1.35.😁

При том, что официально 1.35 в AWS ещё нет.

Повторные ручные попытки дернуть апгрейд приводят лишь к этой ошибке.

Хорошо,что это не прод на работе.
Спасибо хоть на этом.

- - -
Ждём 1.35 на днях?
Please open Telegram to view this post
VIEW IN TELEGRAM
😁121
#пятница #всратость #javascript

Заходят как-то в бар арифметика с плавающей точкой и неявное приведение типов...



Баланс отпуска (PTO) на JS:
- когда "1.41" - "1" почему-то превращается в "15.92".
Обожаю этот язык, не меняйте ничего!1!!
🤣11🙈4😁2👍1
#victorialogs #victoriametrics #grafana #troubleshooting

Когда простая задача превращается в тупик:
миграция дашбордов с Loki на VictoriaLogs

Самобичевание, часть 1 из 2.

Вот уже много лет я работаю.
За плечами - десятки пройденных боёв с Kubernetes, облаками, мониторингом и коллегами.
Поел не одну ложку коричневой субстанции.
Казалось, что в области observability я видывал всё.
Но одна простая задача недавно поставила меня в тупик настолько основательно, что я до сих пор негодую на себя.

Как всё начиналось
У нас на работе есть собственная система observability - довольно сложная, с десятками компонентов, которые менялись со временем.
Сначала были одни инструменты, потом другие, затем поменялась концепция хранения, добавился мульти-тенантный доступ и так далее.
И я попал на задачу в самый неподходящий момент: когда нужно было переделать дашборды и для клиентов (с их интерфейсом и логами), и для внутренней техподдержки SRE.

Задача звучала тривиально:
- поменять дата-сорс в Grafana с Loki на VictoriaLogs
- параллельно часть метрик из VictoriaMetrics тоже переехала в VictoriaLogs - веб-серверный компонент стало удобнее хранить именно там
- переделать существующие панели, сохранив при этом изоляцию данных: чтобы клиенты не видели чужие логи, а права доступа работали корректно

Казалось, пара дней - и готово.

Когда тупик оказался глубже, чем я думал
Я провозился с этой задачей больше недели.
Почему?
Потому что оказалось, что я не знал многого.

Да нихера я не знал, начиная с базовой архитектуры:
- как работает индексация полей в VictoriaLogs (спойлер: индексируются все поля, не только лейблы как в Loki, плюс есть отдельные _stream_fields для ускорения типичных фильтров)
- как агенты отправляют данные и что такое _stream_fields
- что такое тенанты (AccountID, ProjectID) и как они задаются через HTTP-заголовки
- какие эндпоинты существуют (/select/logsql/query для запросов, /insert/loki/api/v1/push для записи)

Самое сложное - это понимание метрик в логах.
Когда берёшь запрос, не ясно: эта метрика уже распарсена из JSON на этапе ingestion, или её нужно парсить прямо в запросе?

Оказалось, что VictoriaLogs по умолчанию автоматически парсит JSON при записи данных (начиная с версии v1.16.0).
При Loki-совместимом ingestion VictoriaLogs пытается распарсить JSON из поля сообщения (message / _msg) и разложить его ключи в отдельные поля, которые затем индексируются.
Это можно отключить флагом -loki.disableMessageParsing или параметром disable_message_parsing=1 в URL.

А для парсинга в запросах есть свои операторы LogsQL:
- unpack_json - распаковка JSON
- unpack_logfmt - распаковка logfmt
- extract и extract_regexp - извлечение по паттерну
- fields pipe - работа с полями

Но вот сюрприз номер два: оказалось, что в Grafana можно вообще не парсить через LogsQL.
Есть Grafana Transformations - extractFields с format: "json" - и он достаёт поля из JSON прямо в браузере. 🤡
Это работает, но жрёт ресурсы на клиенте и ломает голову: где парсить - в запросе или в трансформации?

Примеры запросов, с которыми пришлось работать:
{tenant_id="${tenant_id}"} stream:"stderr" "[crit]" | stats count() as total
{tenant_id="${tenant_id}", region=~"${region}"} eventType:kubernetes-event

Тут видно:
- фильтрация по stream полям (stream:"stderr")
- поиск по тексту ("[crit]", "[emerg]")
- stats pipe с count() as total
- переменные Grafana (${tenant_id}, ${region})

И каждый раз для новой панели нужно было решать:
парсить в LogsQL через unpack_json или в Grafana через transformations?
Использовать stats pipe или range запрос?
Какой подход быстрее, а какой уложит VictoriaLogs ошибками 502 и 504?
Спойлер: я раз 30 уложил на лопатки виктория сторадж.
👍6
#victorialogs #victoriametrics #grafana #troubleshooting

Самобичевание, часть 2 из 2.

Особенности Grafana, о которых я забыл
Потом всплыли нюансы самой Grafana.
В плагине VictoriaLogs для Grafana (victoriametrics-logs-datasource) есть несколько queryType:
- Raw Logs - для логовой панели (лента логов, tailing)
- Range - для time series и графиков по времени, под капотом ходит в /select/logsql/stats_query_range
- Stats - для агрегатов без временной развёртки (одно число/набор чисел по фильтру)

И вот тут началась жесть с выбором правильного queryType:
- queryType: "range" - для time series графиков
- queryType: "stats" - для агрегаций через | stats count() as total
- queryType: "logs" - для сырых логов

Где-то нужен range-запрос, где-то stats.
Где-то метрика уже доступна как число в отдельном поле, а где-то её нужно извлекать из JSON.
И если ошибёшься с queryType - панель просто пустая, без ошибок.
Сидишь и гадаешь: запрос кривой или тип панели неправильный? 😭

Разница между _stream_fields (лейблы потока, быстрая фильтрация) и обычными полями лога оказалась критичной:
- фильтровать по stream field - быстро и дёшево
- по log field - медленнее, особенно если JSON-парсинг выполняется в запросе

Выводы и самоанализ
Когда я осмотрел свои коммиты, увидел десятки итераций:
- сначала делал парсинг в запросах через unpack_json, потом переключился на Grafana transformations, потом понял - лучше хранить поля структурированно при ingestion
- менял queryType с range на stats и обратно, потому что панели то пустые, то не те данные
- возился с мульти-тенантностью: настраивал лейблы типа tenant_id в запросах и флаги изоляции
- оптимизировал запросы со stats count() as total чтобы не гонять по всей базе полнотекстовый поиск
- добавлял фильтры по stream:"stderr" вместо поиска по всем логам
Полная фигня, если честно. 🤡

Это сильно меня опечалило:
- либо я отстал от технологий и мои знания Prometheus/Grafana устарели и надо срочно учить весь VM стек
- либо я переоценил себя при оценке задачи
- либо это действительно сложная задача, которую я недооценил
Скорее всего, правда посередине.

VictoriaLogs - не просто замена Loki, это другой инструмент с другой философией:
- Loki индексирует только лейблы потока, текст сообщений не индексируется - regex по message сканирует содержимое
- VictoriaLogs индексирует все поля логов, так что по ним можно быстро фильтровать и искать. При этом стрим-поля через _stream_fields дополнительно помогают с компрессией и ускоряют типичные фильтры
- LogsQL ≠ LogQL - это разные языки запросов, хоть и похожие
- Высокая кардинальность (trace_id, user_id, ip) в VictoriaLogs гораздо менее болезненна, чем в Loki, если хранить такие вещи как обычные поля, а не stream fields. Loki сильно страдает, если тащить user_id/trace_id в лейблы

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

И это я ещё пропускаю часть про VMrules и recording rules, которые пришлось добавить для некоторых панелей....😭

Задача - теперь для меня не сложная. Ну я максимум часа 2-3 на подобное потрачу.
Проблема в том, что я переоценил свои силы и знания (которых и не было).
В общем надо учиться и читать документацию, прежде, чем приступать к задаче.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍14🔥1
#kubernetes #мысли

Сижу, разбираюсь с Crossplane и провайдерами для Azure.
- https://docs.crossplane.io/latest/
- https://marketplace.upbound.io/providers (да, мне понравились upbound)
Надо было кое-что найти, а обычный лист выдаёт сотни ответов без грепа.
Глянул сколько у нас всего кастом кайндов, а там..

Ах этот безумный-безумный мир.
kubectl api-resources --verbs=list | grep -v customresourcedefinition | wc -l

513
kubectl get crd -o name | wc -l

498

В наших кластерах CRD станет скоро больше, чем базовых сущностей кубернетиса.
Безумие, абсолютное безумие.

В целом можно на собеседовании/в баре с коллегами по девопс-цеху меряться:
- У нас в кластере 498 CRD, а сколько у вас? 😁

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

Очередной раз не завидую всем молодым специалистам, кому надо будет учить этот кубер.
Please open Telegram to view this post
VIEW IN TELEGRAM
💯12😱6😁1
1.png
431.8 KB
#devops #git

Никакого рокетсайнса или, прости господи, лайфхаков, просто делюсь наблюдением.

Последние месяцы для работы со схемами/диаграммами вместо привычных ресурсов (draw.io excalidraw.com diagrams.mingrammer.com) перешёл на связку:
- любой AI ассистент, любая модель
- markdown файл https://www.markdownguide.org/
- mermaid фреймворк https://mermaid.js.org/

Поддержка mermaid в MD файлах уже достаточно давно, а основные инструменты - VSCode, GitHub, GitLab, Cursor умеют это отображать для человека и в IDE, и в веб-браузере.
В некоторых случаях надо ставить плагин, но в некоторых уже работает и так.

Как это работает:
- я прошу ассистента нарисовать некую схему. Схема может быть обычная flowchart, может быть state, sequence или что-то другое
- в основном README.md или CLAUDE.md появляется блок кода, который отлично лежит в md файле и при этом
- - схема видна в IDE через preview режим
- - схема видна в браузере на страницах GitHub/GitLab
- - схема понятна любой нейронке
- самое главное, это прозрачно и для меня и нейронка понимает что хочет, чтобы изменение кода было корректным
- ну и всё это в git репозитории

Рандомный пример промпта:
Нарисуй flowchart в mermaid, который показывает, как Pod с toleration scheduling'ится на tainted node в Kubernetes


Рандомный пример кода (в файлах формата *.md)
graph TB
subgraph "Node Pool"
N1[Node 1<br/>No Taint]
N2[Node 2<br/>Taint: dedicated=app:NoSchedule]
N3[Node 3<br/>Taint: dedicated=app:NoSchedule]
end

subgraph "Pods"
P1[Pod A<br/>No Toleration]
P2[Pod B<br/>Toleration: dedicated=app]
P3[Pod C<br/>No Toleration]
end

P1 -->|Can Schedule| N1
P1 -.->|Cannot Schedule| N2
P1 -.->|Cannot Schedule| N3

P2 -->|Can Schedule| N1
P2 -->|Can Schedule| N2
P2 -->|Can Schedule| N3

P3 -->|Can Schedule| N1
P3 -.->|Cannot Schedule| N2
P3 -.->|Cannot Schedule| N3

style N1 fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px,color:#1B5E20
style N2 fill:#FCE4EC,stroke:#E91E63,stroke-width:2px,color:#880E4F
style N3 fill:#FCE4EC,stroke:#E91E63,stroke-width:2px,color:#880E4F
style P1 fill:#E3F2FD,stroke:#2196F3,stroke-width:2px,color:#0D47A1
style P2 fill:#E3F2FD,stroke:#2196F3,stroke-width:2px,color:#0D47A1
style P3 fill:#E3F2FD,stroke:#2196F3,stroke-width:2px,color:#0D47A1

Результат на скриншоте (GitHub + Cursor IDE).
Сами промпт/схема просто рандомно-мусорная, лишь для примера.

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

Связка мне нравится, я думаю многие это используют.
Если ещё не пробовали - начните.

Мы итак многие инженерные вещи переусложнили.
Хочется хотя бы с диаграммами и схемами сделать всё проще.
5👍286
#longread #grafana #kubernetes #troubleshooting #одинденьизжизни

А давненько не было лонгридов.

Три дашборда на границе Grafana
Часть 1 из 3.

У нас был дашборд service-metrics-overview, который работал с Loki.
Пришло время мигрировать на VictoriaLogs.
Чтобы не ломать продакшен, я сделал всё по уму: создал копию дашборда с новым UID service-metrics-overview-vl-draft и пометкой "[VictoriaLogs Draft]" в названии.

Пару недель работал над ним параллельно с основным.
Переписывал запросы, проверял, что данные сходятся.
Всё работало.
Старый дашборд на Loki, новый на VictoriaLogs - оба живут рядом, никому не мешают.

Когда draft был готов, убрал из названия "Draft", поменял UID на service-metrics-overview-vl.
Ещё через неделю решили, что суффикс -vl не нужен - пусть будет просто service-metrics-overview, как раньше. Старый Loki-дашборд к тому моменту уже удалили.

Меняю в JSON:
"title": "Service Metrics Overview [VictoriaLogs Draft]"

на
"title": "Service Metrics Overview [VictoriaLogs]"


И UID с service-metrics-overview-vl-draft на service-metrics-overview.

Коммит, пуш, ArgoCD синкается.
Зелёненькое. Красота.
Иду пить чай.
А, нет, не иду, коллеги пишут, что многое не работает, ссылки ведут на старую/поломанную борду.

Иду в Grafana проверить.
А там старый дашборд. С "Draft" в названии.
И рядом ещё какие-то СТАРЫЕ версии.
И переменная loki висит, хотя я её точно убирал.

Эээ, а я вообще смеержил в main ветку?
Да, смержил, вижу новый код в git.

Первая мысль - может ArgoCD не синканул?
argocd app get dashboards --refresh

Всё синхронизировано.
Статус: Healthy, Synced.
Странно.

Может ConfigMap в кубернетисе не обновился?
kubectl get configmap -n grafana dashboards-myapp-service-metrics-common -o yaml | tail -20

Смотрю - там новый JSON.
Без "Draft". Всё верно.
Ничего старого.

Может в поде Grafana старый файл закэшировался?
kubectl exec -n grafana deployment/prod-region1-grafana -c grafana -- tail -5 /tmp/dashboards/MyApp/service-metrics-overview.json

Нет, файл тоже новый. UID правильный. Title без "Draft".

Может другие файлы рядом лежат и он их синкает?
Все файлы внутри пода проверил - не, только новые тут.
Проверил во второй реплике POD Grafana -так же только новые дашборды.
Бред.

Чего имеем:
- в гите есть изменения, старых дашбордов нет
- ConfigMap правильный
- файл в поде правильный
а в UI - старое.
Что-то не сходится.
Что я упускаю?
👍6🔥1
#longread #grafana #kubernetes #troubleshooting #одинденьизжизни

Три дашборда на границе Grafana
Часть 2 из 3.

Смотрю логи Grafana:
kubectl logs -n grafana deployment/prod-region1-grafana -c grafana --since=1h | grep -iE "provision|dashboard"


И вот оно:
{"error":"A dashboard with the same uid already exists","file":"/tmp/dashboards/MyApp/service-metrics-overview.json"}


Grafana provisioning видит файл, пытается его применить, но не может - в базе уже есть дашборд с таким UID.
Кстати в некоторых версиях Grafana логи могут не содержать сам UID.
В таком случае придётся идти в API или базу данных напрямую.

У нас графана с авторизацией по SSO, у меня права, как у девелопера.
Мне лень писать заявки на админский доступ и ковырять UI интерфейс, думаю мне хватит и curl + localhost для анализа.
Ведь я знаю, что рут пароль лежит в секретах кубера.😁
Я сам себе админ 😀

Проверяю через Grafana API:
GRAFANA_PASS=$(kubectl get secret -n grafana creds-grafana-admin -o jsonpath='{.data.admin-password}' | base64 -d)

kubectl exec -n grafana deployment/prod-region1-grafana -c grafana -- curl -s -u "admin:${GRAFANA_PASS}" "http://localhost:3000/api/search?query=service%20metrics"


И вижу:
- service-metrics-overview
- service-metrics-overview-vl
- service-metrics-overview-vl-draft
Сука, да откуда вы берётесь.

Три версии одного дашборда.
А файл у меня один (гит, конфигмап, файл внутри пода).

Тут до меня дошло: каждый раз, когда я менял UID, provisioning создавал новую запись в базе.
А старые записи никуда не девались - они просто висели мёртвым грузом.
Теперь, когда я хочу вернуть оригинальный UID service-metrics-overview, он уже занят старой записью.

Grafana хранит дашборды в PostgreSQL. Provisioning работает так:
- видит файл с UID
- проверяет - есть ли в БД запись с таким UID?
- если есть и привязана к этому файлу - обновляет
- если есть, но привязана к другому источнику - ошибка

Это не баг, а задуманное поведение: provisioning не перезаписывает дашборды, созданные из других источников.

Лезу в базу:
# ищу primary ноду (рид реплика не даст писать)
kubectl exec -n grafana pg-monitoring-1 -c postgres -- psql -U postgres -d grafana -c "SELECT pg_is_in_recovery();"
# true = replica

kubectl exec -n grafana pg-monitoring-2 -c postgres -- psql -U postgres -d grafana -c "SELECT pg_is_in_recovery();"
# false = primary


Смотрю что там:
kubectl exec -n grafana pg-monitoring-2 -c postgres -- psql -U postgres -d grafana -c "SELECT id, uid, title, version FROM dashboard WHERE uid LIKE '%service-metrics%' ORDER BY uid;"


  id  |                uid                  |                      title                       | version 
------+-------------------------------------+--------------------------------------------------+---------
3781 | service-metrics-overview | Service Metrics Overview | 1
3830 | service-metrics-overview-vl | Service Metrics Overview [VictoriaLogs] | 1
3825 | service-metrics-overview-vl-draft | Service Metrics Overview [VictoriaLogs Draft] | 1


Три записи от разных этапов миграции.
Файл один.
Вот и причина.

Варианты:
1. Удалить через UI - не выйдет (я ж попробовал), "provisioned dashboard cannot be deleted"
2. Ждать - не рассосётся само
3. Рестарт пода/подов - не вариант, мне по шапке за это дадут, графана как бы на всех.
4. Принудительный reload provisioning через API:
kubectl exec -n grafana deployment/prod-region1-grafana -c grafana -- curl -s -X POST -u "admin:${GRAFANA_PASS}" "http://localhost:3000/api/admin/provisioning/dashboards/reload"

Не помогло - ошибка та же, записи в БД мешают.
5. Удалить записи из базы напрямую 🤡

Выбираю последний вариант. Сначала бэкап:
kubectl exec -n grafana pg-monitoring-2 -c postgres -- pg_dump -U postgres grafana > grafana_backup_$(date +%Y%m%d_%H%M%S).sql


Затем удаление:
kubectl exec -n grafana pg-monitoring-2 -c postgres -- psql -U postgres -d grafana -c "DELETE FROM dashboard WHERE uid IN ('service-metrics-overview', 'service-metrics-overview-vl', 'service-metrics-overview-vl-draft');"

DELETE 3
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥5
#longread #grafana #kubernetes #troubleshooting #одинденьизжизни

Три дашборда на границе Grafana
Часть 3 из 3.

Жду 30 секунд (updateIntervalSeconds в provisioning конфиге).

Проверяю:
kubectl exec -n grafana pg-monitoring-2 -c postgres -- psql -U postgres -d grafana -c "SELECT id, uid, title FROM dashboard WHERE uid LIKE '%service-metrics%';"


  id  |            uid              |                   title                    
------+-----------------------------+--------------------------------------------
3838 | service-metrics-overview | Service Metrics Overview [VictoriaLogs]


Одна запись. Правильный title. Правильный UID.

Иду в UI - работает. Никаких дублей, никаких "Draft", никакого Loki.
Победа! 🎉

Если provisioning не срабатывает автоматически, можно вызвать reload через API (выше был пример курлом) или перезапустить pod Grafana (не мой вариант).
Подробнее про provisioning: https://grafana.com/docs/grafana/latest/administration/provisioning/

На всё ушло часа два.
Могло бы - минут пятнадцать, если бы сразу глянул в логи.

Выводы, а куда без них.
- Grafana provisioning не перезаписывает существующие записи в БД, если они созданы из другого источника. Он кидает ошибку в лог и оставляет старую версию.
- ошибка "A dashboard with the same uid already exists" - это про запись в PostgreSQL, не про файл. Файл может быть идеальным, но если в базе мусор - ничего не заработает.
- логи первым делом. Там обычно всё написано. Но не во всех версиях Grafana логи достаточно информативны.
- удаление из БД работает, но это неофициальный подход. Я не нашёл другого способа. Для provisioned dashboards безопасно - они автоматически пересоздаются из файлов. Главное - делать на primary ноде PostgreSQL и иметь бэкап.
- миграции дашбордов - это не "поменять пару строк". Особенно если UID менялся несколько раз. Особенно, если я баран.

Как избежать этой проблемы (предполагаю):
- не меняйте UID после первого деплоя. Если нужна миграция - создайте новый дашборд с новым UID, а старый удалите полностью (включая запись в БД).
- используйте стабильные UID с префиксами:
myteam-service-metrics-overview
myteam-app-logs-dashboard

- не используйте один UID для дашбордов в разных папках - это создаёт race condition.
- документируйте миграции в changelog или README.
- используйте allowUiUpdates: false в provisioning конфиге - это предотвращает дрейф конфигурации.
Пример конфигурации провижининга
apiVersion: 1
providers:
- name: 'sidecarProvider'
orgId: 1
type: file
disableDeletion: false
allowUiUpdates: false # запрет редактирования в UI
updateIntervalSeconds: 30 # интервал сканирования файлов
options:
foldersFromFilesStructure: true
path: /tmp/dashboards


Альтернативный подход, без прямого доступа к БД - когда его нет или страшно (предполагаю, что это правильный путь, но я не проверял):
- временно переименовать ConfigMap (например добавить суффикс -old)
- дождаться, пока Grafana удалит дашборды (если disableDeletion: false)
- вернуть ConfigMap с правильным именем и новым UID
- provisioning создаст дашборды заново
Этот способ медленнее, но безопаснее.

Для зануд:
Прямое изменение базы данных Grafana - неофициальная практика!
Это не является моим советом или рекомендацией!
Просто поделился своей историей.


- - -
- https://github.com/grafana/grafana/issues/12411
- https://github.com/grafana/grafana/issues/41085
- https://github.com/grafana/grafana/issues/73043
🔥81
#kubernetes

Пока я спал там взломали все инторнеты и все кубернетисы.

Источник на английском с глубоким разбором.
- https://grahamhelton.com/blog/nodes-proxy-rce

Хорошие ребята, кого не жалко репостнуть:
- https://xn--r1a.website/tech_b0lt_Genona/6101
- https://labs.iximiuz.com/tutorials/nodes-proxy-rce-c9e436a9

Хорошо, что все кубернетисы (кроме моих личных лабораторий) на предыдущих и нынешней работах это приватные сетки (что спасает от внешних негодяев, но не спасает от внутренних, но таких у нас нет).

Как проверить, подвержен ли ваш кластер/SA? А легко!
Копи-паст скрипта в своем кластере:
kubectl get serviceaccounts --all-namespaces -o json | \
jq -r '.items[] | "\(.metadata.namespace)\t\(.metadata.name)"' | \
while IFS=$'\t' read -r ns sa; do
if kubectl auth can-i get nodes/proxy --as="system:serviceaccount:${ns}:${sa}" -n "$ns" >/dev/null 2>&1; then
echo -e "${ns}\t${sa}\tyes"
fi
done

Да, такие дела. Ну и порт 10250 должен быть доступен.
🔥2
#kubernetes #golang #troubleshooting

Снова про операторы.

У ресурса Secret в Kubernetes есть два поля для данных:
- Data map[string][]byte - бинарные данные, в YAML выглядят как base64
- StringData map[string]string - строковые данные, plain text в YAML

Как это работает на самом деле:
StringData - write-only поле для удобства.
Когда ты создаёшь или обновляешь секрет, API-сервер мержит StringData в Data (ключи из StringData перезаписывают Data при совпадении).
В etcd хранилке и в ответе API сохраняется только Data.
При чтении секрета из кластера поле StringData всегда пустое - его там просто нет.
Поэтому StringData - для людей и YAML-манифестов, а Data - для кода операторов.


А теперь типичнейшая ошибка, на которую я снова попался. 😭

Код оператора с ошибкой:
func BuildCredentialsSecret(instance *v1alpha1.MyApp) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: instance.Name + "-credentials",
Namespace: instance.Namespace,
},
Type: corev1.SecretTypeOpaque,
StringData: map[string]string{
"API_HOST": instance.Spec.Config.API.Host,
"API_TOKEN": instance.Spec.Config.API.Token,
},
}
}

В реконсайлере проверка "изменился ли секрет?":
func secretDataEqual(a, b map[string][]byte) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if !bytes.Equal(v, b[k]) {
return false
}
}
return true
}

// в reconcile:
if !secretDataEqual(found.Data, secret.Data) {
// обновляем секрет
found.Data = secret.Data // ВОТ ТУТ ПРОБЛЕМА
return r.Update(ctx, found)
}

Видите проблему?

При создании desired-секрета мы используем StringData.
Но в Go-структуре этого объекта поле Data равно nil, потому что мы его не заполняли!!!

Что происходит:
- оператор создаёт секрет с StringData > API сервер конвертирует в Data, в кластере всё ок
- при следующем reconcile оператор читает секрет - там Data заполнен
- оператор строит desired-секрет - там Data = nil, StringData заполнен
- сравнение видит разницу:
len(found.Data) != 0, а len(secret.Data) == 0 

(потому что nil)
- оператор решает обновить секрет и делает
found.Data = secret.Data

записывая nil в поле данных!
- теперь в кластере секрет с
data: {}

(0 ключей)
- в следующем reconcile сравнение
secretDataEqual(found.Data, nil)

оба пустые? Да!
(len(nil) == 0, len(map{}) == 0)

- оператор думает: "секрет актуален", и никогда не восстанавливает данные 🤡

Результат: секрет живёт, ownerReference на месте, лейблы есть, а данных - ноль.
Поды падают с:
MountVolume.SetUp failed for volume "credentials": 
references non-existent secret key: API_TOKEN

Фикс очевиден: использовать Data в коде оператора:
func BuildCredentialsSecret(instance *v1alpha1.MyApp) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: instance.Name + "-credentials",
Namespace: instance.Namespace,
},
Type: corev1.SecretTypeOpaque,
Data: map[string][]byte{
"API_HOST": []byte(instance.Spec.Config.API.Host),
"API_TOKEN": []byte(instance.Spec.Config.API.Token),
},
}
}


Теперь secret.Data заполнен, сравнение работает корректно, и ты случайно не затираешь данные nil-ом.
🎉🎉🎉

Мораль:
- stringData - для YAML и kubectl, где удобно писать plain text
- data - для кода операторов на Go
- в client-go структуре StringData и Data - разные поля; API сервер не синхронизирует их при чтении
- если используешь StringData в коде, помни: secret.Data == nil в этой структуре
- сравнивай и применяй то, что реально используешь

Потерял на этом баге часа два, пока не дошло. Сука.
Секрет создавался, лейблы были, ownerReference был, а данных - ноль.

Классика - симптомы в одном месте (падающие поды), причина в другом (nil вместо мапы в билдере).
Please open Telegram to view this post
VIEW IN TELEGRAM
6👍6
#kubernetes #aws #eks #helm

Тихо и незаметно вышла мажорная версия AWS Load Balancer Controller - 3.0.0.
https://github.com/kubernetes-sigs/aws-load-balancer-controller
Вместе с ней и Helm-чарт тоже прыгнул на 3.x.
https://github.com/aws/eks-charts/tree/master/stable/aws-load-balancer-controller

Как я понял по заявлению мейнтейнеров, breaking changes в пользовательском API (Ingress, Service, Gateway) они не предполагают.
Но issue на GitHub уже висят, так что посмотрим.

Что важного и интересного (для меня):
- Gateway API объявлен GA (production ready) - теперь можно смело использовать в бою, а не в тестовых проектах 🔥
​- Версия Helm-чарта выровнена с версией контроллера - раньше было 2.x контроллер с чартом 1.x, теперь 3.0.0 = 3.0.0. Наконец-то! ❤️
​- Новые фиксы NLB target groups, порядка сабнетов, AZ mismatch и webhook-сертификатов
​- CRD ListenerRuleConfiguration добавлен, но пока экспериментальный - не использовать в проде
- Минимальная версия Kubernetes - теперь официально требуется 1.24+ (интересно, кто ещё на таком старье сидит 😁)
​- IPv6 для internet-facing ALB/NLB - теперь нативно поддерживается без костылей, раньше были ограничения 🎉

Нюансы с апгрейдом:
- перед helm upgrade до 3.0.0 нужно вручную обновить CRD'ы. Issue #4555 прямо про это - люди натыкаются на проблемы, потому что в release notes это не очень явно прописано.
Но как по мне так это и очевидно, так как мы все знаем, что при хелм апгрейде​ CRD не обновляется никогда и ни у кого, архитектурная особенность самого хелма.
- для Gateway API нужны не только CRD самого контроллера, но и базовые Gateway API CRD (HTTPRoute, GatewayClass и т.д.), если их ещё нет в кластере

Сам, конечно же, я пока обновляться не буду.
Почитаю issues ещё недельку, потом с командой решим - стоит ли сразу или подождать патч-релиз.

Better safe than sorry, особенно с ингресс-контроллерами, которые держат весь трафик.
Please open Telegram to view this post
VIEW IN TELEGRAM
💯4🎃1
А вариантов отказаться и нет 😭
Please open Telegram to view this post
VIEW IN TELEGRAM
7🤣4🤯3💔2👍1
Там Daniele Polencic обновил статью про k8s API.
Прям с графиками, картиночками, наглядно.
В целом годно, как и многие его другие ресурсы/статьи. Никакого рокетсайнса и кишочков - просто база-дополнение к официальной документации.

Рекомендую как для общего развития начинающим специалистам, так и в качестве подготовки к собеседованиям на тему кубернетиса.

https://learnkube.com/kubernetes-api-explained
🔥10👍2
#gitlab #troubleshooting #одинденьизжизни

Тихий убийца времени.


На новом проекте дали задачу: небольшие изменения в гошном операторе Kubernetes.
Секреты, cluster-scoped объекты, ничего космического.
Покрутил код, разобрался в архитектуре, поднял локальный kind-кластер, погонял тесты.
Makefile на месте, всё зелёное. Красота.

Коммичу, пушу MR.
Тесты падают. 🙃

Ну ладно, думаю. Я серьёзно менял логику, наверное поломал что-то. Бывает.
Возвращаюсь, перепроверяю. Локально гоняю - всё проходит.
Коммичу, пушу - снова падает.

Ок, уже интересно.

Открываю .gitlab-ci.yml, читаю джобу тестов строчка за строчкой.
Повторяю ровно то же самое локально - make test, всё зелёное.
В GitLab - красное.

Смотрю логи джобы внимательнее.
Один из тестов падает на скачивании image из container registry соседнего репозитория. Не хватает авторизации. Ага.

Проверяю:
- мой GitLab токен - валидный
- доступ в соседние репозитории (сайдкар-образы для тестов) - есть
- образы в registry - на месте
- пайплайн до моих изменений - зелёный, тесты проходили
Всё на месте, а не работает.🤡

Перепроверяю ещё тысячу раз.
Локально - 100% то же самое - работает.
В GitLab - нет.

Тут приходит мысль: а может дело не в коде, а в правах?
Пайплайн-то запускается от моего имени.
А у тех, кто запускал до меня, может быть что-то другое.

Иду смотреть настройки репозитория.
Settings - нет доступа.
Access Tokens - нет доступа.
Deploy Tokens - нет доступа.
У меня Developer, без права заглянуть хоть куда-то в настройки.

Пишу в личку коллеге, который точно Admin:
- Слушай, можешь по этой ссылке просто нажать Retry на джобе тестов?
Он жмёт.
Тесты проходят. Сука.

Я жму Retry.
Тесты падают. Сука.

Пишу главной команде DevOps (да, оксюморон - девопс пишет девопсам "спасити-памагити").
Объясняю ситуацию: так и так, тесты падают только когда пайплайн запускает мой юзер, предполагаю, что CI_JOB_TOKEN моего профиля не имеет нужных прав, он передаётся в кубер как image pull secret, а там авторизация не проходит.

Ну, после некоторых усилий удалось донести мысль. Ребята берут таймаут.

Через несколько дней/часов:
- Ретрай.
Ретраю.
Зелёное. 🎉

- А что было?
- Мы тоже долго дебажили, скормили все токены в Claude, но нашли.
У тебя в профиле GitLab стояла галочка External User.


External. User. Сука.
Галочка. В профиле. Которую я не ставил. Которую даже не видел (у меня нет доступа к админ-панели). Которую кто-то когда-то поставил при создании моего аккаунта. Или не снял. Или поставил по дефолту в LDAP/SAML/SCIM-маппинге. Или просто потому что.

Что делает External User в GitLab:
- https://docs.gitlab.com/administration/external_users/
- внешний пользователь не имеет доступа к internal-репозиториям
- CI_JOB_TOKEN наследует ограничения профиля
- даже если у тебя есть явный Developer-доступ к проекту, некоторые internal-ресурсы (включая container registry соседних проектов) могут быть недоступны
- в логах пайплайна это выглядит как обычная 401/403 при pull image - ни слова про external user

Всё работает. Везде. Кроме одного места.
И это место - галочка в чужой админ-панели, которую ты даже увидеть не можешь.

Потрачено:
- ~6-7 часов моего времени на дебаг
- N часов времени DevOps-команды
- массу нервных клеток
- массу токенов в Claude

Выводы:
- если пайплайн падает только у вашего юзера, а у коллег - нет, проблема не всегда в коде.
Проблема иногда в правах. Не в правах репозитория, а в правах профиля.
- External User в GitLab - это тихий убийца. Никаких предупреждений в UI, никаких баннеров "вы external". Просто CI_JOB_TOKEN молча получает урезанные права.
- вы не увидите эту галочку сами - она в Admin Area, доступ только у администраторов GitLab.
- при онбординге на новый проект спросите: "А мой аккаунт точно не external?" Серьёзно. Одна галочка - полдня жизни.
- локальные тесты != CI тесты. Даже если команды идентичны. Контекст авторизации разный.
- и как обычно: во всём виноваты девопсы. Даже когда девопс - это ты сам. 😢
Please open Telegram to view this post
VIEW IN TELEGRAM
👍18🤡2💔1
#longread #troubleshooting #operators #kubectl #kubernetes #одинденьизжизни

Внезапный и незапланированный лонгрид.

Разбирал по работе задачу, узнал новое про фичу kubectl cache discovery.
Теперь делюсь историей и знаниями с вами.
Приятного прочтения.

https://teletype.in/@kruchkov_alexandr/wWa-INvQvJH
15👍23🔥1