问题描述
编辑/解决方案:
我明白了,部分感谢@anemyte 的评论。尽管 eureka.hostname
属性不是问题所在(尽管它确实需要更正),但更仔细地观察让我找到了问题的真正原因:正在使用的网络接口、端口转发和(坏)运气。
我为这个原型实现选择的服务是那些在生产环境中具有端口转发的服务(不幸的是,我一定忘记在下面的示例服务中添加端口转发 - 愚蠢的,虽然我不知道这是否会有所帮助)。
当 Docker Swarm 服务有端口转发时,容器除了用于内部容器到容器通信的覆盖接口外,还有一个额外的桥接接口。
不幸的是,客户端服务选择使用他们的桥接接口 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
的黑名单方法,但我不想使用它。
在这种情况下,客户端应用程序将其实际的群覆盖 IP 通告给 Eureka,并且 SBA 能够访问它们。
我确实觉得有点奇怪,我没有从 SBA 收到任何错误消息,并将在他们的跟踪器上打开一个问题。也许我只是做错了什么。
(原始问题如下)
我有以下设置:
- 使用 Eureka 进行服务发现(使用
eureka.client.fetch-registry=true
和eureka.instance.preferIpAddress=true
) - Spring Boot Admin 在与 Eureka 相同的应用程序中运行,带有
spring.boot.admin.context-path=/admin
- Keycloak 集成,例如:
- SBA 本身使用一个服务帐户来轮询我的客户端应用程序的各个
/actuator
端点。 - SBA UI 本身通过需要管理员登录的登录页面进行保护。
- SBA 本身使用一个服务帐户来轮询我的客户端应用程序的各个
在本地,此设置有效。当我将 eureka-server
应用程序与客户端应用程序一起启动时,我看到以下正确行为:
- Eureka 正在运行,例如
localhost:8761
- 客户端应用程序通过 IP 注册 (
eureka.instance.preferIpAddress=true
) 成功向 Eureka 注册 - SBA 运行于例如
localhost:8761/admin
并发现我的服务 -
localhost:8761/admin
正确重定向到我的 Keycloak 登录页面,并且登录正确为 SBA UI 提供了一个会话 - SBA 本身已成功轮询任何已注册应用程序的
/actuator
端点。
但是,我在 Docker Swarm 中复制此设置时遇到问题。
我有两个 Docker 服务,比如说 eureka-server
和 client-api
- 两者都是使用相同的网络创建的,并且容器可以通过这个网络(例如通过 curl
)相互访问。 eureka-server
正确启动,client-api
立即向 Eureka 注册。
尝试导航到 eureka_url/admin
会正确显示 Keycloak 登录页面,并在成功登录后重定向回 Spring Boot 管理 UI。但是,没有注册应用程序,我不知道为什么。
我尝试启用更多调试/跟踪日志记录,但我绝对看不到任何日志;就好像 SBA 根本没有获取 Eureka 注册表一样。
有人知道解决这种行为的方法吗?有人遇到过这个问题吗?
编辑:
我不太确定哪些设置可能与问题有关,但这里是我的一些配置文件(作为代码片段,因为它们不是那么小,我希望没问题):
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-server
Dockerfile
:
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 swarm 服务:
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 似乎没有注意到有任何应用程序。
解决方法
我发现您提供的配置没有任何问题。我看到的唯一弱领先是来自 eureka.hostname=localhost
的 application.yml
。 localhost
和环回 IP 是 swarm 最好避免的两件事。我认为你应该检查它是否与网络无关。