Форум программистов, компьютерный форум, киберфорум
golander
Войти
Регистрация
Восстановить пароль

Создаем микросервисы с Go и Kubernetes

Запись от golander размещена 02.07.2025 в 19:49
Показов 5811 Комментарии 0

Нажмите на изображение для увеличения
Название: Создаем микросервисы с Go и Kubernetes.jpg
Просмотров: 119
Размер:	156.1 Кб
ID:	10950
Когда я только начинал с микросервисами, все спорили о том, какой язык юзать. Сейчас Go (или Golang) фактически захватил эту нишу. И вот почему этот язык настолько заходит для этих задач:
  • Конкурентность в Go — это бомба. Горутины и каналы вшиты в ядро языка, а не приколочены сверху как в других языках. Помню, написал сервис, который легко держал 100к соеденений на средненьком железе. На Java такое без адской настройки и тюнинга нереально сделать.
  • Go проще некуда — показал код новичку и через 15 минут он уже сечет фишку. Никаких выносящих мозг абстракций. И самое вкусное — на выходе один бинарник, который не требует никаких зависимостей. Закинул в контейнер — и погнали.
  • У Go есть и косяки, конечно. Обработка ошибок с этим вечным if err != nil уже стала мемом. Но для микросервисов это не так критично.
Теперь о Kubernetes. Почему он, а не что-то другое? K8s — это как крутой комбайн для контейнеров. Он сам масштабирует сервисы под нагрузкой, перезапускает упавшие поды, обновляет версии без даунтайма. Я хорошо помню, как было до K8s — скрипты, костыли, ночные алерты. С Kubernetes большая часть этого ада просто исчезла.

У K8s есть конкуренты. Docker Swarm проще въехать, Nomad легче, AWS ECS лучше интегрируется с экосистемой Amazon. Но ни один не предлагает такого функционала и такой поддержки комьюнити.

На одном финтех-проекте связка Go+Kubernetes позволила нам запустить 30 микросервисов за 3 месяца и спокойно держать нагрузку в 10 млн запросов в день. При этом Go обычно обгоняет Java в 2-3 раза, а Node.js и Python — в 5-10 раз, кушая при этом меньше оперативки. Это не значит, что Go и Kubernetes — универсальное решение всех проблем. Для простых монолитов такой стек будет оверкилом. Но для новых микросервисных проектов с высокими требованиями к перформансу и скейлингу — это золотой стандарт.

Архитектура микросервисного приложения



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

Декомпозиция монолита — первая боль, с которой сталкиваются разработчики. Я неоднократно видел, как люди просто рубят систему по контроллерам — и получают микросервисный ад. Правильный подход — выделение по бизнес-доменам. Например, в e-commerce это отдельные сервисы для каталога, корзины, платежей, уведомлений.

Domain-Driven Design тут очень к месту. Если вы не можете объяснить назначение сервиса одним предложением — вероятно, его границы определены неверно. Каждый сервис должен владеть своими данными целиком, а не дергать соседей по любому поводу.

Общение между сервисами — это отдельный квест. Тут два основных подхода: синхронный (REST, gRPC) и асинхронный (очереди сообщений). Я часто использую комбинацию: gRPC для критичных по времени запросов и Kafka или RabbitMQ для остального.

Go
1
2
3
4
5
6
7
8
9
10
// Пример клиента gRPC на Go
conn, err := grpc.Dial("user-service:50051", grpc.WithInsecure())
if err != nil {
    log.Fatalf("не удалось подключиться: %v", err)
}
defer conn.Close()
client := pb.NewUserServiceClient(conn)
 
// Вызов метода
resp, err := client.GetUser(context.Background(), &pb.GetUserRequest{Id: userID})
С gRPC в Go все просто и быстро работает, но важно настроить таймауты и ретраи. Иначе каскадные сбои вам обеспечены — когда один сервис падает и тянет за собой всю систему.
Для обработки ошибок в распределенных системах я придерживаюсь паттерна Circuit Breaker. Это как автомат в щитке — вырубает цепь при перегрузке.

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Упрощенный Circuit Breaker
type CircuitBreaker struct {
    failures int
    maxFailures int
    resetTimeout time.Duration
    lastFailure time.Time
    state string // "closed", "open", "half-open"
}
 
func (cb *CircuitBreaker) Execute(req func() error) error {
    if cb.state == "open" {
        if time.Since(cb.lastFailure) > cb.resetTimeout {
            cb.state = "half-open"
        } else {
            return errors.New("circuit open")
        }
    }
    
    err := req()
    if err != nil {
        cb.failures++
        cb.lastFailure = time.Now()
        if cb.failures >= cb.maxFailures {
            cb.state = "open"
        }
        return err
    }
    
    if cb.state == "half-open" {
        cb.state = "closed"
    }
    cb.failures = 0
    return nil
}
Транзакции в микросервисах — это особая головная боль. В монолите все просто: BEGIN TRANSACTION и все дела. В распределенной системе транзакция может затрагивать несколько сервисов с разными базами данных. Двухфазный коммит технически возможен, но на практике редко используется из-за проблем с производительностю и блокировками. Чаще применяют паттерн Saga — последовательность локальных транзакций с компенсирующими действиями в случае ошибок. Например, представим процесс заказа в интернет-магазине: пользователь делает заказ → списываются деньги → резервируется товар → отправляется уведомление. Если на любом шаге произошла ошибка, нужно откатить все предыдущие действия. В коде это выглядит примерно так:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Реализация Saga-паттерна
type OrderSaga struct {
    orderService    *OrderService
    paymentService  *PaymentService
    inventoryService *InventoryService
    notificationService *NotificationService
}
 
func (s *OrderSaga) Execute(ctx context.Context, orderData OrderData) error {
    // Шаг 1: Создаем заказ
    order, err := s.orderService.CreateOrder(ctx, orderData)
    if err != nil {
        return err
    }
    
    // Шаг 2: Списываем деньги
    payment, err := s.paymentService.ProcessPayment(ctx, order.ID, orderData.Amount)
    if err != nil {
        // Компенсирующее действие: отменяем заказ
        s.orderService.CancelOrder(ctx, order.ID)
        return err
    }
    
    // Шаг 3: Резервируем товар
    err = s.inventoryService.ReserveItems(ctx, order.ID, orderData.Items)
    if err != nil {
        // Компенсирующие действия: возвращаем деньги и отменяем заказ
        s.paymentService.RefundPayment(ctx, payment.ID)
        s.orderService.CancelOrder(ctx, order.ID)
        return err
    }
    
    // Шаг 4: Отправляем уведомление
    err = s.notificationService.SendOrderConfirmation(ctx, order.ID)
    // Даже если отправка уведомления не удалась, заказ уже создан
    // Можно просто залогировать ошибку
    
    return nil
}
Есть две разновидности Saga: хореография и оркестрация. В хореографии сервисы общаются через события — каждый публикует события и подписывается на события других. В оркестрации есть центральный координатор, который вызывает сервисы в нужном порядке. Оба подхода имеют право на жизнь, но для сложных бизнес-процессов я предпочитаю оркестрацию, она проще отлаживается.

Еще один важный аспект архитектуры — согласованость данных. В микросервисах мы часто жертвуем немедленной согласованостью (как в ACID-транзакциях) в пользу итоговой согласованности (eventual consistency). Это означает, что в какие-то моменты времени данные могут быть несогласованны, но в конечном итоге приходят к согласованому состоянию.

Это ведет к тому, что архитектуру микросервисов часто строят вокруг событий. Event-Driven Architecture позволяет сервисам слабо связываться друг с другом через события, а не через прямые вызовы.

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Пример с использованием NATS
nc, _ := nats.Connect(nats.DefaultURL)
defer nc.Close()
 
// Подписка на события
nc.Subscribe("user.created", func(m *nats.Msg) {
    var user User
    json.Unmarshal(m.Data, &user)
    // Обработка события о создании пользователя
})
 
// Публикация события
user := User{ID: "123", Name: "Алексей"}
data, _ := json.Marshal(user)
nc.Publish("user.created", data)
Отдельно стоит упомянуть Command Query Responsibility Segregation (CQRS). Эта шртука разделяет операции чтения и записи, что позволяет оптимизировать их независимо. Например, служба заказов может использовать PostgreSQL для записи, но читать данные из ElasticSearch, куда они попадают через Kafka.

Еще один сложный момент — управление секретами. В монолите можно было просто положить файл .env в корень проекта и забыть. В микросервисном мире так не получится. Для этого часто используют Vault или Kubernetes Secrets.

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Получение секрета из K8s
config, err := rest.InClusterConfig()
if err != nil {
    log.Fatal(err)
}
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
    log.Fatal(err)
}
 
secret, err := clientset.CoreV1().Secrets("default").Get(context.TODO(), "api-keys", metav1.GetOptions{})
if err != nil {
    log.Fatal(err)
}
 
apiKey := string(secret.Data["api-key"])
И последнее, но не менее важное — мониторинг и трейсинг. В микросервисах отладка становится настоящим квестом. Запрос проходит через десяток сервисов, и понять, где он застрял — та еще задачка. Здесь на помощь приходят системы распределенной трассировки вроде Jaeger или Zipkin и метрики Prometheus.

В Go легко добавить распределенную трассировку через OpenTelemetry:

