prod logs
45 subscribers
1 photo
5 links
Заметки разработчика: Go, базы данных, очереди, алгоритмы. Разбираю код и подходы — то, что сам долго искал нормально объяснённым.
Статьи на Хабре.
Download Telegram
prod logs pinned «Привет. Я Go-разработчик, веду этот канал как технический дневник и создаю своё сообщество! Что будет: → разбор кода, ошибок, подходов → объяснения тем, по которым сам долго не мог найти нормально описанными → Go, PostgreSQL, Redis, очереди сообщений,…»
Ошибки в Go: оборачивать или нет

Все ошибки делятся на два типа — ожидаемые и неожиданные. От этого зависит, как с ними работать.

Ожидаемые — часть бизнес-логики. Продумываем заранее, объявляем явно, возвращаем на конкретные события:
err := sqlx.GetContext(ctx, u.getDB(ctx), &user, getUserByEmailQuery, email)
if errors.Is(err, sql.ErrNoRows) {
return nil, models.ErrUserNotFound
}
if err != nil {
return nil, err
}


Сервисный слой подменяет ошибку под контекст операции. При аутентификации ErrUserNotFound → ErrInvalidCredentials — не раскрываем причину отказа:
if errors.Is(err, models.ErrUserNotFound) {
return nil, models.ErrInvalidCredentials
}


Неожиданные. Оборачивать или нет

Когда есть структурированный лог с операцией и контекстом — оборачивание избыточно:
if errors.Is(err, context.DeadlineExceeded) {
logger.Info("canceled_by_timeout",
zap.Error(err),
zap.String("operation", operation))
return models.ErrRequestTimeout
}


Лог содержит всё необходимое для диагностики.

fmt.Errorf("op: %w", err) оправдан только когда логирование далеко от источника ошибки и контекст иначе не сохранить.

Вывод. Решая делать wrapping или нет нужно ответить на вопрос: будет ли понятно без дополнительного контекста условия возникновения ошибки. В случае отрицательного ответа — оборачиваем.
#go #errors
1
Моя статья участвует в Технотексте 8 🙂
Большинство сталкиваются с тяжестью развития в разработке. Особенно, когда в жизни уже есть сторонние работы, ответственность за которые тоже давит.
Лучшая мотивация, пока не добился первой работы, первого повышения, первой значимой должности — смотреть назад и видеть результат. Для этого нужны индикаторы. Например, алгоритмы, которые я начал учить. Сначала не понимаешь как подойти к решению, потом понимаешь принцип, затем решаешь.
Старайтесь искать ориентиры, в том числе в сообществе. То, что статью приняли в Технотекст — для меня тоже индикатор. Хотелось зайти именно с технической.
Кому интересно — Модно не значит правильно — про pgx, метрики и OpenTelemetry
#articles #go
🔥2
Права доступа при деплое с Docker

Частный случай, который легко пропустить при настройке CI/CD.

Если в compose указан локальный путь и папки нет на хосте — Docker создаёт её от root, потому что фоновый процесс Docker работает от root, независимо от того кто запустил docker compose.

grafana:
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning # <- проблемный путь


При следующем деплое, пользователь deploy не сможет удалить эту папку — Permission denied.

Решение для текущего случая — создавать папки до старта Docker в deploy скрипте:

mkdir -p ~/backend/grafana/provisioning/dashboards
mkdir -p ~/backend/grafana/provisioning/datasources


Docker найдёт папки уже существующими и не будет создавать от root. Хрупко — при добавлении нового тома надо не забыть добавить mkdir -p. Для опыта взаимодействия с CI/CD хороший кейс.
#CD #деплой
1
Как понять, что ты развиваешься в IT?

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

Каналы: почему нельзя закрывать канал дважды?

close(ch) — это не просто операция над памятью. Это утверждение: "я, владелец канала, гарантирую что записей больше не будет". Это соглашение между частями программы, которое установлено разработчиками.


"Closing a channel is a signal to the goroutine reading from the channel that there are no more values to send. There should never be any confusion as to whether there are more values to send or not. Either you are done, and you close the channel, or you are not done, and you do not close the channel. Closing a channel twice implies that you don't know whether you are done or not. By that argument, closing a channel twice does not make sense, so the runtime panics."
— Ian Lance Taylor
https://groups.google.com/g/golang-nuts/c/rhxMiNmRAPk/m/bfVxcz6CAQAJ


