From 4e63f8db5e6310fa905c6c361a6926fd49953ffd Mon Sep 17 00:00:00 2001 From: Gytis Trikleris Date: Fri, 29 Jun 2018 16:26:34 +0300 Subject: [PATCH] Leader election example --- .../README.md | 83 +++++++++++++ .../pom.xml | 110 ++++++++++++++++++ .../src/main/fabric8/rb.yaml | 13 +++ .../src/main/fabric8/role.yaml | 12 ++ .../cloud/kubernetes/examples/App.java | 29 +++++ .../kubernetes/examples/LeaderController.java | 105 +++++++++++++++++ .../src/main/resources/application.properties | 2 + .../examples/LeaderControllerTest.java | 94 +++++++++++++++ spring-cloud-kubernetes-examples/pom.xml | 1 + 9 files changed, 449 insertions(+) create mode 100644 spring-cloud-kubernetes-examples/kubernetes-leader-election-example/README.md create mode 100644 spring-cloud-kubernetes-examples/kubernetes-leader-election-example/pom.xml create mode 100644 spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/fabric8/rb.yaml create mode 100644 spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/fabric8/role.yaml create mode 100644 spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/java/org/springframework/cloud/kubernetes/examples/App.java create mode 100644 spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/java/org/springframework/cloud/kubernetes/examples/LeaderController.java create mode 100644 spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/resources/application.properties create mode 100644 spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/test/java/org/springframework/cloud/kubernetes/examples/LeaderControllerTest.java diff --git a/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/README.md b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/README.md new file mode 100644 index 00000000..1858ee66 --- /dev/null +++ b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/README.md @@ -0,0 +1,83 @@ +# Spring Cloud Kubernetes leader election example + +## Setting up the Environment + +This example uses a Fabric8 Maven Plugin to deploy an application to a Kubernetes cluster. +To try it locally, download and install `[Minikube](https://kubernetes.io/docs/getting-started-guides/minikube/)`. + +Once Minikube is downloaded, start it with the following command: +``` +minikube start +``` + +And configure environment variables to point to the Minikube's Docker daemon: +``` +eval $(minikube docker-env) +``` + +## Overview + +Spring Cloud Kubernetes leader election mechanism implements leader election API of Spring Integration using Kubernetes ConfigMap. +Multiple instances of the same application compete for a leadership of a specified role. +But only one of them can become a leader and receive an `OnGrantedEvent` application event with a leadership `Context`. +All the instances periodically try to get a leadership and whichever comes first - becomes a leader. +The new leader will remain until either its instance disappears from the cluster or it yields its leadership. +Once the leader is gone, any of the existing instances can become a new leader (even the previous leader if it yielded leadership but stayed in the cluster). +And finally, if the leadership is yielded or revoked for some reason, the old leader receives `OnRevokedEvent` application event. + +## Example application usage + +To begin with, build and deploy the application: +``` +mvn clean package fabric8:deploy -Pkubernetes +``` + +This will deploy a single application instance to the cluster and that instance will automatically become a leader. + +Create an environment variable for an easier application access: +``` +SERVICE_URL=$(minikube service kubernetes-leader-election-example --url) +``` + +Get leadership information: +``` +curl $SERVICE_URL +``` + +You should receive a message like this: +``` +I am 'kubernetes-leader-election-example-1234567890-abcde' and I am the leader of the 'world' +``` + +Yield the leadership: +``` +curl -X PUT $SERVICE_URL +``` + +And check the leadership information again: +``` +curl $SERVICE_URL +``` + +Now you should receive a message like this: +``` +I am 'kubernetes-leader-election-example-1234567890-abcde' but I am not a leader of the 'world' +``` + +If you wouldn't do anything for a few seconds, the same instance will become a leader again because it only yielded its leadership but stayed in the cluster. + +Now scale the application to two instances and try all the steps again: +``` +kubectl scale --replicas=2 deployment.apps/kubernetes-leader-election-example +``` + +> Note: with multiple replicas in the cluster, `curl` command will access one of them depending on the Kubernetes load balancing configuration. +Thus, when trying to yield the leadership, request might go to a non-leader node first. Just execute command again until it reaches the correct node. + +> Note: instances periodically try to acquire leadership and Spring Cloud Kubernetes doesn't decide which one of them is more worth to become one. +Thus, it is possible that the instance which just yielded the leadership, made another leadership take over request faster than another instances and became a leader again. + +## Access control notice + +Leader election mechanism uses Kubernetes ConfigMap feature to coordinate leadership information. +In order to access it, [Role](./src/main/fabric8/role.yaml) and [RoleBinding](./src/main/fabric8/rb.yaml) objects are defined. diff --git a/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/pom.xml b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/pom.xml new file mode 100644 index 00000000..7d6efb8d --- /dev/null +++ b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/pom.xml @@ -0,0 +1,110 @@ + + + 4.0.0 + + org.springframework.cloud + spring-cloud-kubernetes-examples + 0.3.0.BUILD-SNAPSHOT + + + kubernetes-leader-election-example + Spring Cloud Kubernetes :: Examples :: Leader Election + Leader election demonstration with Spring Integration and ConfigMap + + + + + org.springframework.boot + spring-boot-dependencies + pom + import + ${spring-boot.version} + + + org.springframework.cloud + spring-cloud-kubernetes-leader + ${project.version} + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.cloud + spring-cloud-kubernetes-leader + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + io.fabric8 + fabric8-maven-plugin + ${fabric8.maven.plugin.version} + + + org.apache.maven.plugins + maven-deploy-plugin + ${maven-deploy-plugin.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-deploy-plugin + + true + + + + + + + + kubernetes + + + + io.fabric8 + fabric8-maven-plugin + + + fmp + + resource + build + + + + + + + + NodePort + + + + + + + + + + diff --git a/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/fabric8/rb.yaml b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/fabric8/rb.yaml new file mode 100644 index 00000000..3aa75746 --- /dev/null +++ b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/fabric8/rb.yaml @@ -0,0 +1,13 @@ +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: RoleBinding +metadata: + name: leader + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: leader +subjects: +- kind: ServiceAccount + name: default + namespace: default diff --git a/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/fabric8/role.yaml b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/fabric8/role.yaml new file mode 100644 index 00000000..988a9f00 --- /dev/null +++ b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/fabric8/role.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: Role +metadata: + name: leader + namespace: default +rules: +- apiGroups: + resources: + - pods + - configmaps + verbs: + - "*" diff --git a/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/java/org/springframework/cloud/kubernetes/examples/App.java b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/java/org/springframework/cloud/kubernetes/examples/App.java new file mode 100644 index 00000000..87a1c733 --- /dev/null +++ b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/java/org/springframework/cloud/kubernetes/examples/App.java @@ -0,0 +1,29 @@ +/* + * Copyright 2018 to the original authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.examples; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class App { + + public static void main(String[] args) { + SpringApplication.run(App.class, args); + } + +} diff --git a/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/java/org/springframework/cloud/kubernetes/examples/LeaderController.java b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/java/org/springframework/cloud/kubernetes/examples/LeaderController.java new file mode 100644 index 00000000..d8943d8b --- /dev/null +++ b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/java/org/springframework/cloud/kubernetes/examples/LeaderController.java @@ -0,0 +1,105 @@ +/* + * Copyright 2018 to the original authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.examples; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.http.ResponseEntity; +import org.springframework.integration.leader.Context; +import org.springframework.integration.leader.event.OnGrantedEvent; +import org.springframework.integration.leader.event.OnRevokedEvent; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class LeaderController { + + private final String host; + + @Value("${spring.cloud.kubernetes.leader.role}") + private String role; + + private Context context; + + public LeaderController() throws UnknownHostException { + this.host = InetAddress.getLocalHost().getHostName(); + } + + /** + * Return a message whether this instance is a leader or not. + * + * @return + */ + @GetMapping + public String getInfo() { + if (context == null) { + return String.format("I am '%s' but I am not a leader of the '%s'", host, role); + } + + return String.format("I am '%s' and I am the leader of the '%s'", host, role); + } + + /** + * PUT request to try and revoke a leadership of this instance. + * If the instance is not a leader, leadership cannot be revoked. Thus "HTTP Bad Request" response. + * If the instance is a leader, it must have a leadership context instance which can be used to give up the + * leadership. + * + * @return + */ + @PutMapping + public ResponseEntity revokeLeadership() { + if (context == null) { + String message = String.format("Cannot revoke leadership because '%s' is not a leader", host); + return ResponseEntity + .badRequest() + .body(message); + } + + context.yield(); + + String message = String.format("Leadership revoked for '%s'", host); + return ResponseEntity + .ok(message); + } + + /** + * Handle a notification that this instance has become a leader. + * + * @param event + */ + @EventListener + public void handleEvent(OnGrantedEvent event) { + System.out.println(String.format("'%s' leadership granted", event.getRole())); + context = event.getContext(); + } + + /** + * Handle a notification that this instance's leadership has been revoked. + * + * @param event + */ + @EventListener + public void handleEvent(OnRevokedEvent event) { + System.out.println(String.format("'%s' leadership revoked", event.getRole())); + context = null; + } +} diff --git a/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/resources/application.properties b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/resources/application.properties new file mode 100644 index 00000000..f6ededa7 --- /dev/null +++ b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/main/resources/application.properties @@ -0,0 +1,2 @@ +# Role to which leader election this instance will participate +spring.cloud.kubernetes.leader.role=world diff --git a/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/test/java/org/springframework/cloud/kubernetes/examples/LeaderControllerTest.java b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/test/java/org/springframework/cloud/kubernetes/examples/LeaderControllerTest.java new file mode 100644 index 00000000..7ce0ebc7 --- /dev/null +++ b/spring-cloud-kubernetes-examples/kubernetes-leader-election-example/src/test/java/org/springframework/cloud/kubernetes/examples/LeaderControllerTest.java @@ -0,0 +1,94 @@ +package org.springframework.cloud.kubernetes.examples; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.integration.leader.Context; +import org.springframework.integration.leader.event.OnGrantedEvent; +import org.springframework.integration.leader.event.OnRevokedEvent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@RunWith(MockitoJUnitRunner.class) +public class LeaderControllerTest { + + @Mock + private OnGrantedEvent mockOnGrantedEvent; + + @Mock + private OnRevokedEvent mockOnRevokedEvent; + + @Mock + private Context mockContext; + + private String host; + + private LeaderController leaderController; + + @Before + public void before() throws UnknownHostException { + host = InetAddress.getLocalHost().getHostName(); + leaderController = new LeaderController(); + } + + @Test + public void shouldGetNonLeaderInfo() { + String message = String.format("I am '%s' but I am not a leader of the 'null'", host); + assertThat(leaderController.getInfo()).isEqualTo(message); + } + + @Test + public void shouldHandleGrantedEvent() { + given(mockOnGrantedEvent.getContext()).willReturn(mockContext); + + leaderController.handleEvent(mockOnGrantedEvent); + + String message = String.format("I am '%s' and I am the leader of the 'null'", host); + assertThat(leaderController.getInfo()).isEqualTo(message); + } + + @Test + public void shouldHandleRevokedEvent() { + given(mockOnGrantedEvent.getContext()).willReturn(mockContext); + + leaderController.handleEvent(mockOnGrantedEvent); + leaderController.handleEvent(mockOnRevokedEvent); + + String message = String.format("I am '%s' but I am not a leader of the 'null'", host); + assertThat(leaderController.getInfo()).isEqualTo(message); + } + + @Test + public void shouldRevokeLeadership() { + given(mockOnGrantedEvent.getContext()).willReturn(mockContext); + + leaderController.handleEvent(mockOnGrantedEvent); + ResponseEntity responseEntity = leaderController.revokeLeadership(); + + String message = String.format("Leadership revoked for '%s'", host); + assertThat(responseEntity.getBody()).isEqualTo(message); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(mockContext).yield(); + } + + @Test + public void shouldNotRevokeLeadershipIfNotLeader() { + ResponseEntity responseEntity = leaderController.revokeLeadership(); + + String message = String.format("Cannot revoke leadership because '%s' is not a leader", host); + assertThat(responseEntity.getBody()).isEqualTo(message); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verify(mockContext, times(0)).yield(); + } + +} diff --git a/spring-cloud-kubernetes-examples/pom.xml b/spring-cloud-kubernetes-examples/pom.xml index 73f0d60e..5b674e25 100644 --- a/spring-cloud-kubernetes-examples/pom.xml +++ b/spring-cloud-kubernetes-examples/pom.xml @@ -35,6 +35,7 @@ kubernetes-reload-example kubernetes-hello-world-example + kubernetes-leader-election-example