Go
1
2
3
4
5
6
7
8
9
10
// Пример трейсинга с OpenTelemetry
tracer := global.Tracer("order-service")
ctx, span := tracer.Start(ctx, "process-order")
defer span.End()
 
// Добавление метаданных
span.SetAttributes(attribute.String("order.id", orderID))
 
// Вызов другого сервиса с передачей контекста
resp, err := inventoryClient.CheckStock(ctx, &pb.CheckStockRequest{ItemID: itemID})

Запуск docker образа в kubernetes
Контейнер в docker запускаю так: docker run --cap-add=SYS_ADMIN -ti -e "container=docker" -v...

Деплой телеграм бота на Google Kubernetes Engine через GitLab CI
Доброго времни суток. Прошу помощи у форумчан тк. сам не могу разобраться. Как задеплоить бота на...

Возможно ли поднять в kubernetes proxy
Задача. Дано: На роутере настроены 10 ip-адресов внешних от провайдера. На сервере vmware поднято...

Nginx + Kubernetes
Добрый день всем! Я решил попробовать использовать Kubernetes. Вот что я сделал на текущий...


Создание базового сервиса на Go



Когда начинаешь писать микросервис на Go, первое, с чем сталкиваешься — это структура проекта. В отличие от Java или C# с их жесткими фреймворками, Go дает свободу. Я предпочитаю примерно такой формат:

Go
1
2
3
4
5
6
7
8
9
10
/cmd            # Точки входа
  /api          # HTTP API сервис
  /worker       # Фоновые задачи
/internal       # Приватный код
  /domain       # Бизнес-модели и интерфейсы
  /repository   # Доступ к данным
  /service      # Бизнес-логика
  /api          # API обработчики
/pkg            # Переиспользуемые пакеты
/config         # Конфигурация
Конечно, это не догма. Но мне нравится разделять код на слои — так его проще тестировать. Кстати, Go с его встроенной поддержкой тестирования делает модульные тесты приятными в написании.
Начнем с точки входа. Создадим простой HTTP-сервер, который будет нашим микросервисом:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package main
 
import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
 
    "github.com/gorilla/mux"
)
 
func main() {
    r := mux.NewRouter()
    
    // Регистрируем роуты
    r.HandleFunc("/api/v1/books", getBooks).Methods(http.MethodGet)
    r.HandleFunc("/api/v1/books/{id}", getBook).Methods(http.MethodGet)
    
    // Добавляем middleware
    r.Use(loggingMiddleware)
    
    // Создаем HTTP сервер
    srv := &http.Server{
        Addr:    ":8080",
        Handler: r,
    }
    
    // Запускаем сервер в горутине
    go func() {
        log.Printf("Сервер запущен на %s", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Ошибка при запуске сервера: %v", err)
        }
    }()
    
    // Канал для сигналов
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    
    // Блокируемся до получения сигнала
    <-quit
    log.Println("Получен сигнал остановки, завершаем работу...")
    
    // Graceful shutdown
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("Ошибка при остановке сервера: %v", err)
    }
    
    log.Println("Сервер остановлен")
}
Тут важный момент — graceful shutdown. Это позволяет серверу корректно завершить текущие запросы перед выключением. В K8s особенно важно, т.к. поды могут перезапускаться в любой момент.
Дальше добавим middleware для логирования:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // Оборачиваем ResponseWriter для перехвата статус-кода
        ww := NewWrappedResponseWriter(w)
        
        // Вызываем следующий обработчик
        next.ServeHTTP(ww, r)
        
        // Логируем информацию о запросе
        log.Printf(
            "%s %s %d %s",
            r.Method,
            r.RequestURI,
            ww.Status(),
            time.Since(start),
        )
    })
}
 
// WrappedResponseWriter для отслеживания статус-кода
type WrappedResponseWriter struct {
    http.ResponseWriter
    statusCode int
}
 
func NewWrappedResponseWriter(w http.ResponseWriter) *WrappedResponseWriter {
    return &WrappedResponseWriter{w, http.StatusOK}
}
 
func (ww *WrappedResponseWriter) Status() int {
    return ww.statusCode
}
 
func (ww *WrappedResponseWriter) WriteHeader(code int) {
    ww.statusCode = code
    ww.ResponseWriter.WriteHeader(code)
}
Теперь давайте глянем на обработчики. В Go очень удобно работать с JSON:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type Book struct {
    ID     string `json:"id"`
    Title  string `json:"title"`
    Author string `json:"author"`
}
 
var books = []Book{
    {ID: "1", Title: "Война и мир", Author: "Лев Толстой"},
    {ID: "2", Title: "Преступление и наказание", Author: "Федор Достоевский"},
}
 
func getBooks(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(books)
}
 
func getBook(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    
    // Получаем ID из URL
    vars := mux.Vars(r)
    id := vars["id"]
    
    for _, book := range books {
        if book.ID == id {
            json.NewEncoder(w).Encode(book)
            return
        }
    }
    
    // Если книга не найдена
    w.WriteHeader(http.StatusNotFound)
    json.NewEncoder(w).Encode(map[string]string{"error": "Книга не найдена"})
}
Но в реальных проектах нужна валидация входных данных. Вот пример с добавлением книги:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func createBook(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    
    var book Book
    decoder := json.NewDecoder(r.Body)
    if err := decoder.Decode(&book); err != nil {
        w.WriteHeader(http.StatusBadRequest)
        json.NewEncoder(w).Encode(map[string]string{"error": "Невалидный JSON"})
        return
    }
    
    // Простая валидация
    if book.Title == "" || book.Author == "" {
        w.WriteHeader(http.StatusBadRequest)
        json.NewEncoder(w).Encode(map[string]string{"error": "Название и автор обязательны"})
        return
    }
    
    // Генерируем новый ID
    book.ID = strconv.Itoa(len(books) + 1)
    
    // Добавляем книгу
    books = append(books, book)
    
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(book)
}
В реальных проектах для валидации входных данных я обычно использую библиотеку go-playground/validator. Она избавляет от тонны бойлерплейт-кода:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type CreateBookRequest struct {
    Title  string `json:"title" validate:"required,min=1,max=100"`
    Author string `json:"author" validate:"required,min=1,max=50"`
    Year   int    `json:"year" validate:"required,gt=0,lt=2100"`
}
 
func createBookWithValidator(w http.ResponseWriter, r *http.Request) {
    var req CreateBookRequest
    
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        respondWithError(w, http.StatusBadRequest, "Некорректный JSON")
        return
    }
    
    validate := validator.New()
    if err := validate.Struct(req); err != nil {
        errors := err.(validator.ValidationErrors)
        respondWithError(w, http.StatusBadRequest, fmt.Sprintf("Ошибка валидации: %v", errors))
        return
    }
    
    // Теперь создаем книгу...
}
Для аутентификации и авторизации я обычно использую JWT-токены. Это хорошо работает в микросервисной архитектуре, поскольку позволяет не хранить сессии:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        tokenString := r.Header.Get("Authorization")
        if tokenString == "" {
            w.WriteHeader(http.StatusUnauthorized)
            json.NewEncoder(w).Encode(map[string]string{"error": "Требуется авторизация"})
            return
        }
        
        // Удаляем префикс "Bearer "
        tokenString = strings.TrimPrefix(tokenString, "Bearer ")
        
        // Парсим токен
        token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
            // Проверяем метод подписи
            if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("неожиданный метод подписи: %v", token.Header["alg"])
            }
            
            // Возвращаем ключ для проверки подписи
            return []byte("ваш-секретный-ключ"), nil
        })
        
        if err != nil || !token.Valid {
            w.WriteHeader(http.StatusUnauthorized)
            json.NewEncoder(w).Encode(map[string]string{"error": "Невалидный токен"})
            return
        }
        
        // Извлекаем claims
        claims, ok := token.Claims.(jwt.MapClaims)
        if !ok {
            w.WriteHeader(http.StatusUnauthorized)
            json.NewEncoder(w).Encode(map[string]string{"error": "Невалидные claims"})
            return
        }
        
        // Добавляем userID в контекст запроса
        userID := claims["sub"].(string)
        ctx := context.WithValue(r.Context(), "userID", userID)
        
        // Передаем запрос дальше
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
Когда дело доходит до логирования, простого log.Print явно не хватает. Для продакшн-систем рекомендую использовать структурированое логирование с уровнями, например с помощью библиотеки zap от Uber:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package main
 
import (
    "go.uber.org/zap"
    "net/http"
    "time"
)
 
var logger *zap.Logger
 
func initLogger() {
    var err error
    
    // В продакшене используем Production конфиг
    // В разработке - Development для более читаемых логов
    if os.Getenv("ENV") == "production" {
        logger, err = zap.NewProduction()
    } else {
        logger, err = zap.NewDevelopment()
    }
    
    if err != nil {
        panic("не удалось инициализировать логгер: " + err.Error())
    }
}
 
func loggingMiddlewareWithZap(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        ww := NewWrappedResponseWriter(w)
        next.ServeHTTP(ww, r)
        
        latency := time.Since(start)
        
        // Структурированный лог с полями
        logger.Info("HTTP запрос",
            zap.String("method", r.Method),
            zap.String("url", r.URL.String()),
            zap.Int("status", ww.Status()),
            zap.Duration("latency", latency),
            zap.String("user-agent", r.UserAgent()),
            zap.String("remote-addr", r.RemoteAddr),
        )
    })
}
Для конфигурации микросервиса я предпочитаю комбинацию переменных окружения и конфиг-файлов. Вот простой пример с библиотекой Viper:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package config
 