Вторая часть сообщения и мне пришла в голову до прочтения. Но для этого ещё не время.

--------------------

runtime.mutex

Помимо sync.Mutex и sync.RWMutex есть runtime.mutex — но это не третий мьютекс на одном уровне с ними. Это примитив другого слоя: недоступен из пользовательского кода вообще, существует только внутри рантайма. Именно он используется для синхронизации полей структуры каналов.

Ключевое отличие — он усыпляет поток ОС, а не горутину. Пока поток спит в ядре, он не может выполнять другие горутины. Поэтому runtime.mutex держится только наносекунды — ровно пока идёт работа с внутренней структурой канала.

// Mutual exclusion locks. In the uncontended case,
// as fast as spin locks (just a few user-level instructions),
// but on the contention path they sleep in the kernel.
// A zeroed Mutex is unlocked (no need to initialize each lock).
type mutex struct {
lockRankStruct
// Futex-based impl treats it as uint32 key,
// while sema-based impl as M* waitm.
key uintptr
}

https://go.dev/src/runtime/runtime2.go

#go #ch #mutex
👍1
context.Context — несколько фактов которые полезно знать
1. WithTimeout — это просто обёртка над WithDeadline
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}

А WithDeadline в свою очередь — обёртка над WithDeadlineCause с nil причиной. Вся цепочка: WithTimeout → WithDeadline → WithDeadlineCause.

2. AfterFunc — колбэк на отмену контекста
stop := context.AfterFunc(ctx, func() {
conn.Close() // вызовется в новой горутине когда ctx отменится
})
defer stop() // если контекст не отменился — отменяем регистрацию

Неблокирующий. Регистрирует функцию и идёт дальше. stop() нужен чтобы не держать регистрацию если операция завершилась раньше отмены контекста.

3. WithoutCancel — жить дольше запроса, не теряя values
bgCtx := context.WithoutCancel(ctx)
go s.auditLog(bgCtx, order)

Контекст запроса отменится когда клиент получит ответ. Но trace ID, user ID и остальные values нужны в фоновой горутине — для логов, трейсинга, метрик. context.Background() их теряет. WithoutCancel — нет.
Если у тебя фоновые горутины из хендлеров получают context.Background() — скорее всего там должен быть WithoutCancel.
#go #context
1
Проектирование структуры

Сложный проект меняет не код — он меняет порядок, в котором ты думаешь о коде.

Повезло оказаться в большом проекте Мастерской, в процессе которого я начал иначе смотреть на то, с чего начинается реализация. Раньше казалось: разберусь с архитектурой, напишу репозитории, потом сервисы, потом хендлеры. Логично же? Нет.

Писать код — это про понимание структуры, инфраструктуры и надёжности системы до первой бизнес-строчки.

Конкретно это выглядит так:
1. Скелет — приложение запускается и корректно гасится. Без логики.
2. Конфиги — структуры по сущностям, дефолты, валидация на старте.
3. Зависимости — логгер, кэш, метрики. До бизнес-логики, не во время.

Почему не во время? Потому что вот так начинают:

func New(db *pgxpool.Pool) *App { ... }

// потом логгер
func New(db *pgxpool.Pool, logger *zap.Logger) *App { ... }

// потом кэш, метрики, конфиг...
func New(db *pgxpool.Pool, logger *zap.Logger, cache *redis.Client, cfg *Config) *App { ... }

// хочется рефакторить?


Правильно — решить это один раз и не возвращаться:

type App struct {
db *pgxpool.Pool
cache *redis.Client
logger *zap.Logger
}

func New(cfg *Config) (*App, error) { ... }


4. Первый сквозной путь — один рабочий сценарий end-to-end. Всё остальное заглушки.
5. Скелет тестов — точка входа для интеграционных. Не сами тесты — структура.
6. Рост — новый функционал по уже выстроенному шаблону.

 Горизонтально:
[все репо] → [все сервисы] → [все хендлеры]
↳ долго ждёшь первого рабочего результата

