Как сервисы не теряют друг друга в потемках
Раньше я думал, что микросервисы — это когда всё работает. Теперь я знаю: это когда всё делает вид, что работает, а ты проверяешь логи в три часа ночи.
У меня три сервиса: Auth, Gateway и Backend. Каждый крутится на своем порту. Backend знал, что Auth живет на 8082. Он помнил это как номер телефона, который вбит прямо в пальцы. И всё было бы нормально, пока я не подумал: а что будет, если поднять вторую реплику Auth?
Вот тут и начнется самое интересное. Коннекты будут рваться. Тайм-ауты. Backend будет стучаться по старому адресу, а там только одна реплика или вообще никто не живет. Я буду сидеть и смотреть в монитор, как смотритель зоопарка, у которого разбежались звери.
Требуется решение. И… да… меня осенило! Эврика! Ну, то есть Eureka, или телефонный справочник для сервисов.
Хардкодить адреса в конфигах — это как писать номер телефона на руке перед встречей: вспотел — и всё поплыло. Auth может переехать на другой порт, или мы можем распилить его на два сервиса. И что тогда, писать в конфигах «второй Auth в подвале, пароль — „помидоры“»?
Netflix придумал Eureka как раз для такого. Это одна точка, где все регистрируются при старте, как в домовой книге. Каждый сервис при запуске говорит: «Я здесь, мой порт такой-то», — а Eureka записывает.
Как это выглядит в коде. Добавляем модуль discovery. Внутри — три строчки:
java
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryApplication {
public static void main(String... args) {
SpringApplication.run(DiscoveryApplication.class, args);
}
}
И минимальный application.yml для сервера:
yaml
server:
port: 8761
eureka:
client:
register-with-eureka: false # сервер не регистрирует сам себя
fetch-registry: false
server:
enable-self-preservation: false # для разработки
eviction-interval-timer-in-ms: 5000 # проверяет умерших каждые 5 секунд
Всё. Теперь это центр. Висит на порту 8761 и знает, кто жив, а кто притворяется.
А теперь как подключается клиент. В pom.xml любого сервиса:
xml <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency>
И в его application.yml:
yaml
spring:
application:
name: ollama-backend # это имя будет видно в Eureka
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
instance:
lease-renewal-interval-in-seconds: 10 # говорит "я жив" каждые 10 сек.
lease-expiration-duration-in-seconds: 30 # если 30 сек. тишины — я умер
prefer-ip-address: true
Раньше Gateway обращался к Backend напрямую: uri: http://localhost:8080. Теперь он пишет так:
yaml
spring:
cloud:
gateway:
routes:
- id: backend_route
uri: lb://ollama-backend
predicates:
- Path=/api/generate/**
filters:
- StripPrefix=1
- name: Retry
args:
retries: 3
statuses: INTERNAL_SERVER_ERROR
lb:// значит «посмотри в Eureka, кто такой ollama-backend, и дай мне его адрес». Если сервисов три, Gateway сам раскидает запросы между ними. Ни строчки кода для балансировки. Просто дописываешь конфиг и сидишь, моргаешь.
Куда прятать конфиги, чтобы не сойти с ума
Дальше всплыла вторая проблема: конфиги. У каждого сервиса — свой application.yml: пароли, ключи, тайм-ауты. Когда сервисов три — еще ладно. Когда пять — уже тошнит. А когда на проде пароль один, на стейджинге другой, а локально вообще третий?
Можно коммитить и раскатывать разные ветки или открыть три терминала и править конфиги руками. Но это бесит.
Решение — Config Server. Еще один модуль (или микросервис, кому как нравится):
java
@SpringBootApplication
@EnableConfigServer
public class ConfigApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigApplication.class, args);
}
}
И его application.yml:
yaml
server:
port: 8888
spring:
cloud:
config:
server:
git:
uri: https://github.com/yourcompany/ollama-configs
default-label: main
search-paths: '{application}' # ищет в папке с именем сервиса
encrypt:
key: my-super-secret-key-32-chars-long!!! # для шифрования паролей
Теперь конфиги лежат в отдельном Git-репозитории — просто папка ollama-configs с файлами backend.yml, auth.yml, gateway.yml. Сервисы при старте идут туда и читают свои настройки из Git.
Как клиент тянет конфиги. В каждом сервисе появляется bootstrap.yml (грузится до application.yml):
yaml
spring:
application:
name: ollama-backend
cloud:
config:
uri: http://localhost:8888
fail-fast: true # если Config Server не отвечает — сервис даже не стартует
retry:
max-attempts: 6
initial-interval: 1000
А в Git-репозитории лежит, например, ollama-backend.yml:
yaml
ollama:
url: http://ollama:11434
timeout: 30000
model: llama3.2
postgres:
url: jdbc:postgresql://db:5432/ollama
username: ollama_user
password: '{cipher}7a8b3c9d...' # зашифрованный пароль
Пароли шифруются простой командой:
bash
curl localhost:8888/encrypt -d POSTGRES_PASSWORD
# вернет: 7a8b3c9d...
И самое крутое: если меняешь конфиг в Git и дергаешь /actuator/refresh, сервис подхватывает изменение сам, без перезагрузки и пересборки контейнера.
bash
curl -X POST http://localhost:8090/actuator/refresh
Ответ покажет, что именно изменилось:
json
["ollama.timeout", "postgres.url"]
Поменял тайм-аут и ключ шифрования на лету. Система даже не икнула.
Можно еще прикрутить Spring Cloud Bus с RabbitMQ — тогда изменения разлетаются по сервисам сами. Достаточно добавить зависимость и дернуть /actuator/busrefresh на любом сервисе — и все подтянут новые конфиги. Но это уже для полного дзена.
А если нейросеть зависла? (а она зависнет)
Теперь о грустном. Ollama — штука непредсказуемая. Может работать, может не работать. Может на вопрос «Сколько будет 2 + 2?» думать 40 секунд и выдать инструкцию по сборке велосипеда, причем половина ответа будет написана иероглифами.
Когда она зависает, всё встает. Пользователь жмет Enter, запрос висит, поток залипает. Двести таких потоков — и процессор уже дымится. Это называется каскадный отказ: одна часть легла — и за ней посыпались остальные.
Circuit Breaker решает эту проблему. Идея старая, из электрики: если ток слишком сильный, цепь размыкается. У нас ток — это ошибки.
Как это пишется в коде. Добавляем зависимость:
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>
Оборачиваем вызов к Ollama:
java
@Service
@Slf4j
public class OllamaService {
@CircuitBreaker(
name = "ollama",
fallbackMethod = "handleOllamaFailure"
)
public String generate(String prompt) {
var response = restTemplate.postForObject(
"http://localhost:11434/api/generate",
request,
OllamaResponse.class
);
return response.getResponse();
}
public String handleOllamaFailure(String prompt, Throwable t) {
log.error("Circuit breaker сработал: {}", t.getMessage());
return switch (t.getClass().getSimpleName()) {
case "TimeoutException" -> "⏳ Модель думает слишком долго. Попробуйте проще.";
case "HttpServerErrorException" -> "🔌 У Ollama внутренняя ошибка. Уже чиним.";
case "ResourceAccessException" -> "🌐 Ollama не отвечает. Проверьте сервер.";
default -> "🤖 Нейросеть временно недоступна. Загляните через минуту.";
};
}
}
И конфиг:
yaml
resilience4j:
circuitbreaker:
instances:
ollama:
sliding-window-type: COUNT_BASED
sliding-window-size: 10
minimum-number-of-calls: 5
failure-rate-threshold: 50
wait-duration-in-open-state: 15s
permitted-number-of-calls-in-half-open-state: 3
slow-call-duration-threshold: 30s
slow-call-rate-threshold: 70
record-exceptions:
- org.springframework.web.client.HttpServerErrorException
- java.net.SocketTimeoutException
Работает так: смотрим на последние запросы. Если половина провалилась, цепь рвется на 10 секунд (или на 15, как в примере выше). Никто даже не пытается дозвониться до Ollama. Сразу уходят в fallback. Пользователь видит вежливое сообщение вместо тайм-аута. Сервер не падает.
Кроме того, можно посмотреть, в каком состоянии Circuit Breaker прямо сейчас:
bash
# Текущее состояние
curl http://localhost:8080/actuator/health
# Все события переключения
curl http://localhost:8080/actuator/circuitbreakerevents/STATE_TRANSITION
Когда всё сломалось, в /health придет:
json
{
"status": "UP",
"components": {
"circuitBreakers": {
"status": "UP",
"details": {
"ollama": "OPEN" // цепь разомкнута
}
}
}
}
Я сплю.
Схема
Вот что должно получиться:
text
┌─────────┐ ┌─────────────────┐
│ БРАУЗЕР │──│ GATEWAY (:8081) │
└─────────┘ │ │
└────────┬────────┘
│ "lb://ollama-backend", "lb://ollama-auth"
▼
┌─────────────────┐
│ EUREKA │
│ (:8761) │
└────────┬────────┘
│
┌────────────┼────────────┐
│ │ │
│ (регистр) │ (регистр) │
▼ ▼ │
┌────────────┐ ┌────────────┐ │
│ AUTH │ │ BACKEND │ │
└─────┬──────┘ └─────┬──────┘ │
│ │ │
│ (конфиг) │ (конфиг) │
▼ ▼ │
┌────────────────────────────┐ (Gateway тоже может тянуть конфиги)
│ CONFIG SERVER (:8888) │ │
│ ┌─────┐ │ │
│ │ GIT │ │───┘
│ └─────┘ │
└────────────────────────────┘
Auth и Backend могут менять порты — никто не заметит. Eureka в курсе, Gateway спрашивает у Eureka, Config Server стоит в стороне и не лезет.
Всё вместе в Docker Compose для локальной разработки:
yaml
services:
eureka:
build: ./discovery
ports:
- "8761:8761"
config-server:
build: ./config-server
ports:
- "8888:8888"
backend:
build: ./backend
depends_on:
- eureka
- config-server
environment:
- EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eureka:8761/eureka/
- SPRING_CLOUD_CONFIG_URI=http://config-server:8888
deploy:
replicas: 2 # две реплики — Eureka знает обе
gateway:
build: ./gateway
ports:
- "8081:8081"
Что я понял
Eureka — это когда ты не хранишь адреса в голове, а спрашиваешь у справочной.
Config Server — это когда правки на проде вносятся через коммит в Git, а не через kubectl edit или docker exec дрожащими руками.
Circuit Breaker — это когда твой сервис не умирает от того, что соседний лег.
Всё это — Spring Cloud, просто набор для решения проблем, которые у вас появятся, если вы разобьете монолит на части.
P.S. Что я забыл и теперь жалею:
- Bulkhead — отдельный пул потоков для каждого сервиса, чтобы один медленный сервис не забил все потоки Gateway.
- Rate limiter — чтобы никто не дергал нейросеть 1000 раз в секунду через Swagger UI.
- Retry с экспоненциальной задержкой вместо линейной.
Но это уже в следующих статьях, которые я буду писать в 4 утра, когда прод снова ляжет… Да, и мне будет не лень.