import (
    "github.com/spf13/viper"
    "log"
)
 
type Config struct {
    Server struct {
        Port int
        Host string
    }
    Database struct {
        Host     string
        Port     int
        User     string
        Password string
        Name     string
    }
    JWT struct {
        Secret string
        TTL    int // время жизни токена в минутах
    }
}
 
func LoadConfig(path string) (*Config, error) {
    viper.SetConfigFile(path)
    viper.AutomaticEnv() // Читаем переменные окружения
    
    // Значения по умолчанию
    viper.SetDefault("server.port", 8080)
    viper.SetDefault("server.host", "0.0.0.0")
    
    if err := viper.ReadInConfig(); err != nil {
        log.Printf("Предупреждение: не удалось прочитать конфиг файл: %v", err)
        // Продолжаем работу, используя переменные окружения и значения по умолчанию
    }
    
    var config Config
    if err := viper.Unmarshal(&config); err != nil {
        return nil, err
    }
    
    return &config, nil
}
А для мониторинга в Go-микросервисах хорош Prometheus с его простым API:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package main
 
import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
    "net/http"
)
 
var (
    httpRequestsTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Количество HTTP запросов",
        },
        []string{"method", "endpoint", "status"},
    )
    
    httpRequestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "Длительность HTTP запросов в секундах",
            Buckets: []float64{0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10},
        },
        []string{"method", "endpoint"},
    )
)
 
func init() {
    // Регистрируем метрики
    prometheus.MustRegister(httpRequestsTotal)
    prometheus.MustRegister(httpRequestDuration)
}
 
// Middleware для сбора метрик
func metricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        ww := NewWrappedResponseWriter(w)
        next.ServeHTTP(ww, r)
        
        duration := time.Since(start).Seconds()
        
        // Инкрементируем счетчик запросов
        httpRequestsTotal.WithLabelValues(r.Method, r.URL.Path, fmt.Sprintf("%d", ww.Status())).Inc()
        
        // Записываем длительность запроса
        httpRequestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration)
    })
}

Контейнеризация и Docker-образы



Если микросервисная архитектура — фундамент современной разработки, то контейнеры — это материал, из которого строится этот фундамент. Не представляю, как мы раньше жили без Docker. Помню, давал задание джуну настроить окружение — в итоге два дня проблем с зависимостями и "у меня локально работает". С Docker таких проблем нет: запаковал приложение со всеми зависимостями — и хоть на луну отправляй, будет работать.

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

Go
1
2
3
4
5
6
7
8
9
10
11
FROM golang:1.20
 
WORKDIR /app
 
COPY . .
 
RUN go build -o main .
 
EXPOSE 8080
 
CMD ["./main"]
Но это не лучший вариант для продакшена. Такой образ будет весить под гигабайт, т.к. тащит весь Go SDK. Плюс еще вопросы с безопасностью. Поэтому для реальных проектов я использую многоэтапную сборку:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Этап сборки
FROM golang:1.20-alpine AS builder
 
WORKDIR /app
 
# Копируем только файлы, необходимые для загрузки модулей
COPY go.mod go.sum ./
RUN go mod download
 
# Копируем исходный код
COPY . .
 
# Компилируем с отключеными отладочными символами
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o app cmd/api/main.go
 
# Финальный образ
FROM alpine:3.18
 
# Добавляем CA-сертификаты и timezone для корректной работы с HTTPS и временем
RUN apk --no-cache add ca-certificates tzdata
 
WORKDIR /app
 
# Копируем скомпилированое приложение из предыдущего этапа
COPY --from=builder /app/app .
 
# Копируем файлы конфигурации, если нужно
COPY --from=builder /app/configs ./configs
 
# Запускаем как непривилегированый пользователь
RUN adduser -D appuser
USER appuser
 
EXPOSE 8080
 
CMD ["./app"]
Такой подход дает нам контейнер размером 10-20 МБ вместо гигабайта. Да, я не ошибся — в 50-100 раз меньше! Это имеет массу преимуществ: быстрее скачивается, меньше поверхность для атак, меньше нагрузка на реестр образов.

Обратите внимание на флаги компиляции: -ldflags="-w -s". Они отключают отладочную информацию, уменьшая размер бинарника. А CGO_ENABLED=0 гарантирует, что приложение не будет зависеть от libc, что важно для Alpine-образов. Еще один трюк для оптимизации — использование дистрибутива scratch вместо Alpine:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
# Финальный этап
FROM scratch
 
# Копируем файлы CA сертификатов из промежуточного образа
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
 
# Копируем скомпилированое приложение
COPY --from=builder /app/app /app
 
EXPOSE 8080
 
# Запускаем приложение
CMD ["/app"]
С scratch мы получаем абсолютный минимум — только наш бинарник и необходимые сертификаты. Но у этого подхода есть минус: в таком контейнере нет shell, отладочных утилит, даже команды ls. Если что-то пойдет не так, отлаживать будет сложно. Поэтому я чаще выбираю Alpine, жертвуя несколькими мегабайтами ради удобства отладки.

Безопасность контейнеров — отдельная большая тема. Первое и главное правило: никогда не запускайте контейнеры от рута! Видите строчку USER appuser в Dockerfile? Это не просто так. Если злоумышленик получит доступ к контейнеру, запущеному от root, и сможет из него вырваться, он получит root-доступ к хосту.

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

Go
1
2
3
4
5
# Плохой пример
RUN apt-get update && apt-get install -y curl htop vim netcat
 
# Хороший пример - устанавливаем только то, что действительно нужно
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates
Для выявления известных уязвимостей в образах используйте инструменты типа Trivy, Clair или Docker Scout. Я обычно интегрирую сканирование в CI/CD пайплайн:

YAML
1
2
3
4
5
6
7
8
# GitLab CI пример
scan-image:
  stage: security
  image: aquasec/trivy
  script:
    - trivy image --severity HIGH,CRITICAL myapp:latest
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
Если в вашей компании строгая политика безопасности, можно настроить блокировку сборки при обнаружении критических уязвимостей.
Для развертывания на разных архитектурах (x86 и ARM) Docker поддерживает multi-platform сборки. Это особено актуально сейчас с ростом популярности процессоров ARM, вроде AWS Graviton или Apple Silicon:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Многоплатформенная сборка с помощью BuildKit
FROM --platform=$BUILDPLATFORM golang:1.20-alpine AS builder
 
ARG TARGETPLATFORM
ARG BUILDPLATFORM
 
# Определяем архитектуру и ОС для компиляции
RUN echo "Building on $BUILDPLATFORM for $TARGETPLATFORM"
 
WORKDIR /app
COPY . .
 
# Настраиваем GOOS и GOARCH на основе TARGETPLATFORM
RUN \
  if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
    GOOS=linux GOARCH=amd64 go build -o app .; \
  elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
    GOOS=linux GOARCH=arm64 go build -o app .; \
  else \
    echo "Unsupported platform: $TARGETPLATFORM"; \
    exit 1; \
  fi
 
FROM --platform=$TARGETPLATFORM alpine:3.18
COPY --from=builder /app/app /app
CMD ["/app"]
Для сборки такого образа используем:

Bash
1
docker buildx build --platform linux/amd64,linux/arm64 -t myapp:latest --push .
BuildKit (встроеный в новые версии Docker) автоматически запустит две сборки и создаст манифест, который позволит Docker выбирать правильный образ в зависимости от архитектуры.

Чтобы автоматизировать сборку контейнеров, без правильного CI/CD процесса не обойтись. На проде мы используем GitLab CI, и вот как выглядит наш типичный пайплайн для микросервиса на Go:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
stages:
  - test
  - build
  - scan
  - deploy
 
variables:
  DOCKER_REGISTRY: registry.example.com
  IMAGE_NAME: $DOCKER_REGISTRY/myapp
  IMAGE_TAG: $CI_COMMIT_SHORT_SHA
 
# Запуск тестов
test:
  stage: test
  image: golang:1.20
  script:
    - go test -v -race ./...
  only:
    - merge_requests
    - main
 
# Сборка Docker образа
build:
  stage: build
  image: docker:24.0
  services:
    - docker:24.0-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $DOCKER_REGISTRY
    - docker build -t $IMAGE_NAME:$IMAGE_TAG .
    - docker tag $IMAGE_NAME:$IMAGE_TAG $IMAGE_NAME:latest
    - docker push $IMAGE_NAME:$IMAGE_TAG
    - docker push $IMAGE_NAME:latest
  only:
    - main
 
# Сканирование образа на уязвимости
scan:
  stage: scan
  image: aquasec/trivy
  script:
    - trivy image --exit-code 1 --severity CRITICAL $IMAGE_NAME:$IMAGE_TAG
  only:
    - main
 
# Деплой в Kubernetes
deploy:
  stage: deploy
  image: bitnami/kubectl
  script:
    - kubectl config use-context production
    - sed -i "s|IMAGE_TAG|$IMAGE_TAG|g" k8s/deployment.yaml
    - kubectl apply -f k8s/deployment.yaml
  only:
    - main