Вертикально:
[скелет] → [конфиги] → [зависимости] → [1 сценарий] → рост
↳ каждый шаг даёт обратную связь
1
Окно возможностей

Постоянное саморазвитие — это хорошо. Но без переключения энергия иссякнет. Что вас заряжает?

В ожидании следующего проекта прошёлся seq scan по составленной roadmap. Через index scan — погрузился в проблемные темы. Несколько недель часы напролёт — и в какой-то момент мозг просто перестаёт брать новое: нужна понятная задача, не просто поток информации.

Ресурс есть у всех. Мой стиль — интенсивный: усваиваю больше информации, увеличиваю её поток в единицу времени. Работает — пока не упираешься в стену.

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

Без проекта контекст уменьшается — и его нужно заполнять. Самое время для нового: теория, практика, алгоритмы. И более глубокие посты в канале — есть время подумать, а не только зафиксировать находку.

prod logs — инструмент в обоих режимах. В проекте разгружает, между проектами — углубляет. И в обоих случаях — карта моего пути в IT.
👍21
Паттерн singleflight и delete(g.m, key)

При изучении исходников бросился в глаза один участок кода в doCall:

func (g *Group) doCall(c *call, key string, fn func() (any, error)) {
...
if g.m[key] == c {
delete(g.m, key)
}
...
}


Сразу появился вопрос: мы удаляем ключ — что происходит с данными?

Вспоминаем как работает GC в Go. Он оперирует ссылками: пока на область памяти есть живые ссылки — объект не собирается. delete убирает ключ из мапы, но не объект из памяти.

А ссылки уже взяты раньше — в Do:

func (g *Group) Do(key string, fn func() (any, error)) (v any, err error, shared bool) {
...
if c, ok := g.m[key]; ok {
c.dups++
g.mu.Unlock()
c.wg.Wait()
return c.val, c.err, true
}
...
}


Каждая ожидающая горутина держит локальную копию указателя c. Первый вызов завершается, пишет результат в структуру, doCall удаляет ключ из мапы. Все ожидающие спокойно считывают данные через свои указатели, завершаются — и только тогда GC может собрать объект.

delete(g.m, key) ≠ удаление данных.

func (g *Group) Forget(key string)

Тут и появляется Forget. Если doCall всё равно почистит мапу сам — зачем он?

Forget нужен когда не хочешь ждать завершения текущего вызова. Например, fn зависла — внешний сервис деградировал. Вызываешь Forget — ключ удаляется из мапы прямо сейчас. Следующий Do не встанет в очередь к зависшему вызову, а выполнит в Do до ветки с ожидающими горутинами:

c := new(call)
c.wg.Add(1)
g.m[key] = c


Новый *call, новый wg — независимо от старого. Старые ожидающие горутины ждут свой *call, новые — свой. Когда старый завершится, doCall проверит g.m[key] == c — увидит там уже другой указатель и не тронет новый вызов.
Проверка на идентичность указателя в doCall — не случайная деталь. Защита от ситуации когда старый вызов завершается позже нового. Осознанный трейдофф: дублирование в обмен на latency.

Исходники: https://github.com/golang/sync/blob/master/singleflight/singleflight.go
#go #sync #gogc
«Go: Simple to learn, but hard to master» — так называется первая глава книги 100 Go Mistakes (Teiva Harsanyi).

На примере предыдущего поста про singleflight это хорошо видно. Один паттерн — а за ним: Mutex, WaitGroup, map, указатели, GC. И так в каждом паттерне, в каждом пакете, пока углубляясь не начнёшь ходить по кругу. Ведь всё между собой связано.

Курсы проводят по верхнему слою — дают «знаю». Куда идти дальше и насколько глубоко — зависит только от тебя.

«Знаю» — прочитал про singleflight: дедупликация вызовов по ключу, ждём первого, отдаём всем.

«Понимаю» — могу объяснить что происходит с памятью когда вызывают Forget. Почему GC не тронет объект. Почему в doCall сравнивают указатели, а не ключи.

Второе приходит только через практику + возврат к теории с конкретным вопросом.

