使用 Eureka 和 Docker Swarm 的 Spring Boot Admin

Posted

技术标签:

【中文标题】使用 Eureka 和 Docker Swarm 的 Spring Boot Admin【英文标题】:Spring Boot Admin with Eureka and Docker Swarm 【发布时间】:2021-04-16 17:52:56 【问题描述】:

编辑/解决方案

我明白了,部分感谢@anemyte 的评论。虽然 eureka.hostname 属性不是问题所在(尽管它确实需要更正),但仔细观察后我发现了问题的真正原因:使用中的网络接口、端口转发和(坏)运气。

我为这个原型实现选择的服务是那些在生产环境中具有端口转发的服务(很遗憾,我一定忘记在下面的示例服务中添加端口转发 - 愚蠢,尽管我不知道这是否会有所帮助)。

当 Docker Swarm 服务有端口转发时,容器除了用于内部容器到容器通信的覆盖接口外,还有一个额外的桥接接口。

不幸的是,客户端服务选择使用他们的 bridge interface IP 作为广告 IP 而不是内部 swarm IP 向 Eureka 注册 - 可能是因为这就是 InetAddress.getLocalhost()(内部使用在这种情况下,Spring Cloud 将返回。

这导致我错误地认为 Spring Boot Admin 可以访问这些服务 - 正如我在外部可以访问的那样,而实际上它不能,因为发布了错误的 IP。使用cURL 验证这一点只会加剧混淆,因为我使用 overlay IP 来检查服务是否可以通信,而这不是在 Eureka 注册的服务。

该问题的(临时)解决方案是将spring.cloud.inetutils.preferred-networks 设置为10.0,这是内部swarm 网络的默认IP 地址池(更具体地说:10.0.0.0/8)(文档here)。还有一个使用spring.cloud.inetutils.ignored-networks的黑名单方法,但我不想使用它。

在这种情况下,客户端应用程序将其实际的 swarm 覆盖 IP 通告给 Eureka,SBA 能够访问它们。

我确实觉得有点奇怪,我没有从 SBA 收到任何错误消息,并将在他们的跟踪器上打开一个问题。也许我只是做错了什么。

(原问题如下)


我有以下设置:

    使用 Eureka 的服务发现(使用 eureka.client.fetch-registry=trueeureka.instance.preferIpAddress=true) Spring Boot Admin 在与 Eureka 相同的应用程序中运行,spring.boot.admin.context-path=/admin Keycloak 集成,例如: SBA 本身使用服务帐户来轮询我的客户端应用程序的各种 /actuator 端点。 SBA UI 本身通过需要管理登录的登录页面受到保护。

在本地,此设置有效。当我启动 eureka-server 应用程序和客户端应用程序时,我看到以下正确行为:

    Eureka 运行在例如localhost:8761 客户端应用程序通过 IP 注册成功向 Eureka 注册 (eureka.instance.preferIpAddress=true) SBA 在例如运行localhost:8761/admin 并发现我的服务 localhost:8761/admin 正确重定向到我的 Keycloak 登录页面,并且正确登录为 SBA UI 提供会话 SBA 本身已成功轮询任何已注册应用程序的 /actuator 端点。

但是,我在 Docker Swarm 中复制此设置时遇到了问题。

我有两个 Docker 服务,比如说 eureka-serverclient-api - 两者都是使用同一个网络创建的,并且容器可以通过这个网络相互访问(例如通过 curl)。 eureka-server 正确启动,client-api 立即向 Eureka 注册。

尝试导航到 eureka_url/admin 时,会正确显示 Keycloak 登录页面,并在成功登录后重定向回 Spring Boot Admin UI。但是,没有注册任何应用程序,我不知道为什么。

我尝试启用更多调试/跟踪日志记录,但我完全看不到任何日志;就好像 SBA 根本没有获取 Eureka 注册表。

有人知道解决此问题的方法吗?有人遇到过这个问题吗?

编辑:

我不太确定哪些设置可能与问题相关,但这里是我的一些配置文件(作为代码 sn-ps,因为它们不是那么小,我希望没关系):

application.yaml

(包括基础 eureka 属性、SBA 属性和 SBA 的 Keycloak 属性)

---
eureka:
  hostname: localhost
  port: 8761
  client:
    register-with-eureka: false
    # Registry must be fetched so that Spring Boot Admin knows that there are registered applications
    fetch-registry: true 
    serviceUrl:
      defaultZone: http://$eureka.hostname:$eureka.port/eureka/
  instance:
    lease-renewal-interval-in-seconds: 10
    lease-expiration-duration-in-seconds: 30
  environment: eureka-test-$user.name
  server:
    enable-self-preservation: false # Intentionally disabled for non-production

spring:
  application:
    name: eureka-server
  boot:
    admin:
      client:
        prefer-ip: true
      # Since we are running in Eureka, "/" is already the root path for Eureka itself
      # Register SBA under the "/admin" path
      context-path: /admin
  cloud:
    config:
      enabled: false
  main:
    allow-bean-definition-overriding: true


keycloak:
  realm: $realm
  auth-server-url: $auth_url
  # Client ID
  resource: spring-boot-admin-automated
  # Client secret used for service account grant
  credentials:
    secret: $client_secret
  ssl-required: external
  autodetect-bearer-only: true
  use-resource-role-mappings: false
  token-minimum-time-to-live: 90
  principal-attribute: preferred_username

build.gradle

// Versioning / Spring parents poms
apply from: new File(project(':buildscripts').projectDir, '/dm-versions.gradle')

configurations 
    all*.exclude module: 'spring-boot-starter-tomcat'


ext 
    springBootAdminVersion = '2.3.1'
    keycloakVersion = '11.0.2'


dependencies 
    compileOnly 'org.projectlombok:lombok'
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server'
    implementation "de.codecentric:spring-boot-admin-starter-server:$springBootAdminVersion"
    implementation 'org.keycloak:keycloak-spring-boot-starter'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    compile "org.keycloak:keycloak-admin-client:$keycloakVersion"

    testCompileOnly 'org.projectlombok:lombok'


dependencyManagement 
    imports 
        mavenBom "org.keycloak.bom:keycloak-adapter-bom:$keycloakVersion"
    

实际应用代码:

package com.app.eureka;

import de.codecentric.boot.admin.server.config.EnableAdminServer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@EnableAdminServer
@EnableEurekaServer
@SpringBootApplication
public class EurekaServer 

public static void main(String[] args) 
    SpringApplication.run(EurekaServer.class, args);


Keycloak 配置:

package com.app.eureka.keycloak.config;

import de.codecentric.boot.admin.server.web.client.HttpHeadersProvider;
import org.keycloak.KeycloakPrincipal;
import org.keycloak.KeycloakSecurityContext;
import org.keycloak.OAuth2Constants;
import org.keycloak.adapters.springboot.KeycloakSpringBootProperties;
import org.keycloak.adapters.springsecurity.KeycloakConfiguration;
import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider;
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter;
import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.KeycloakBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.http.HttpHeaders;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.security.Principal;
import java.util.Objects;

@KeycloakConfiguration
@EnableConfigurationProperties(KeycloakSpringBootProperties.class)
class KeycloakConfig extends KeycloakWebSecurityConfigurerAdapter 

private static final String X_API_KEY = System.getProperty("sba_api_key");

@Value("$keycloak.token-minimum-time-to-live:60")
private int tokenMinimumTimeToLive;

/**
 * @link HttpHeadersProvider used to populate the @link HttpHeaders for
 * accessing the state of the disovered clients.
 *
 * @param keycloak
 * @return
 */
@Bean
public HttpHeadersProvider keycloakBearerAuthHeaderProvider(final Keycloak keycloak) 
    return provider -> 
        String accessToken = keycloak.tokenManager().getAccessTokenString();
        HttpHeaders headers = new HttpHeaders();
        headers.add("X-Api-Key", X_API_KEY);
        headers.add("X-Authorization-Token", "keycloak-bearer " + accessToken);
        return headers;
    ;


/**
 * The Keycloak Admin client that provides the service-account Access-Token
 *
 * @param props
 * @return keycloakClient the prepared admin client
 */
@Bean
public Keycloak keycloak(KeycloakSpringBootProperties props) 
    final String secretString = "secret";
    Keycloak keycloakAdminClient = KeycloakBuilder.builder()
            .serverUrl(props.getAuthServerUrl())
            .realm(props.getRealm())
            .grantType(OAuth2Constants.CLIENT_CREDENTIALS)
            .clientId(props.getResource())
            .clientSecret((String) props.getCredentials().get(secretString))
            .build();

    keycloakAdminClient.tokenManager().setMinTokenValidity(tokenMinimumTimeToLive);
    return keycloakAdminClient;


/**
 * Put the SBA UI behind a Keycloak-secured login page.
 * 
 * @param http
 */
@Override
protected void configure(HttpSecurity http) throws Exception 
    super.configure(http);
    http
            .csrf().disable()
            .authorizeRequests()
            .antMatchers("/**/*.css", "/admin/img/**", "/admin/third-party/**").permitAll()
            .antMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().permitAll();


@Autowired
public void configureGlobal(final AuthenticationManagerBuilder auth) 
    SimpleAuthorityMapper grantedAuthorityMapper = new SimpleAuthorityMapper();
    grantedAuthorityMapper.setPrefix("ROLE_");
    grantedAuthorityMapper.setConvertToUpperCase(true);

    KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
    keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(grantedAuthorityMapper);
    auth.authenticationProvider(keycloakAuthenticationProvider);


@Bean
@Override
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() 
    return new RegisterSessionAuthenticationStrategy(buildSessionRegistry());


@Bean
protected SessionRegistry buildSessionRegistry() 
    return new SessionRegistryImpl();


/**
 * Allows to inject requests scoped wrapper for @link KeycloakSecurityContext.
 * <p>
 * Returns the @link KeycloakSecurityContext from the Spring
 * @link ServletRequestAttributes's @link Principal.
 * <p>
 * The principal must support retrieval of the KeycloakSecurityContext, so at
 * this point, only @link KeycloakPrincipal values and
 * @link KeycloakAuthenticationToken are supported.
 *
 * @return the current <code>KeycloakSecurityContext</code>
 */
@Bean
@Scope(scopeName = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public KeycloakSecurityContext provideKeycloakSecurityContext() 

    ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    Principal principal = Objects.requireNonNull(attributes).getRequest().getUserPrincipal();
    if (principal == null) 
        return null;
    

    if (principal instanceof KeycloakAuthenticationToken) 
        principal = (Principal) ((KeycloakAuthenticationToken) principal).getPrincipal();
    

    if (principal instanceof KeycloakPrincipal<?>) 
        return ((KeycloakPrincipal<?>) principal).getKeycloakSecurityContext();
    

    return null;


KeycloakConfigurationResolver

(单独的类以防止由于某种原因发生的循环 bean 依赖)

package com.app.eureka.keycloak.config;

import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class KeycloakConfigurationResolver 

/**
 * Load Keycloak configuration from application.properties or application.yml
 *
 * @return
 */
@Bean
public KeycloakSpringBootConfigResolver keycloakConfigResolver() 
    return new KeycloakSpringBootConfigResolver();


注销控制器

package com.app.eureka.keycloak.config;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;

import javax.servlet.http.HttpServletRequest;

@Controller
class LogoutController 

/**
 * Logs the current user out, preventing access to the SBA UI
 * @param request
 * @return
 * @throws Exception
 */
@PostMapping("/admin/logout")
public String logout(final HttpServletRequest request) throws Exception 
    request.logout();
    return "redirect:/admin";


很遗憾,我没有docker-compose.yaml,因为我们的部署主要是通过 Ansible 完成的,并且匿名化这些脚本相当困难。

服务最终创建如下(使用docker service create): (其中一些网络可能不相关,因为这是在我的个人节点上运行的本地集群,值得注意的是 swarm 网络)

dev@ws:~$ docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
3ba4a65c319f        bridge              bridge              local
21065811cbff        docker_gwbridge     bridge              local
ti1ksbdxlouo        services            overlay             swarm
c59778b105b5        host                host                local
379lzdi0ljp4        ingress             overlay             swarm
dd92d2f75a31        none                null                local

eureka-serverDockerfile:

FROM registry/image:latest
MAINTAINER "dev@com.app"
COPY eureka-server.jar /home/myuser/eureka-server.jar
USER myuser
WORKDIR /home/myuser
CMD /usr/bin/java -jar \
    -Xmx523351K -Xss1M -XX:ReservedCodeCacheSize=240M \
    -XX:MaxMetaspaceSize=115625K \
    -Djava.security.egd=file:/dev/urandom eureka-server.jar \
    --server.port=8761; sh

Eureka/SBA 应用 Docker 群服务:

dev@ws:~$ docker service create --name eureka-server -p 8080:8761 --replicas 1 --network services --hostname eureka-server --limit-cpu 1 --limit-memory 768m eureka-server

然后按如下方式启动客户端应用程序:

Dockerfile

FROM registry/image:latest
MAINTAINER "dev@com.app"
COPY client-api.jar /home/myuser/client-api.jar
USER myuser
WORKDIR /home/myuser
CMD /usr/bin/java -jar \
    -Xmx523351K -Xss1M -XX:ReservedCodeCacheSize=240M \
    -XX:MaxMetaspaceSize=115625K \
    -Djava.security.egd=file:/dev/urandom -Deureka.instance.hostname=client-api client-api.jar \
    --eureka.zone=http://eureka-server:8761/eureka --server.port=0; sh

然后创建为 Swarm 服务如下:

dev@ws:~$ docker service create --name client-api --replicas 1 --network services --hostname client-api --limit-cpu 1 --limit-memory 768m client-api

在客户端,值得注意的是以下eureka.client 设置:

eureka:
  name: $spring.application.name
  instance:
    leaseRenewalIntervalInSeconds: 10
    instanceId: $spring.cloud.client.hostname:$spring.application.name:$spring.application.instanceId:$random.int
    preferIpAddress: true
  client:
    registryFetchIntervalSeconds: 5

这就是我现在能想到的。创建的 docker 服务在同一个网络中运行,并且可以通过 IP 和主机名相互 ping 通(目前无法显示输出,因为我目前没有积极处理此问题,很遗憾)。

实际上,在 Eureka UI 中,我可以看到我的客户端应用程序已注册并正在运行 - 只有 SBA 似乎没有注意到有任何应用程序。

【问题讨论】:

你能提供一些材料来重现这个问题吗?也许是一个 docker-compose 文件? @anemyte 谢谢你的评论。我已经添加了我能想到的所有内容 - 如果您需要任何其他信息,请告诉我。 顺便说一句:我选择不包含任何额外的 Keycloak 配置,因为该应用程序的这一部分似乎可以正常工作,并且可以防止这个问题变得比它已经变得更加臃肿是。 感谢您提供这些详细信息,但不幸的是,除了application.yaml 中的eureka.hostname 之外,我在这里没有看到任何问题。不应该是eureka-server 吗?还要看一些例子(cloud.spring.io/spring-cloud-netflix/multi/…),通常是eureka.instance.hostname 很高兴它有帮助。我按照您对评论所说的做了,但我宁愿它不被接受。如果其他人偶然发现这一点,他们将首先阅读答案,您的解决方案比我的弱领先要好得多。 【参考方案1】:

我发现您提供的配置没有任何问题。我看到的唯一弱线索是来自application.ymleureka.hostname=localhostlocalhost 和环回 IP 是 swarm 最好避免的两件事。我认为您应该检查它是否与网络无关。

【讨论】:

以上是关于使用 Eureka 和 Docker Swarm 的 Spring Boot Admin的主要内容,如果未能解决你的问题,请参考以下文章

【docker swarm】docker swarm 中的网段冲突问题

Docker Swarm 横向扩容/收缩简单使用

如何使用未部署在 swarm 中的 docker 容器从 docker swarm 访问服务?

Docker swarm中的LB和服务发现详解

docker里面swarm是啥?

docker swarm安装和使用