Тут каждый этап выполняет свою задачу: сначала гоняем тесты, потом собираем Docker-образ, сканируем его на уязвимости и наконец делаем деплой в Kubernetes. Фишка в том, что используем тэг на основе хэша коммита — так мы всегда можем точно соотнести версию кода с версией образа.
Для GitHub Actions пайплайн будет похожим:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
name: Build and Deploy
 
on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-go@v4
      with:
        go-version: '1.20'
    - name: Run tests
      run: go test -v -race ./...
 
  build:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v2
    
    - name: Login to Docker Registry
      uses: docker/login-action@v2
      with:
        registry: ${{ secrets.DOCKER_REGISTRY }}
        username: ${{ secrets.DOCKER_USERNAME }}
        password: ${{ secrets.DOCKER_PASSWORD }}
    
    - name: Build and push
      uses: docker/build-push-action@v4
      with:
        context: .
        push: true
        platforms: linux/amd64,linux/arm64
        tags: |
          ${{ secrets.DOCKER_REGISTRY }}/myapp:latest
          ${{ secrets.DOCKER_REGISTRY }}/myapp:${{ github.sha }}
Из личного опыта могу сказать, что кэширование сборок очень сильно ускоряет пайплайн. Вот как это можно настроить:

YAML
1
2
3
4
build:
  # ...
  script:
    - docker buildx build --cache-from type=registry,ref=$IMAGE_NAME:buildcache --cache-to type=registry,ref=$IMAGE_NAME:buildcache,mode=max -t $IMAGE_NAME:$IMAGE_TAG --push .
Этот подход позволяет переиспользовать слои между сборками, что особенно важно для Go с его зависимостями — не нужно скачивать их каждый раз заново.
А вот еще один лайфхак, который я использую в своих пайплайнах — версионирование API на основе git-тегов:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
build:
  # ...
  script:
    - VERSION=$(git describe --tags --abbrev=0 || echo "v0.0.0")
    - docker build --build-arg VERSION=$VERSION -t $IMAGE_NAME:$IMAGE_TAG .
[/GO]
 
И в Dockerfile:

[/GO]dockerfile
ARG VERSION=dev
# ...
ENV APP_VERSION=$VERSION
Так версия из гита автоматически попадает в приложение, и API может возвращать её в хедерах или эндпоинте /version.

Ещё один важный аспект контейнеризации — правильная работа с секретами. Никогда не хардкодьте пароли и ключи в Dockerfile! Вместо этого используйте BuildKit для монтирования секретов при сборке:

Go
1
2
3
# Пример с использованием SSH ключа для скачивания приватных зависимостей
RUN --mount=type=ssh mkdir -p ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts
RUN --mount=type=ssh go mod download
Для запуска сборки с SSH ключом:

Bash
1
docker buildx build --ssh default=$HOME/.ssh/id_rsa -t myapp .
В заключение хочу сказать: Docker и контейнеризация — не просто модное слово, а технология, которая кардинально меняет разработку микросервисов. Благодаря ей мы получаем воспроизводимые сборки, четкое разделение сред и возможность упаковать приложение в стандартизированный формат, с которым могут работать различные оркестраторы, в первую очередь — Kubernetes. Именно поэтому далее мы поговорим о развертывании наших контейнеров в Kubernetes.

Развертывание в Kubernetes



После того как мы упаковали наш микросервис в контейнер, пора заставить его работать в Kubernetes. Многие разработчики при первом знакомстве с K8s впадают в ступор от обилия терминов: поды, деплойменты, сервисы, ингрессы... Помню, как сам неделю пытался понять, почему мой под не стартует — а все из-за опечатки в YAML-манифесте. С тех пор я взял за правило использовать линтеры для K8s-манифестов.

Начнем с самого простого — Deployment. Это объект Kubernetes, который управляет жизненным циклом ваших подов:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
apiVersion: apps/v1
kind: Deployment
metadata:
  name: books-service
  namespace: default
spec:
  replicas: 3
  selector:
    matchLabels:
      app: books-service
  template:
    metadata:
      labels:
        app: books-service
    spec:
      containers:
      - name: books-service
        image: myregistry.com/books-service:1.0.0
        ports:
        - containerPort: 8080
        resources:
          requests:
            cpu: 100m
            memory: 128Mi
          limits:
            cpu: 500m
            memory: 256Mi
        readinessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 15
          periodSeconds: 20
Тут мы говорим Kubernetes: "эй, запусти 3 экземпляра этого контейнера, дай им ресурсы и проверяй, что они живы". Обратите внимание на readinessProbe и livenessProbe — это ключевые механизмы для отказоустойчивости. Readiness проверяет, готов ли сервис принимать трафик, а liveness — жив ли он вообще. Наш контейнер запущен, но до него не достучаться извне. Для этого создаем Service:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: Service
metadata:
  name: books-service
  namespace: default
spec:
  selector:
    app: books-service
  ports:
  - port: 80
    targetPort: 8080
  type: ClusterIP
Service работает как внутренний балансировщик и DNS-запись. Теперь внутри кластера можно обращаться по имени books-service.

Для конфигурации приложения используем ConfigMap:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: ConfigMap
metadata:
  name: books-config
  namespace: default
data:
  app.yaml: |
    server:
      port: 8080
    database:
      host: postgres
      port: 5432
      name: books
А для секретов — Secret:

YAML
1
2
3
4
5
6
7
8
9
apiVersion: v1
kind: Secret
metadata:
  name: books-secrets
  namespace: default
type: Opaque
data:
  db-user: YWRtaW4= # base64 для "admin"
  db-password: cGFzc3dvcmQxMjM= # base64 для "password123"
Никогда не храните секреты в репозитории! Лучше использовать инструменты вроде HashiCorp Vault или AWS Secret Manager, а в CI/CD подставлять их при деплое.

Для подключения ConfigMap и Secret к контейнеру:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
spec:
  containers:
  - name: books-service
    # ...
    env:
    - name: DB_USER
      valueFrom:
        secretKeyRef:
          name: books-secrets
          key: db-user
    - name: DB_PASSWORD
      valueFrom:
        secretKeyRef:
          name: books-secrets
          key: db-password
    volumeMounts:
    - name: config-volume
      mountPath: /app/configs
  volumes:
  - name: config-volume
    configMap:
      name: books-config
Теперь сделаем наш сервис доступным извне через Ingress:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: books-ingress
  namespace: default
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
  - host: books.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: books-service
            port:
              number: 80
Для автоматического масштабирования сервиса в зависимости от нагрузки используем Horizontal Pod Autoscaler (HPA):

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: books-hpa
  namespace: default
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: books-service
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
Этот манифест говорит: "держи от 2 до 10 реплик и добавляй новые, когда использование CPU превышает 70%". Можно настраивать и под другие метрики, включая пользовательские из Prometheus.

Чтобы защитить кластер от исчерпания ресурсов одним приложением, применяем ResourceQuota:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: ResourceQuota
metadata:
  name: books-quota
  namespace: default
spec:
  hard:
    requests.cpu: "4"
    requests.memory: 8Gi
    limits.cpu: "8"
    limits.memory: 16Gi
    pods: "20"
А для установки ограничений для отдельных контейнеров — LimitRange:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: default
spec:
  limits:
  - default:
      memory: 256Mi
      cpu: 500m
    defaultRequest:
      memory: 128Mi
      cpu: 100m
    type: Container
Теперь даже если разработчик забудет указать лимиты ресурсов, они будут установлены автоматически.
Для управления зависимостями в Kubernetes я использую Helm. Это как пакетный менеджер для K8s. Вместо кучи YAML-файлов получаем один чарт с шаблонами:

Bash
1
2
3
4
5
# Создаем Helm-чарт для нашего сервиса
helm create books-service
 
# Устанавливаем его в кластер
helm install books ./books-service --values production-values.yaml
Структура типичного Helm-чарта:

Go
1
2
3
4
5
6
7
8
9
books-service/
  Chart.yaml           # Метаданные чарта
  values.yaml          # Значения по умолчанию
  templates/           # YAML-шаблоны
    deployment.yaml
    service.yaml
    ingress.yaml
    configmap.yaml
    secret.yaml
В values.yaml храним настройки для разных окружений:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
replicaCount: 3
 
image:
  repository: myregistry.com/books-service
  tag: 1.0.0
 
resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 256Mi
 
ingress:
  enabled: true
  host: books.example.com
А в шаблонах используем эти значения:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}
spec:
  replicas: {{ .Values.replicaCount }}
  # ...
  template:
    # ...
    spec:
      containers:
      - name: {{ .Chart.Name }}
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
        # ...
Если при деплое что-то пошло не так, можно быстро откатиться:

Bash
1
helm rollback books 1  # Откат к первой ревизии
Для организации нескольких окружений в одном кластере используем Namespace:

YAML
1
2
3
4
5
6
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    name: production
И затем в каждом манифесте указываем:

YAML
1
2
metadata:
  namespace: production
Из личного опыта: в проде всегда настраивайте политики сетевой безопасности. Без них любой под может общаться с любым другим — это потенциальная дыра.

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: books-policy
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: books-service
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: api-gateway
    ports:
    - protocol: TCP
      port: 8080

Service Discovery и балансировка нагрузки



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

Service Discovery — это как телефонная книга для микросервисов. Сервис А хочет поговорить с сервисом Б, но не знает, где тот живет. Тут и появляется механизм обнаружения, который говорит: "Я знаю, где живет сервис Б, вот его адрес".