На своём опыте с алгоритмами — сначала решал как придётся. Потом пришёл к теории. Акцент сменился — не «решить задачу», а «найти правильный подход».
1
9 июня участвую в открытом mock-интервью от Яндекс Практикума — вебинар про алгоритмические собеседования для джунов.

Тимлид команды инфраструктуры RMS в Яндекс Финтехе, штатный интервьюер по алгоритмам — проверит меня и коллегу из Python-направления в прямом эфире. Хорошая возможность для всех получить советы напрямую от человека, который оценивает такие решения каждый день.

Можно посмотреть на практике как выглядит то самое знакомство с алгоритмами, о котором писал выше.

Бесплатно, 19:00 мск.
Регистрация: https://practicum.yandex.ru/promowebinar/8bdd09d4-596e-4ef9-a16f-1849a9c122e9/registration/
1
Docker для Go сервиса: три уровня

Уровень 1 — просто работает

FROM golang:1.25-alpine
WORKDIR /app
COPY . .
RUN go build -o server ./cmd/server
CMD ["./server"]


Образ ~300MB. Внутри весь Go toolchain, исходники, всё что не нужно в проде.

Уровень 2 — multistage + layer cache

FROM golang:1.25-alpine AS builder
WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -o /app/bin/server ./cmd/server

FROM alpine
RUN apk --no-cache add ca-certificates curl
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup

COPY --from=builder /app/bin/server .
USER appuser:appgroup
ENTRYPOINT ["./server"]


Образ уже меньше — toolchain и исходники в финальный слой не попадают.

go.mod и go.sum копируются отдельно: если зависимости не менялись — go mod download берётся из layer cache. Работает локально и на self-hosted runner. На GitHub Actions с ephemeral runner — нет, там каждый job на чистой машине. Для этого в уровне 3 появляется mount cache.

CA сертификаты нужны если сервис делает HTTPS запросы. Non-root пользователь — минимальная безопасность, которую стоит закладывать сразу.

Уровень 3 — прод

RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build \
-ldflags "-s -w -X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}" \
-trimpath -o /app/bin/server ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/bin/server .
USER nonroot:nonroot
ENTRYPOINT ["./server"]


Два добавления.

Mount cache — живёт между сборками независимо от layer cache. На CI где каждый раз чистая машина, модули берутся из volume, а не скачиваются заново. Трейдофф: 200–500MB на диске, не чистится автоматически.

VERSION и BUILD_TIME — sha коммита и время сборки вшиваются в бинарь через ldflags. В GitHub Actions передаётся так:

build-args: |
VERSION=${{ github.sha }}
BUILD_TIME=${{ github.event.head_commit.timestamp }}


При старте сервера логируешь — всегда знаешь что задеплоено.

distroless вместо alpine — нет shell, нет пакетного менеджера, nonroot встроен. CA сертификаты тоже уже внутри.

И .dockerignore — без него COPY . . тянет .git, тесты, IDE файлы.

А какой 4 уровень? 😉
2
Насмотренность — это первый уровень

Видишь ошибку — обрабатываешь. Это базовый рефлекс. Но есть второй уровень: понимать, с чем именно работаешь.
Два примера из недавнего PR.

NATS MessageBatch

for msg := range msgs.Messages() { ... }
// msgs.Error() — не вызвали

Итерация прошла, всё обработали. Но MessageBatch — не просто итератор. У него есть Error(), который нужно проверить после цикла: батч мог завершиться с ошибкой, и ты об этом не узнаешь. Не думай, что у типа одно поле и один метод — смотри что там есть.

Два момента pgxpool.NewWithConfig
pool, _ := pgxpool.NewWithConfig(context.Background(), config)


Первый — проглоченная ошибка, классика. Ошибку можно не обрабатывать, если ты понимаешь в каких случаях она возникает. И в go, лучше её обработать несмотря ни на что.

Второй интереснее: зачем функции контекст, если Background() и так работает? Потому что при инициализации пул реально устанавливает соединения (MinConns). Контекст позволяет это отменить. Раз параметр есть — значит на него что-то завязано. Не думай, что всё хорошо по умолчанию.
1👍1
Тимлид: роль или название должности

Крайние посты всё чаще о подходах к разработке. Причина — в команде всё чаще спорим: как решать, почему именно так. По обычаю, вопросы такие поднимаю я.

