
Hola, Habr.
En este artículo, quiero compartir mi experiencia creando un entorno de aprendizaje para experimentar con microservicios. Al aprender cada nueva herramienta, siempre quise probarla no solo en mi máquina local, sino también en condiciones más realistas. Por eso decidí crear una aplicación de microservicios simplificada que posteriormente podría ser "complementada" con diversas tecnologías interesantes. El requisito principal del proyecto es que su funcionalidad se asemeje al máximo a un sistema real.
Inicialmente, dividí la creación del proyecto en varios pasos:
Crear dos servicios: ‘backend’ y ‘gateway’, empacarlos en imágenes de Docker y configurar su funcionamiento conjunto.
Palabras clave: Java 11, Spring Boot, Docker, optimización de imágenes.
Palabras clave: Kubernetes, GKE, gestión de recursos, escalado automático, secretos.
Crear un chart con Helm 3 para una gestión más eficiente del clúster.
Palabras clave: Helm 3, despliegue de charts.
Configurar Jenkins y un pipeline para la entrega automática de código al clúster.
Palabras clave: configuración de Jenkins, plugins, repositorio de configuraciones separadas.
Planeo dedicar un artículo separado a cada paso.
El enfoque de este ciclo de artículos no es cómo escribir microservicios, sino cómo hacer que funcionen en un sistema único. Aunque todas estas cosas generalmente están más allá de la responsabilidad del desarrollador, creo que sigue siendo útil estar familiarizado con ellas al menos en un 20% (que, como se sabe, aporta el 80% del resultado). Algunos temas indudablemente importantes, como la seguridad, quedarán fuera del alcance de este proyecto, ya que el autor no tiene mucho conocimiento en ello; el sistema se crea exclusivamente para uso personal. Agradecería cualquier opinión y crítica constructiva.
Creación de microservicios.
Los servicios fueron escritos en Java 11 utilizando Spring Boot. La interacción entre servicios se organiza utilizando REST. El proyecto incluirá una cantidad mínima de pruebas (para que luego haya algo que probar en Jenkins). El código fuente de los servicios está disponible en GitHub: y .
Para poder verificar el estado de cada uno de los servicios, se ha añadido Spring Actuator a sus dependencias. Este generará un endpoint /actuator/health y devolverá un estado 200 si el servicio está listo para recibir tráfico, o 504 en caso de problemas. En este caso, es una verificación bastante ficticia, ya que los servicios son muy simples, y en caso de algún imprevisto, es más probable que se vuelvan completamente inaccesibles que mantengan una funcionalidad parcial. Sin embargo, en sistemas reales, Actuator puede ayudar a diagnosticar problemas antes de que los usuarios empiecen a encontrarlos. Por ejemplo, ante problemas de acceso a la base de datos, podemos reaccionar automáticamente deteniendo el procesamiento de solicitudes con una instancia de servicio fallida.
Servicio Backend
El servicio backend simplemente contará y devolverá el número de solicitudes recibidas.
Código del controlador:
@RestController
public class RequestsCounterController {
private final AtomicLong counter = new AtomicLong();
@GetMapping("/requests")
public Long getRequestsCount() {
return counter.incrementAndGet();
}
}Prueba del controlador:
@WebMvcTest(RequestsCounterController.class)
public class RequestsCounterControllerTests {
@Autowired
private MockMvc mockMvc;
@Test
public void firstRequest_one() throws Exception {
mockMvc.perform(get("/requests"))
.andExpect(status().isOk())
.andExpect(MockMvcResultMatchers.content().string("1"));
}
}Servicio Gateway
El gateway redirigirá la solicitud al servicio backend, añadiendo la siguiente información:
- ID del gateway. Es necesario para poder distinguir una instancia de gateway de otra en la respuesta del servidor.
- Una "secreto", que actuará como una contraseña muy importante (número de clave de cifrado de una cookie importante).
Configuración en application.properties:
backend.url=http://localhost:8081
instance.id=${random.int}
secret="default-secret"Adaptador para comunicarse con el backend:
@Service
public class BackendAdapter {
private static final String REQUESTS_ENDPOINT = "/requests";
private final RestTemplate restTemplate;
@Value("${backend.url}")
private String backendUrl;
public BackendAdapter(RestTemplateBuilder builder) {
restTemplate = builder.build();
}
public String getRequests() {
ResponseEntity response = restTemplate.getForEntity(
backendUrl + REQUESTS_ENDPOINT, String.class);
return response.getBody();
}
}Controlador:
@RestController
@RequiredArgsConstructor
public class EndpointController {
private final BackendAdapter backendAdapter;
@Value("${instance.id}")
private int instanceId;
@Value("${secret}")
private String secret;
@GetMapping("/")
public String getRequestsCount() {
return String.format("Número de solicitudes %s (gateway %d, secreto %s)", backendAdapter.getRequests(), instanceId, secret);
}
}Inicio:
Iniciando el backend:
./mvnw package -DskipTests
java -Dserver.port=8081 -jar target/microservices-backend-1.0.0.jarIniciando el gateway:
./mvnw package -DskipTests
java -jar target/microservices-gateway-1.0.0.jarVerificando:
$ curl http://localhost:8080/
Número de solicitudes 1 (puerta de enlace 38560358, secreto "default-secret")Todo funciona. El lector atento notará que no hay nada que impida que accedamos al backend directamente eludiendo la puerta de enlace (). Para corregir esto, los servicios deben estar en una sola red, y solo debe "asomarse" la puerta de enlace hacia el exterior.
Además, ambos servicios comparten un sistema de archivos, generan hilos y en un momento pueden comenzar a interferir entre sí. Sería bueno aislar nuestros microservicios. Esto se puede lograr separando las aplicaciones en diferentes máquinas (mucho dinero, complicado), usando máquinas virtuales (consumidoras de recursos, tiempo de inicio largo) o mediante la contenedorización. Como era de esperar, elegimos la tercera opción y como herramienta para la contenedorización.
Docker
En resumen, Docker crea contenedores aislados, uno por aplicación. Para usar Docker, es necesario escribir un Dockerfile: una instrucción para la construcción y ejecución de la aplicación. Luego se podrá construir una imagen, cargarla en el registro de imágenes (nº ) y con un solo comando desplegar nuestro microservicio en cualquier entorno dockerizado.
Dockerfile
Una de las características más importantes de la imagen es su tamaño. Una imagen compacta se descargará más rápido desde el repositorio remoto, ocupará menos espacio y su servicio iniciará más rápido. Cualquier imagen se construye sobre la base de una imagen base, y se recomienda elegir la opción más minimalista. Una buena opción es Alpine: una distribución completa de Linux con el mínimo de paquetes.
Para comenzar, intentaremos escribir un Dockerfile "directamente" (debo decir que este es un mal enfoque, no lo hagan):
FROM adoptopenjdk/openjdk11:jdk-11.0.5_10-alpine
ADD . /src
WORKDIR /src
RUN ./mvnw package -DskipTests
EXPOSE 8080
ENTRYPOINT ["java","-jar","target/microservices-gateway-1.0.0.jar"]Aquí usamos una imagen base basada en Alpine con JDK ya instalado para construir nuestro proyecto. Con el comando ADD, agregamos el directorio actual src a la imagen, lo marcamos como el de trabajo (WORKDIR) y comenzamos la construcción. El comando EXPOSE 8080 le señala a Docker que la aplicación en el contenedor utilizará su puerto 8080 (esto no hará que la aplicación sea accesible externamente, pero permitirá acceder a la aplicación, por ejemplo, desde otro contenedor en la misma red docker).
Para empaquetar los servicios en imágenes, se deben ejecutar los comandos desde la raíz de cada proyecto:
docker image build . -t msvc-backend:1.0.0Como resultado, obtenemos una imagen de 456 MB (de los cuales la imagen base del JDK ocupa 340 MB). Y todo esto, considerando que los clases en nuestro proyecto son contadas con los dedos. Para reducir el tamaño de nuestra imagen:
- Usamos una compilación de múltiples etapas. En el primer paso compilaremos el proyecto, en el segundo instalaremos el JRE y en el tercer paso copiaremos todo esto en una nueva imagen limpia de Alpine. En total, en la imagen final estarán solo los componentes necesarios.
- Utilizaremos la modularización de Java. A partir de Java 9, se puede usar la herramienta jlink para crear un JRE solo con los módulos necesarios
Para los curiosos, aquí hay un buen artículo sobre enfoques para reducir el tamaño de la imagen .
Dockerfile final:
FROM adoptopenjdk/openjdk11:jdk-11.0.5_10-alpine as builder
ADD . /src
WORKDIR /src
RUN ./mvnw package -DskipTests
FROM alpine:3.10.3 as packager
RUN apk --no-cache add openjdk11-jdk openjdk11-jmods
ENV JAVA_MINIMAL="/opt/java-minimal"
RUN /usr/lib/jvm/java-11-openjdk/bin/jlink
--verbose
--add-modules
java.base,java.sql,java.naming,java.desktop,java.management,java.security.jgss,java.instrument
--compress 2 --strip-debug --no-header-files --no-man-pages
--release-info="add:IMPLEMENTOR=radistao:IMPLEMENTOR_VERSION=radistao_JRE"
--output "$JAVA_MINIMAL"
FROM alpine:3.10.3
LABEL maintainer="Anton Shelenkov anshelen@yandex.ru"
ENV JAVA_HOME=/opt/java-minimal
ENV PATH="$PATH:$JAVA_HOME/bin"
COPY --from=packager "$JAVA_HOME" "$JAVA_HOME"
COPY --from=builder /src/target/microservices-backend-*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","/app.jar"]Recreamos la imagen, y al final se redujo 6 veces, quedando en 77 MB. No está mal. Luego, las imágenes preparadas se pueden cargar en un registro de imágenes para que estén disponibles para su descarga desde Internet.
Ejecución conjunta de servicios en Docker
Primero, nuestros servicios deben estar en una misma red. En Docker existen varios tipos de redes, y utilizamos la más primitiva de ellas: bridge, que permite conectar en red contenedores ejecutados en un mismo host. Crearemos la red con el siguiente comando:
docker network create msvc-networkA continuación, lanzaremos el contenedor de backend con el nombre 'backend' utilizando la imagen microservices-backend:1.0.0:
docker run -dit --name backend --network msvc-net microservices-backend:1.0.0Cabe destacar que la red bridge proporciona de manera predeterminada la detección de servicios para contenedores por sus nombres. Es decir, el servicio de backend será accesible dentro de la red de Docker en la dirección .
Iniciando el gateway:
docker run -dit -p 80:8080 --env secret=my-real-secret --env BACKEND_URL=http://backend:8080/ --name gateway --network msvc-net microservices-gateway:1.0.0En este comando, indicamos que estamos redirigiendo el puerto 80 de nuestro host al puerto 8080 del contenedor. Utilizamos las opciones env para establecer variables de entorno que serán leídas automáticamente por Spring y sobreescribirán las propiedades de application.properties.
Después de iniciar, llamamos y nos aseguramos de que todo funcione como en el caso anterior.
Conclusión
Como resultado, creamos dos microservicios sencillos, los empaquetamos en contenedores Docker y los ejecutamos en una misma máquina. Sin embargo, este sistema presenta una serie de desventajas:
- Baja tolerancia a fallos: todo funciona en un solo servidor.
- Baja escalabilidad: al aumentar la carga, sería útil desplegar automáticamente instancias adicionales de los servicios y equilibrar la carga entre ellas.
- Complejidad de inicio: necesitábamos ingresar al menos 3 comandos, con parámetros específicos (esto solo para 2 servicios).
Para abordar los problemas mencionados, existen varias soluciones, como Docker Swarm, Nomad, Kubernetes o OpenShift. Si todo el sistema está escrito en Java, se puede considerar Spring Cloud ().
En Hablaré sobre cómo configuré Kubernetes y desplegué el proyecto en Google Kubernetes Engine.
Fuente: habr.com