Kubernetes из коробки предоставляет крутой механизм обнаружения через свой объект Service. Это гениально просто: вы создаете Service, и K8s автоматически дает вам DNS-имя, которое можно использовать для связи с любым подом за этим сервисом.

YAML
1
2
3
4
5
6
7
8
9
10
apiVersion: v1
kind: Service
metadata:
  name: user-service
spec:
  selector:
    app: user-api
  ports:
  - port: 80
    targetPort: 8080
После применения этого манифеста любой под в кластере может обратиться к user-service или user-service.default.svc.cluster.local (полное имя), и запрос попадет на один из подов с лейблом app: user-api.

За кулисами творится магия. K8s создает эндпоинты, которые соответствуют подам, и постоянно обновляет их список. Если под умирает, эндпоинт исчезает, если появляется новый — добавляется. Внутренний DNS-сервер K8s (CoreDNS) знает об этих эндпоинтах и маршрутизирует запросы. Но тут есть нюанс. Когда вы обращаетесь к сервису, вы не знаете, на какой конкретно под попадете. Балансировка происходит по алгоритму round-robin. Чтобы это работало хорошо, важно настроить health checks:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
spec:
  containers:
  - name: user-api
    image: user-api:1.0
    readinessProbe:
      httpGet:
        path: /health
        port: 8080
      initialDelaySeconds: 5
      periodSeconds: 10
    livenessProbe:
      httpGet:
        path: /health
        port: 8080
      initialDelaySeconds: 15
      periodSeconds: 30
Readiness probe проверяет, готов ли сервис принимать трафик. Если нет — под исключается из балансировки. Liveness probe следит, жив ли сервис вообще. Если нет — перезапускает.
В Go реализация health endpoint обычно простая:

Go
1
2
3
4
5
func healthHandler(w http.ResponseWriter, r *http.Request) {
    // Тут можно добавить проверки БД, кеша и т.д.
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
Но если вы выходите за пределы одного кластера K8s или работаете в гибридной среде, встроенного Discovery может не хватить. Тут на помощь приходит Consul от HashiCorp.

Consul — это распределенное хранилище ключ-значение с встроеным Service Discovery. Каждый сервис регистрирует себя в Consul, указывая своё имя, адрес и порт. Другие сервисы могут запросить эту информацию. Вот как выглядит регистрация сервиса в Consul из Go:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Создаем клиент Consul
config := consulapi.DefaultConfig()
config.Address = "consul:8500"
client, err := consulapi.NewClient(config)
if err != nil {
    log.Fatalf("Ошибка при создании клиента Consul: %v", err)
}
 
// Регистрируем сервис
serviceID := "user-api-" + uuid.New().String()
registration := &consulapi.AgentServiceRegistration{
    ID:      serviceID,
    Name:    "user-api",
    Port:    8080,
    Address: getHostIP(),
    Check: &consulapi.AgentServiceCheck{
        HTTP:     "http://localhost:8080/health",
        Interval: "10s",
        Timeout:  "3s",
    },
}
 
if err := client.Agent().ServiceRegister(registration); err != nil {
    log.Fatalf("Ошибка при регистрации сервиса: %v", err)
}
 
// Не забываем дерегистрировать при завершении
defer client.Agent().ServiceDeregister(serviceID)
А так выглядит обнаружение:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Ищем сервис в Consul
services, _, err := client.Health().Service("user-api", "", true, nil)
if err != nil {
    log.Fatalf("Ошибка при поиске сервиса: %v", err)
}
 
if len(services) == 0 {
    log.Fatal("Сервис не найден")
}
 
// Выбираем случайный экземпляр
rand.Seed(time.Now().UnixNano())
service := services[rand.Intn(len(services))]
serviceAddr := fmt.Sprintf("%s:%d", service.Service.Address, service.Service.Port)
 
// Теперь можем обращаться к сервису
resp, err := http.Get("http://" + serviceAddr + "/users")
В рельном проекте я часто комбинирую K8s Service Discovery внутри кластера и Consul для межкластерного общения.

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

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: user-service
spec:
  hosts:
  - user-service
  http:
  - route:
    - destination:
        host: user-service-v1
        subset: v1
      weight: 90
    - destination:
        host: user-service-v2
        subset: v2
      weight: 10
Этот манифест направляет 90% трафика на версию v1 и 10% на версию v2 — идеально для канареечного релиза.

В заключение скажу: правильная настройка Service Discovery и балансировки — это ключ к отказоустойчивой системе. Не поленитесь потратить время на настройку health checks, ретраев и таймаутов. В микросервисной архитектуре отказы неизбежны, и ваша задача — сделать систему устойчивой к ним.

Полный пример работающего приложения



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

Go
1
2
3
4
5
/bookstore
  /user-service     # Сервис пользователей
  /order-service    # Сервис заказов
  /k8s              # Манифесты Kubernetes
  /monitoring       # Конфигурация мониторинга
Начнем с user-service. Основная функциональность - регистрация, авторизация и управление профилями пользователей:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// user-service/internal/domain/user.go
type User struct {
    ID        string    [INLINE]json:"id"[/INLINE]
    Email     string    [INLINE]json:"email"[/INLINE]
    Password  string    [INLINE]json:"-"[/INLINE]
    Name      string    [INLINE]json:"name"[/INLINE]
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}
 
// user-service/internal/repository/postgres_repository.go
func (r *PostgresRepository) GetUserByID(ctx context.Context, id string) (*domain.User, error) {
    var user domain.User
    query := `SELECT id, email, name, created_at, updated_at FROM users WHERE id = $1`
    err := r.db.QueryRowContext(ctx, query, id).Scan(
        &user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt,
    )
    if err == sql.ErrNoRows {
        return nil, domain.ErrUserNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("ошибка получения пользователя: %w", err)
    }
    return &user, nil
}
Теперь order-service, который общается с user-service через gRPC:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// order-service/internal/domain/order.go
type Order struct {
    ID        string    [INLINE]json:"id"[/INLINE]
    UserID    string    [INLINE]json:"user_id"[/INLINE]
    Items     []Item    [INLINE]json:"items"[/INLINE]
    Status    string    [INLINE]json:"status"[/INLINE]
    Total     float64   [INLINE]json:"total"[/INLINE]
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}
 
// order-service/internal/service/order_service.go
func (s *OrderService) CreateOrder(ctx context.Context, userID string, items []domain.Item) (*domain.Order, error) {
    // Проверяем существование пользователя через gRPC
    userResp, err := s.userClient.GetUser(ctx, &pb.GetUserRequest{Id: userID})
    if err != nil {
        return nil, fmt.Errorf("ошибка проверки пользователя: %w", err)
    }
    
    // Рассчитываем общую стоимость
    var total float64
    for _, item := range items {
        total += item.Price * float64(item.Quantity)
    }
    
    // Создаем заказ в БД
    order := &domain.Order{
        ID:        uuid.New().String(),
        UserID:    userID,
        Items:     items,
        Status:    "created",
        Total:     total,
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }
    
    if err := s.repo.CreateOrder(ctx, order); err != nil {
        return nil, err
    }
    
    // Отправляем событие в Kafka о создании заказа
    s.eventPublisher.PublishOrderCreated(order)
    
    return order, nil
}
Для баз данных используем миграции с golang-migrate:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// migrations/1_create_users_table.up.sql
CREATE TABLE users (
    id UUID PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    password TEXT NOT NULL,
    name TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
 
// migrations/2_create_orders_table.up.sql
CREATE TABLE orders (
    id UUID PRIMARY KEY,
    user_id UUID NOT NULL REFERENCES users(id),
    status TEXT NOT NULL,
    total DECIMAL(10, 2) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Для микросервисной архитектуры жизненно важен мониторинг. Я использую Prometheus для сбора метрик. В каждый сервис добавляю эндпоинт /metrics, который будет опрашивать Prometheus:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// common/metrics/metrics.go
package metrics
 
import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)
 
var (
    RequestCounter = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Количество HTTP запросов",
        },
        []string{"method", "endpoint", "status"},
    )
    
    RequestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "Длительность HTTP запросов",
            Buckets: prometheus.DefBuckets,
        },
        []string{"method", "endpoint"},
    )
    
    ActiveOrders = promauto.NewGauge(
        prometheus.GaugeOpts{
            Name: "active_orders",
            Help: "Количество активных заказов",
        },
    )
)
Конфигурация Prometheus в Kubernetes выглядит так:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# monitoring/prometheus-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: prometheus-config
data:
  prometheus.yml: |
    global:
      scrape_interval: 15s
    scrape_configs:
      - job_name: 'kubernetes-pods'
        kubernetes_sd_configs:
          - role: pod
        relabel_configs:
          - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
            action: keep
            regex: true
          - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
            action: replace
            target_label: __metrics_path__
            regex: (.+)
          - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
            action: replace
            regex: ([^:]+)(?::\d+)?;(\d+)
            replacement: $1:$2
            target_label: __address__
          - action: labelmap
            regex: __meta_kubernetes_pod_label_(.+)
          - source_labels: [__meta_kubernetes_namespace]
            action: replace
            target_label: kubernetes_namespace
          - source_labels: [__meta_kubernetes_pod_name]
            action: replace
            target_label: kubernetes_pod_name
