Когда я только начинал с микросервисами, все спорили о том, какой язык юзать. Сейчас 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? Стоит ли связываться? Есть... Микросервисы (авторизация) Всем привет! Возник такой вопрос, Имеется "монолитной приложение" 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 способы закрыть внутренние маршруты от внешнего...
|