Не могу назвать себя лидом без оговорки — есть старший коллега, к кому идёшь в спорных ситуациях. Честнее сказать: я сопровождаю команду бэкенда.

Но взгляд на проект у меня уже другой.

Вывод №1. В первом проекте подход был — делать максимально сложно. Redis? Дайте два. Сложный запрос? Почему бы и нет. Чтобы потом ходить рассказывать: я работал с тем и этим.

Сейчас — чем проще, тем лучше. Понимаешь бизнес-задачу, ориентируешься на ожидания заказчика — и не закладываешь функционал на будущее.
«Совершенство достигнуто не тогда, когда нечего добавить, а тогда, когда нечего убрать»
— Антуан де Сент-Экзюпери, «Планета людей»


Вывод №2. Типизируй. В Go есть такая возможность — пользуйся. Когда данные ходят между функциями, структура вместо россыпи параметров ловит ошибки ещё до компиляции. В команде джунов это особенно заметно.

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

Тимлид меньше пишет код и больше смотрит на проект целиком. Когнитивную нагрузку от этого не передать.
#тимлид #go #бэкенд #разработка #команда
1
Написал статью на Хабр — про публичный мок-собес по АА-секции от Яндекс Практикума.

Формально это рекламная активность курсов. По факту — опыт, который никакой подготовкой не заменить.

3 недели, ~40 решённых задач с нуля и много пройденных заново, наконец ушёл от решений «в лоб» к пониманию паттернов. А на созвоне дали array длины 6 и k=40 — и код, который прошёл первый кейс, на втором ушёл в панику. Раннего выхода на k > len(nums) просто не было.

Оказалось, что АА-секция проверяет не только знание алгоритмов — она проверяет, видишь ли ты собственные слепые пятна, когда решение уже «работает».

Статья: https://habr.com/ru/articles/1059542/
👍31
Прямой переворот интуиции

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

За последнюю неделю поймал так два бага у себя. Не в момент ревью своего кода — разбирая чужой PR.

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

Первый баг: неделей раньше забыл прописать префикс /api/v1 в финальных роутах. Планировал вешать его через группу роутера, но из-за разных обёрток (rate limiter в одних местах, auth в других) решил подождать, пока все ручки устаканятся — и забыл вписать префикс в итоге. Код уже в main, готовимся к деплою.

Второй, там же — увидел в чужом PR, как собирается URL внутри модуля, и вспомнил, что у меня не так:

verifyURL := au.cfg.FrontendURL +
"/verify-email?token=" +
base64.RawURLEncoding.EncodeToString(tokenRaw)


Функции нужен был только токен — сборка url была не её зоной ответственности:

verifyURL := base64.RawURLEncoding.EncodeToString(tokenRaw)


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

Это цена мелкой ошибки — на уровне пары строк. Что бывает, когда без ревью в main проходят решения покрупнее — отдельная история.
#go #codereview #backend #разработка #процессы #инженерка
👍2
Рефакторинг на ровном месте

У нас в main оказалось два хелпера для работы с контекстом — и часть ручек из-за этого не работает.

Логгеру нужен был request_id из middleware — прокидывать его в slog.With без контекста не получится, встал вопрос хелпера.

Параллельно я делал auth middleware — тоже клал в контекст, только role и user_id. Задача с логгером подвисла, моя — нет. Я залил свой пакет первым, не дожидаясь хелпера коллеги, которого ещё не было в main.

Позже коллега смёржил логгер — с собственной реализацией контекста, той же проблемой в зеркале: моего пакета к тому моменту уже не заметил.

Не разгильдяйство — просто у параллельной работы своя цена, и в этот раз её заплатили две одинаковые реализации одного хелпера.

Юнит-тесты — зелёные, каждый написан под свой пакет. Но использование контекста в них разное: там, где код ожидает один формат, получает другой — часть данных не прочитать. E2E-тестов, которые прогнали бы запрос через оба варианта, нет.

Будь ревьюеров больше — шанс поймать это раньше был бы выше, до того как поверх обоих пакетов выросли новые задачи.

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

#go #codereview #backend #архитектура
💯1