Для интеграционного тестирования я использую библиотеку Testcontainers, которая позволяет запускать контейнеры прямо в тестах:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// order-service/internal/repository/postgres_repository_test.go
func TestOrderRepository(t *testing.T) {
    ctx := context.Background()
    
    // Запускаем PostgreSQL в Docker
    postgres, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: testcontainers.ContainerRequest{
            Image:        "postgres:13",
            ExposedPorts: []string{"5432/tcp"},
            Env: map[string]string{
                "POSTGRES_USER":     "test",
                "POSTGRES_PASSWORD": "test",
                "POSTGRES_DB":       "testdb",
            },
            WaitingFor: wait.ForLog("database system is ready to accept connections"),
        },
        Started: true,
    })
    if err != nil {
        t.Fatal(err)
    }
    defer postgres.Terminate(ctx)
    
    // Получаем хост и порт
    host, err := postgres.Host(ctx)
    if err != nil {
        t.Fatal(err)
    }
    port, err := postgres.MappedPort(ctx, "5432")
    if err != nil {
        t.Fatal(err)
    }
    
    // Подключаемся к БД
    dsn := fmt.Sprintf("host=%s port=%s user=test password=test dbname=testdb sslmode=disable", host, port.Port())
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        t.Fatal(err)
    }
    defer db.Close()
    
    // Применяем миграции
    migrate.Up(db)
    
    // Создаем репозиторий и тестируем
    repo := NewPostgresRepository(db)
    // ...тесты...
}
Визуализацию метрик делаю через Grafana. Вот пример дашборда на основе наших метрик:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# monitoring/grafana-dashboard.json
{
  "annotations": {
    "list": []
  },
  "editable": true,
  "gnetId": null,
  "graphTooltip": 0,
  "id": 1,
  "links": [],
  "panels": [
    {
      "aliasColors": {},
      "bars": false,
      "dashLength": 10,
      "dashes": false,
      "datasource": "Prometheus",
      "fieldConfig": {
        "defaults": {},
        "overrides": []
      },
      "fill": 1,
      "fillGradient": 0,
      "gridPos": {
        "h": 9,
        "w": 12,
        "x": 0,
        "y": 0
      },
      "hiddenSeries": false,
      "id": 2,
      "legend": {
        "avg": false,
        "current": false,
        "max": false,
        "min": false,
        "show": true,
        "total": false,
        "values": false
      },
      "lines": true,
      "linewidth": 1,
      "nullPointMode": "null",
      "options": {
        "alertThreshold": true
      },
      "percentage": false,
      "pluginVersion": "7.5.5",
      "pointradius": 2,
      "points": false,
      "renderer": "flot",
      "seriesOverrides": [],
      "spaceLength": 10,
      "stack": false,
      "steppedLine": false,
      "targets": [
        {
          "expr": "sum(rate(http_requests_total[5m])) by (endpoint)",
          "interval": "",
          "legendFormat": "{{endpoint}}",
          "refId": "A"
        }
      ],
      "thresholds": [],
      "timeFrom": null,
      "timeRegions": [],
      "timeShift": null,
      "title": "Запросы в минуту",
      "tooltip": {
        "shared": true,
        "sort": 0,
        "value_type": "individual"
      },
      "type": "graph",
      "xaxis": {
        "buckets": null,
        "mode": "time",
        "name": null,
        "show": true,
        "values": []
      },
      "yaxes": [
        {
          "format": "short",
          "label": null,
          "logBase": 1,
          "max": null,
          "min": null,
          "show": true
        },
        {
          "format": "short",
          "label": null,
          "logBase": 1,
          "max": null,
          "min": null,
          "show": true
        }
      ],
      "yaxis": {
        "align": false,
        "alignLevel": null
      }
    }
  ],
  "refresh": "5s",
  "schemaVersion": 27,
  "style": "dark",
  "tags": [],
  "templating": {
    "list": []
  },
  "time": {
    "from": "now-1h",
    "to": "now"
  },
  "timepicker": {},
  "timezone": "",
  "title": "Микросервисы",
  "uid": "microservices",
  "version": 1
}
Для полноценной микросервисной архитектуры нам нужно правильно организовать логирование. Когда у тебя один монолит, просто пишешь логи в файл и дело с концом. А вот с десятком распределенных сервисов такой номер не пройдет — логи будут разбросаны по разным подам, и когда что-то пойдет не так, придется перерывать их все вручную. Поэтому сразу поставим ELK Stack (Elasticsearch, Logstash, Kibana).

Начнем с настройки отправки логов из наших сервисов. Я использую библиотеку logrus с хуком для Elasticsearch:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// common/logging/elasticsearch.go
package logging
 
import (
  "github.com/olivere/elastic/v7"
  "github.com/sirupsen/logrus"
  "github.com/sirupsen/logrus/hooks/writer"
  "os"
  "time"
)
 
type ElasticsearchHook struct {
  client    *elastic.Client
  host      string
  index     string
  levels    []logrus.Level
  ctx       context.Context
  ctxCancel context.CancelFunc
  fireFunc  func(entry *logrus.Entry, hook *ElasticsearchHook) error
}
 
func NewElasticsearchHook(client *elastic.Client, host, index string, levels []logrus.Level) (*ElasticsearchHook, error) {
  ctx, cancel := context.WithCancel(context.Background())
  
  return &ElasticsearchHook{
      client:    client,
      host:      host,
      index:     index,
      levels:    levels,
      ctx:       ctx,
      ctxCancel: cancel,
      fireFunc:  syncFireFunc,
  }, nil
}
 
func (hook *ElasticsearchHook) Fire(entry *logrus.Entry) error {
  return hook.fireFunc(entry, hook)
}
 
func (hook *ElasticsearchHook) Levels() []logrus.Level {
  return hook.levels
}
 
func syncFireFunc(entry *logrus.Entry, hook *ElasticsearchHook) error {
  // Форматируем лог для Elasticsearch
  type LogEntry struct {
      Timestamp time.Time     [INLINE]json:"@timestamp"[/INLINE]
      Message   string        [INLINE]json:"message"[/INLINE]
      Level     string        [INLINE]json:"level"[/INLINE]
      Data      logrus.Fields `json:"data"`
  }
  
  logEntry := LogEntry{
      Timestamp: entry.Time,
      Message:   entry.Message,
      Level:     entry.Level.String(),
      Data:      entry.Data,
  }
  
  // Отправляем в Elasticsearch
  _, err := hook.client.Index().
      Index(hook.index + "-" + time.Now().Format("2006.01.02")).
      BodyJson(logEntry).
      Do(hook.ctx)
  
  return err
}
 
func SetupLogging(serviceName string, elasticsearchURL string) *logrus.Logger {
  logger := logrus.New()
  logger.SetFormatter(&logrus.JSONFormatter{})
  logger.SetOutput(os.Stdout)
  
  // Настраиваем хук для Elasticsearch
  if elasticsearchURL != "" {
      client, err := elastic.NewClient(
          elastic.SetURL(elasticsearchURL),
          elastic.SetSniff(false),
      )
      if err != nil {
          logger.Warnf("Не удалось подключиться к Elasticsearch: %v", err)
      } else {
          levels := []logrus.Level{
              logrus.PanicLevel,
              logrus.FatalLevel,
              logrus.ErrorLevel,
              logrus.WarnLevel,
              logrus.InfoLevel,
          }
          hook, err := NewElasticsearchHook(client, elasticsearchURL, serviceName, levels)
          if err != nil {
              logger.Warnf("Не удалось создать хук для Elasticsearch: %v", err)
          } else {
              logger.AddHook(hook)
          }
      }
  }
  
  return logger
}
В каждом сервисе мы инициализируем логгер:

Go
1
2
3
4
5
6
// order-service/cmd/main.go
func main() {
  // ...
  logger := logging.SetupLogging("order-service", os.Getenv("ELASTICSEARCH_URL"))
  // ...
}
Теперь настроим ELK Stack в Kubernetes:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# monitoring/elasticsearch.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: elasticsearch
spec:
serviceName: elasticsearch
replicas: 1
selector:
  matchLabels:
    app: elasticsearch
template:
  metadata:
    labels:
      app: elasticsearch
  spec:
    containers:
    - name: elasticsearch
      image: docker.elastic.co/elasticsearch/elasticsearch:7.14.0
      ports:
      - containerPort: 9200
        name: http
      - containerPort: 9300
        name: transport
      env:
      - name: discovery.type
        value: single-node
      - name: ES_JAVA_OPTS
        value: "-Xms512m -Xmx512m"
      volumeMounts:
      - name: data
        mountPath: /usr/share/elasticsearch/data
    volumes:
    - name: data
      emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: elasticsearch
spec:
selector:
  app: elasticsearch
ports:
port: 9200
  name: http
port: 9300
  name: transport
[/GO]
 
[/GO]yaml
# monitoring/kibana.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: kibana
spec:
replicas: 1
selector:
  matchLabels:
    app: kibana
template:
  metadata:
    labels:
      app: kibana
  spec:
    containers:
    - name: kibana
      image: docker.elastic.co/kibana/kibana:7.14.0
      ports:
      - containerPort: 5601
      env:
      - name: ELASTICSEARCH_URL
        value: [url]http://elasticsearch:9200[/url]
---
apiVersion: v1
kind: Service
metadata:
name: kibana
spec:
selector:
  app: kibana
ports:
port: 5601
  targetPort: 5601
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kibana-ingress
annotations:
  nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
host: kibana.example.com
  http:
    paths:
    - path: /
      pathType: Prefix
      backend:
        service:
          name: kibana
          port:
            number: 5601
Теперь если что-то пойдет не так, мы можем открыть Kibana и смотреть логи всех сервисов в одном месте. Это очень помогает, когда пытаешься выяснить, почему заказ не создался, а пользователю пришло уведомление об успешной оплате.

Интеграционные тесты в Kubernetes



Я часто сталкивался с ситуацией, когда все юнит-тесты проходят, а после деплоя в K8s система падает. Поэтому важно тестировать приложение прямо в кластере:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// tests/integration/order_test.go
package integration
 
import (
  "context"
  "encoding/json"
  "net/http"
  "testing"
  "time"
  
  "github.com/stretchr/testify/assert"
)
 
func TestCreateOrder(t *testing.T) {
  // Создаем тестового пользователя
  userReq := map[string]interface{}{
      "email": "test@example.com",
      "name":  "Test User",
      "password": "password123",
  }
  userJSON, _ := json.Marshal(userReq)
  
  userResp, err := http.Post(
      "http://user-service/api/v1/users",
      "application/json",
      bytes.NewBuffer(userJSON),
  )
  assert.NoError(t, err)
  assert.Equal(t, http.StatusCreated, userResp.StatusCode)
  
  var user map[string]interface{}
  json.NewDecoder(userResp.Body).Decode(&user)
  userID := user["id"].(string)
  
  // Создаем заказ
  orderReq := map[string]interface{}{
      "user_id": userID,
      "items": []map[string]interface{}{
          {
              "id": "item1",
              "quantity": 2,
              "price": 10.5,
          },
      },
  }
  orderJSON, _ := json.Marshal(orderReq)
  
  orderResp, err := http.Post(
      "http://order-service/api/v1/orders",
      "application/json",
      bytes.NewBuffer(orderJSON),
  )
  assert.NoError(t, err)
  assert.Equal(t, http.StatusCreated, orderResp.StatusCode)
  
  var order map[string]interface{}
  json.NewDecoder(orderResp.Body).Decode(&order)
  orderID := order["id"].(string)
  
  // Проверяем статус заказа
  time.Sleep(2 * time.Second) // Даем время на обработку
  
  statusResp, err := http.Get("http://order-service/api/v1/orders/" + orderID)
  assert.NoError(t, err)
  assert.Equal(t, http.StatusOK, statusResp.StatusCode)
  
  var orderStatus map[string]interface{}
  json.NewDecoder(statusResp.Body).Decode(&orderStatus)
  assert.Equal(t, "processing", orderStatus["status"])
}
Для запуска таких тестов я создаю специальный под в том же неймспейсе, где работают тестируемые сервисы:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# tests/integration-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: integration-tests
spec:
containers:
name: tests
  image: golang:1.20
  command: ["go", "test", "-v", "./tests/integration/..."]
  volumeMounts:
  - name: code
    mountPath: /go/src/app
  workingDir: /go/src/app
volumes:
name: code
  configMap:
    name: integration-tests-code
Перед запуском создаем ConfigMap с кодом тестов:

Bash
1
kubectl create configmap integration-tests-code --from-file=tests/

Полный листинг приложения



Давайте соберём всё вместе в структуру проекта:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/bookstore
  /user-service
    /cmd
      /api
        main.go
    /internal
      /domain
        user.go
      /repository
        postgres_repository.go
      /service
        user_service.go
      /api
        handlers.go
    /pkg
      /auth
        jwt.go
    /migrations
      1_create_users_table.up.sql
      1_create_users_table.down.sql
    Dockerfile
    go.mod
    go.sum
  
  /order-service
    /cmd
      /api
        main.go
    /internal
      /domain
        order.go
        item.go
      /repository
        postgres_repository.go
      /service
        order_service.go
      /api
        handlers.go
      /client
        user_client.go
    /pkg
      /events
        kafka_publisher.go
    /migrations
      1_create_orders_table.up.sql
      1_create_orders_table.down.sql
    Dockerfile
    go.mod
    go.sum
  
  /k8s
    /user-service
      deployment.yaml
      service.yaml
    /order-service
      deployment.yaml
      service.yaml
    /databases
      postgres.yaml
    /kafka
      kafka.yaml
    /monitoring
      prometheus.yaml
      grafana.yaml
    /logging
      elasticsearch.yaml
      kibana.yaml
  
  /common
    /metrics
      prometheus.go
    /logging
      elasticsearch.go
  
  /tests
    /integration
      order_test.go
    integration-pod.yaml
  
  docker-compose.yaml
  README.md
Взгляним на главный компонент интеграции между сервисами - gRPC клиент для обращения к сервису пользователей:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// order-service/internal/client/user_client.go
package client
 
import (
  "context"
  "time"
  
  "google.golang.org/grpc"
  "google.golang.org/grpc/credentials/insecure"
  pb "github.com/yourusername/bookstore/user-service/api/proto"
)
 
type UserClient struct {
  conn   *grpc.ClientConn
  client pb.UserServiceClient
}
 
func NewUserClient(userServiceURL string) (*UserClient, error) {
  // Настраиваем gRPC соединение с таймаутом и ретраями
  ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  defer cancel()
  
  conn, err := grpc.DialContext(
      ctx,
      userServiceURL,
      grpc.WithTransportCredentials(insecure.NewCredentials()),
      grpc.WithBlock(),
  )
  if err != nil {
      return nil, err
  }
  
  client := pb.NewUserServiceClient(conn)
  
  return &UserClient{
      conn:   conn,
      client: client,
  }, nil
}
 
func (c *UserClient) GetUser(ctx context.Context, userID string) (*pb.UserResponse, error) {
  ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
  defer cancel()
  
  return c.client.GetUser(ctx, &pb.GetUserRequest{Id: userID})
}
 
func (c *UserClient) Close() error {
  return c.conn.Close()
}
И для полноты картины — сервис для обработки заказов с реализацией паттерна Saga:

Go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
// order-service/internal/service/order_service.go
package service
 
import (
  "context"
  "fmt"
  "time"
  
  "github.com/yourusername/bookstore/order-service/internal/domain"
  "github.com/yourusername/bookstore/order-service/internal/repository"
  "github.com/yourusername/bookstore/order-service/internal/client"
  "github.com/yourusername/bookstore/order-service/pkg/events"
  "github.com/google/uuid"
  "go.uber.org/zap"
)
 
type OrderService struct {
  repo           repository.OrderRepository
  userClient     *client.UserClient
  eventPublisher *events.KafkaPublisher
  logger         *zap.Logger
}
 
func NewOrderService(
  repo repository.OrderRepository,
  userClient *client.UserClient,
  eventPublisher *events.KafkaPublisher,
  logger *zap.Logger,
) *OrderService {
  return &OrderService{
      repo:           repo,
      userClient:     userClient,
      eventPublisher: eventPublisher,
      logger:         logger,
  }
}
 
func (s *OrderService) CreateOrder(ctx context.Context, userID string, items []domain.Item) (*domain.Order, error) {
  // Реализация паттерна Saga
  // Шаг 1: Проверяем существование пользователя
  user, err := s.userClient.GetUser(ctx, userID)
  if err != nil {
      s.logger.Error("Ошибка при получении пользователя", zap.String("user_id", userID), zap.Error(err))
      return nil, fmt.Errorf("ошибка при получении пользователя: %w", err)
  }
  
  // Шаг 2: Рассчитываем общую стоимость
  var total float64
  for _, item := range items {
      total += item.Price * float64(item.Quantity)
  }
  
  // Шаг 3: Создаем заказ
  order := &domain.Order{
      ID:        uuid.New().String(),
      UserID:    userID,
      Items:     items,
      Status:    "created",
      Total:     total,
      CreatedAt: time.Now(),
      UpdatedAt: time.Now(),
  }
  
  if err := s.repo.CreateOrder(ctx, order); err != nil {
      s.logger.Error("Ошибка при создании заказа", zap.String("user_id", userID), zap.Error(err))
      return nil, fmt.Errorf("ошибка при создании заказа: %w", err)
  }
  
  // Шаг 4: Публикуем событие о создании заказа
  if err := s.eventPublisher.PublishOrderCreated(order); err != nil {
      // Компенсирующее действие: отменяем заказ
      s.logger.Error("Ошибка при публикации события", zap.String("order_id", order.ID), zap.Error(err))
      if err := s.repo.UpdateOrderStatus(ctx, order.ID, "cancelled"); err != nil {
          s.logger.Error("Ошибка при отмене заказа", zap.String("order_id", order.ID), zap.Error(err))
      }
      return nil, fmt.Errorf("ошибка при публикации события: %w", err)
  }
  
  // Шаг 5: Обновляем статус заказа
  if err := s.repo.UpdateOrderStatus(ctx, order.ID, "processing"); err != nil {
      s.logger.Error("Ошибка при обновлении статуса заказа", zap.String("order_id", order.ID), zap.Error(err))
      // Заказ уже создан, так что просто логируем ошибку
  }
  
  return order, nil
}
 
func (s *OrderService) GetOrder(ctx context.Context, orderID string) (*domain.Order, error) {
  return s.repo.GetOrderByID(ctx, orderID)
}
 
func (s *OrderService) ListUserOrders(ctx context.Context, userID string) ([]*domain.Order, error) {
  return s.repo.GetOrdersByUserID(ctx, userID)
}
 
func (s *OrderService) CancelOrder(ctx context.Context, orderID string) error {
  // Проверяем существование заказа
  order, err := s.repo.GetOrderByID(ctx, orderID)
  if err != nil {
      return err
  }
  
  // Можно отменить только заказы в статусе "created" или "processing"
  if order.Status != "created" && order.Status != "processing" {
      return fmt.Errorf("нельзя отменить заказ в статусе %s", order.Status)
  }
  
  // Обновляем статус
  if err := s.repo.UpdateOrderStatus(ctx, orderID, "cancelled"); err != nil {
      return err
  }
  
  // Публикуем событие об отмене
  if err := s.eventPublisher.PublishOrderCancelled(order); err != nil {
      s.logger.Error("Ошибка при публикации события об отмене", zap.String("order_id", orderID), zap.Error(err))
      // Статус уже обновлен, так что просто логируем ошибку
  }
  
  return nil
}
И наконец, docker-compose для локальной разработки и тестирования:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
# docker-compose.yaml
version: '3'
 
services:
user-service:
  build: ./user-service
  ports:
    - "8080:8080"
  environment:
    - DB_HOST=postgres
    - DB_PORT=5432
    - DB_USER=postgres
    - DB_PASSWORD=postgres
    - DB_NAME=users
    - ELASTICSEARCH_URL=http://elasticsearch:9200
  depends_on:
    - postgres
    - elasticsearch
 
order-service:
  build: ./order-service
  ports:
    - "8081:8080"
  environment:
    - DB_HOST=postgres
    - DB_PORT=5432
    - DB_USER=postgres
    - DB_PASSWORD=postgres
    - DB_NAME=orders
    - USER_SERVICE_URL=user-service:9090
    - KAFKA_BROKERS=kafka:9092
    - ELASTICSEARCH_URL=http://elasticsearch:9200
  depends_on:
    - postgres
    - kafka
    - user-service
    - elasticsearch
 
postgres:
  image: postgres:13
  ports:
    - "5432:5432"
  environment:
    - POSTGRES_USER=postgres
    - POSTGRES_PASSWORD=postgres
    - POSTGRES_MULTIPLE_DATABASES=users,orders
  volumes:
    - postgres-data:/var/lib/postgresql/data
    - ./scripts/create-multiple-dbs.sh:/docker-entrypoint-initdb.d/create-multiple-dbs.sh
 
kafka:
  image: wurstmeister/kafka:2.13-2.7.0
  ports:
    - "9092:9092"
  environment:
    - KAFKA_ADVERTISED_HOST_NAME=kafka
    - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181
  depends_on:
    - zookeeper
 
zookeeper:
  image: wurstmeister/zookeeper:3.4.6
  ports:
    - "2181:2181"
 
elasticsearch:
  image: docker.elastic.co/elasticsearch/elasticsearch:7.14.0
  ports:
    - "9200:9200"
  environment:
    - discovery.type=single-node
    - ES_JAVA_OPTS=-Xms512m -Xmx512m
  volumes:
    - elasticsearch-data:/usr/share/elasticsearch/data
 
kibana:
  image: docker.elastic.co/kibana/kibana:7.14.0
  ports:
    - "5601:5601"
  environment:
    - ELASTICSEARCH_URL=http://elasticsearch:9200
  depends_on:
    - elasticsearch
 
prometheus:
  image: prom/prometheus:v2.30.0
  ports:
    - "9090:9090"
  volumes:
    - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
    - prometheus-data:/prometheus
 
grafana:
  image: grafana/grafana:8.1.2
  ports:
    - "3000:3000"
  environment:
    - GF_SECURITY_ADMIN_PASSWORD=admin
  volumes:
    - grafana-data:/var/lib/grafana
  depends_on:
    - prometheus
 
volumes:
postgres-data:
elasticsearch-data:
prometheus-data:
grafana-data:
Вот мы и собрали полноценную микросервисную архитектуру на Go и Kubernetes. С ней можно работать, расширять и масштабировать под практически любые бизнес-требования.

Кстати, большой бонус этого подхода — независимость команд разработки. Каждая команда может отвечать за свой микросервис, выбирать удобные инструменты и деплоить изменения по своему графику, не мешая другим. У нас был случай, когда команда, отвечающая за заказы, полностью переписала свой сервис с Go на Rust, а остальные даже не заметили — интерфейсы остались теми же.

Конфигурация ngnix для Kubernetes Deployment
Подскажите, что не так с nginx.conf переданным в ConfigMap для k8s? У меня на порту сервиса сайт не...

Где расположить БД для Kubernetes кластера в облаке
Привет. Нагуглил и разобрал пример, как разместить Spring-овый микросервис в кубернетес-кластере....

Node.js аппа на Kubernetes
Или кто проворачивал такое? Есть какие грабли? Как там с process.env переменными?

Kubernetes не работает localhost
Добрый день! Пытался поставить kubernetes-dashboard на новом кластере. Выполнял все пункты по...

Микросервисы и их ограничения в вебе
Доброго вечер коллеги, хочу обсудить проблему микросервисов в вебе. У меня есть порядка 15...

Микросервисы. Основы
Добрый день, посоветуйте материал для построение микросервисов на c#. Желательно хотя одну ссылку...

Микросервисы и .NET
Добрый вечер! Кто применял в своей практике .NET микросервисы ASP.NET? Стоит ли связываться? Есть...

Микросервисы (авторизация)
Всем привет! Возник такой вопрос, Имеется &quot;монолитной приложение&quot; asp net core + react/redux,...

Научить микросервисы передавать запросы друг другу
Technologies: · Spring core, MVC. (Don’t use Spring Boot) · Hibernate Task: develop 3...

SpringCloud ConfigServer и Микросервисы - ожидание запуска сервера конфигураций
Всем доброго дня! Подскажите куда копнуть, есть SpringCloudConfigServer - для распространения...

Микросервисы на Spring Boot
Всем привет. Написал два микросервиса на Spring Boot, засекьюрил один обычным вводом пароля и мыла....

Имеются ли способы в KrakenD способы закрыть внутренние маршруты, когда микросервисы общаются друг с другом?
Вопрос: имеются ли способы в API шлюзе KrakenD способы закрыть внутренние маршруты от внешнего...

Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
Тестирование энергоэффективности и скорости вычислений видеокарт в BOINC проектах
Programma_Boinc 08.07.2025
Тестирование энергоэффективности и скорости вычислений видеокарт в BOINC проектах Опубликовано: 07. 07. 2025 Рубрика: Uncategorized Автор: AlexA Статья размещается на сайте с разрешения. . .
Раскрываем внутренние механики Android с помощью контекста и манифеста
mobDevWorks 07.07.2025
Каждый Android-разработчик сталкивается с Context и манифестом буквально в первый день работы. Но много ли мы задумываемся о том, что скрывается за этими обыденными элементами? Я, честно говоря,. . .
API на базе FastAPI с Python за пару минут
AI_Generated 07.07.2025
FastAPI - это относительно молодой фреймворк для создания веб-API, который за короткое время заработал бешеную популярность в Python-сообществе. И не зря. Я помню, как впервые запустил приложение на. . .
Основы WebGL. Раскрашивание вершин с помощью VBO
8Observer8 05.07.2025
На русском https:/ / vkvideo. ru/ video-231374465_456239020 На английском https:/ / www. youtube. com/ watch?v=oskqtCrWns0 Исходники примера:
Мониторинг микросервисов с OpenTelemetry в Kubernetes
Mr. Docker 04.07.2025
Проблема наблюдаемости (observability) в Kubernetes - это не просто вопрос сбора логов или метрик. Это целый комплекс вызовов, которые возникают из-за самой природы контейнеризации и оркестрации. К. . .
Проблемы с Kotlin и Wasm при создании игры
GameUnited 03.07.2025
В современном мире разработки игр выбор технологии - это зачастую балансирование между удобством разработки, переносимостью и производительностью. Когда я решил создать свою первую веб-игру, мой. . .
Создаем микросервисы с Go и Kubernetes
golander 02.07.2025
Когда я только начинал с микросервисами, все спорили о том, какой язык юзать. Сейчас Go (или Golang) фактически захватил эту нишу. И вот почему этот язык настолько заходит для этих задач: . . .
C++23, квантовые вычисления и взаимодействие с Q#
bytestream 02.07.2025
Я всегда с некоторым скептицизмом относился к громким заявлениям о революциях в IT, но квантовые вычисления - это тот случай, когда революция действительно происходит прямо у нас на глазах. Последние. . .
Вот в чем сила LM.
Hrethgir 02.07.2025
как на английском будет “обслуживание“ Слово «обслуживание» на английском языке может переводиться несколькими способами в зависимости от контекста: * **Service** — самый распространённый. . .
Использование Keycloak со Spring Boot и интеграция Identity Provider
Javaican 01.07.2025
Два года назад я получил задачу, которая сначала показалась тривиальной: интегрировать корпоративную аутентификацию в микросервисную архитектуру. На тот момент у нас было семь Spring Boot приложений,. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2025, CyberForum.ru