이 튜토리얼은 특히 첫 번째 kubernetes 연산자를 빠르게 작성하는 방법을 배우고 싶은 Java 배경이 있는 개발자를 위한 것입니다. 왜 운영자인가? 몇 가지 장점이 있습니다:
이론을 최소한으로 제한하고 "케이크 굽는" 방법을 확실한 레시피로 보여드리겠습니다. 저는 Java가 제 업무 경험과 가깝고 솔직히 Go보다 쉽기 때문에 선택했습니다(그러나 일부는 동의하지 않을 수도 있습니다).
바로 넘어가겠습니다.
긴 문서를 읽는 것을 좋아하는 사람은 없지만, 빨리 가슴 속으로 읽어 볼까요?
포드란 무엇인가요?
Pod는 공유 네트워크 인터페이스(고유한 IP 주소가 제공됨)와 저장소를 갖춘 컨테이너 그룹입니다.
레플리카세트란 무엇인가요?
복제본 세트는 포드 생성 및 삭제를 제어하여 매 순간 특정 템플릿에 정확히 지정된 수의 포드가 있도록 합니다.
배포란 무엇인가요?
배포는 복제본 세트를 소유하고 포드를 간접적으로 소유합니다. 생성하면 배포 포드가 생성되고 삭제하면 포드가 사라집니다.
서비스란 무엇인가요?
서비스는 여러 포드에 대한 단일 인터넷 엔드포인트입니다(로드를 균등하게 분배합니다). 클러스터 외부에서도 볼 수 있도록 노출할 수 있습니다. 엔드포인트 슬라이스 생성을 자동화합니다.
Kubernetes의 문제점은 처음부터 Stateless로 설계되었다는 것입니다. 복제본 세트는 포드의 ID를 추적하지 않습니다. 특정 포드가 사라지면 새 포드가 생성됩니다. 데이터베이스 및 캐시 클러스터와 같은 상태가 필요한 일부 사용 사례가 있습니다. 상태 저장 세트는 문제를 부분적으로만 완화합니다.
이것이 사람들이 유지 관리의 부담을 덜기 위해 연산자를 쓰기 시작한 이유입니다. 패턴과 다양한 SDK에 대해 깊이 다루지는 않겠습니다. 여기서부터 시작할 수 있습니다.
Kubernetes에서 작동하는 모든 것, 모든 작은 기계 장치는 제어 루프라는 간단한 개념을 기반으로 합니다. 따라서 이 제어 루프가 특정 리소스 유형에 대해 수행하는 작업은 매니페스트에 정의된 대로 무엇이고 무엇이 되어야 하는지 확인하는 것입니다. 불일치가 있는 경우 이를 수정하기 위해 몇 가지 작업을 수행하려고 시도합니다. 이것을 화해라고 합니다.
그리고 실제로 연산자는 동일한 개념이지만 사용자 지정 리소스에 대한 것입니다. 사용자 정의 리소스는 kubernetes api를 사용자가 정의한 일부 리소스 유형으로 확장하는 수단입니다. kubernetes에서 crd를 설정하면 가져오기, 나열, 업데이트, 삭제 등과 같은 모든 작업이 이 리소스에서 가능해집니다. 그리고 실제 작업은 어떻게 될까요? 맞습니다. 저희 운영자입니다.
처음으로 기술을 테스트하는 경우 일반적으로 가장 기본적인 문제를 선택합니다. 개념이 특히 복잡하기 때문에 이 경우 hello world는 약간 길어질 것입니다. 어쨌든 대부분의 소스에서 가장 간단한 사용 사례는 정적 페이지 제공을 설정하는 것임을 확인했습니다.
그래서 프로젝트는 다음과 같습니다. 우리가 제공하려는 두 페이지를 나타내는 사용자 정의 리소스를 정의합니다. 해당 리소스 운영자를 적용한 후 Spring Boot에서 서비스 제공 애플리케이션을 자동으로 설정하고 페이지 콘텐츠가 포함된 구성 맵을 생성하고 구성 맵을 앱 포드의 볼륨에 마운트하고 해당 포드에 대한 서비스를 설정합니다. 여기서 재미있는 점은 리소스를 수정하면 즉시 모든 것을 다시 바인딩하고 새로운 페이지 변경 사항이 즉시 표시된다는 것입니다. 두 번째로 재미있는 점은 리소스를 삭제하면 클러스터를 깨끗하게 유지하는 모든 항목이 삭제된다는 것입니다.
자바 앱 제공
이것은 Spring Boot에서 매우 간단한 정적 페이지 서버가 될 것입니다. spring-boot-starter-web만 필요하므로 spring 초기화로 가서 다음을 선택하세요.
앱은 다음과 같습니다.
@SpringBootApplication @RestController public class WebpageServingApplication { @GetMapping(value = "/{page}", produces = "text/html") public String page(@PathVariable String page) throws IOException { return Files.readString(Path.of("/static/"+page)); } public static void main(String[] args) { SpringApplication.run(WebpageServingApplication.class, args); } }
경로 변수로 전달하는 모든 것은 /static 디렉토리(이 경우 page1 및 page2)에서 가져옵니다. 따라서 정적 디렉토리는 구성 맵에서 마운트되지만 이에 대해서는 나중에 설명합니다.
이제 네이티브 이미지를 빌드하여 원격 저장소에 푸시해야 합니다.
팁 1번
<plugin> <groupId>org.graalvm.buildtools</groupId> <artifactId>native-maven-plugin</artifactId> <configuration> <buildArgs> <buildArg>-Ob</buildArg> </buildArgs> </configuration> </plugin>
GraalVM을 이와 같이 구성하면 가장 낮은 메모리 소비(약 2GB)로 가장 빠른 빌드를 얻을 수 있습니다. 저에게는 메모리가 16GB밖에 없고 설치되어 있는 것도 많기 때문에 필수였습니다.
팁 2번
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <image> <publish>true</publish> <builder>paketobuildpacks/builder-jammy-full:latest</builder> <name>ghcr.io/dgawlik/webpage-serving:1.0.5</name> <env> <BP_JVM_VERSION>21</BP_JVM_VERSION> </env> </image> <docker> <publishRegistry> <url>https://ghcr.io/dgawlik</url> <username>dgawlik</username> <password>${env.GITHUB_TOKEN}</password> </publishRegistry> </docker> </configuration> </plugin>
So now you do:
mvn spring-boot:build-image
And that’s it.
Operator with Fabric8
Now the fun starts. First you will need this in your pom:
<dependencies> <dependency> <groupId>io.fabric8</groupId> <artifactId>kubernetes-client</artifactId> <version>6.13.4</version> </dependency> <dependency> <groupId>io.fabric8</groupId> <artifactId>crd-generator-apt</artifactId> <version>6.13.4</version> <scope>provided</scope> </dependency> </dependencies>
crd-generator-apt is a plugin that scans a project, detects CRD pojos and generates the manifest.
Since I mentioned it, these resources are:
@Group("com.github.webserving") @Version("v1alpha1") @ShortNames("websrv") public class WebServingResource extends CustomResource<WebServingSpec, WebServingStatus> implements Namespaced { }
public record WebServingSpec(String page1, String page2) { }
public record WebServingStatus (String status) { }
What is common in all resource manifests in Kubernetes is that most of them has spec and status. So you can see that the spec will consist of two pages pasted in heredoc format. Now, the proper way to handle things would be to manipulate status to reflect whatever operator is doing. If for example it is waiting on deployment to finish it would have status = “Processing”, on everything done it would patch the status to “Ready” and so on. But we will skip that because this is just simple demo.
Good news is that the logic of the operator is all in main class and really short. So step by step here it is:
KubernetesClient client = new KubernetesClientBuilder() .withTaskExecutor(executor).build(); var crdClient = client.resources(WebServingResource.class) .inNamespace("default"); var handler = new GenericResourceEventHandler<>(update -> { synchronized (changes) { changes.notifyAll(); } }); crdClient.inform(handler).start(); client.apps().deployments().inNamespace("default") .withName("web-serving-app-deployment").inform(handler).start(); client.services().inNamespace("default") .withName("web-serving-app-svc").inform(handler).start(); client.configMaps().inNamespace("default") .withName("web-serving-app-config").inform(handler).start();
So the heart of the program is of course Fabric8 Kuberenetes client built in first line. It is convenient to customize it with own executor. I used famous virtual threads, so when waiting on blocking io java will suspend the logic and move to main.
How here is a new part. The most basic version would be to run forever the loop and put Thread.sleep(1000) in it or so. But there is more clever way - kubernetes informers. Informer is websocket connection to kubernetes api server and it informs the client each time the subscribed resource changes. There is more to it you can read on the internet for example how to use various caches which fetch updates all at once in batch. But here it just subscribes directly per resource. The handler is a little bit bloated so I wrote a helper class GenericResourceEventHandler.
public class GenericResourceEventHandler<T> implements ResourceEventHandler<T> { private final Consumer<T> handler; public GenericResourceEventHandler(Consumer<T> handler) { this.handler = handler; } @Override public void onAdd(T obj) { this.handler.accept(obj); } @Override public void onUpdate(T oldObj, T newObj) { this.handler.accept(newObj); } @Override public void onDelete(T obj, boolean deletedFinalStateUnknown) { this.handler.accept(null); } }
Since we only need to wake up the loop in all of the cases then we pass it a generic lambda. The idea for the loop is to wait on lock in the end and then the informer callback releases the lock each time the changes are detected.
Next:
for (; ; ) { var crdList = crdClient.list().getItems(); var crd = Optional.ofNullable(crdList.isEmpty() ? null : crdList.get(0)); var skipUpdate = false; var reload = false; if (!crd.isPresent()) { System.out.println("No WebServingResource found, reconciling disabled"); currentCrd = null; skipUpdate = true; } else if (!crd.get().getSpec().equals( Optional.ofNullable(currentCrd) .map(WebServingResource::getSpec).orElse(null))) { currentCrd = crd.orElse(null); System.out.println("Crd changed, Reconciling ConfigMap"); reload = true; }
If there is no crd then there is nothing to be done. And if the crd changed then we have to reload everything.
var currentConfigMap = client.configMaps().inNamespace("default") .withName("web-serving-app-config").get(); if(!skipUpdate && (reload || desiredConfigMap(currentCrd).equals(currentConfigMap))) { System.out.println("New configmap, reconciling WebServingResource"); client.configMaps().inNamespace("default").withName("web-serving-app-config") .createOrReplace(desiredConfigMap(currentCrd)); reload = true; }
This is for the case that ConfigMap is changed in between the iterations. Since it is mounted in pod then we have to reload the deployment.
var currentServingDeploymentNullable = client.apps().deployments().inNamespace("default") .withName("web-serving-app-deployment").get(); var currentServingDeployment = Optional.ofNullable(currentServingDeploymentNullable); if(!skipUpdate && (reload || !desiredWebServingDeployment(currentCrd).getSpec().equals( currentServingDeployment.map(Deployment::getSpec).orElse(null)))) { System.out.println("Reconciling Deployment"); client.apps().deployments().inNamespace("default").withName("web-serving-app-deployment") .createOrReplace(desiredWebServingDeployment(currentCrd)); } var currentServingServiceNullable = client.services().inNamespace("default") .withName("web-serving-app-svc").get(); var currentServingService = Optional.ofNullable(currentServingServiceNullable); if(!skipUpdate && (reload || !desiredWebServingService(currentCrd).getSpec().equals( currentServingService.map(Service::getSpec).orElse(null)))) { System.out.println("Reconciling Service"); client.services().inNamespace("default").withName("web-serving-app-svc") .createOrReplace(desiredWebServingService(currentCrd)); }
If any of the service or deployment differs from the defaults we will replace them with the defaults.
synchronized (changes) { changes.wait(); }
Then the aforementioned lock.
So now the only thing is to define the desired configmap, service and deployment.
private static Deployment desiredWebServingDeployment(WebServingResource crd) { return new DeploymentBuilder() .withNewMetadata() .withName("web-serving-app-deployment") .withNamespace("default") .addToLabels("app", "web-serving-app") .withOwnerReferences(createOwnerReference(crd)) .endMetadata() .withNewSpec() .withReplicas(1) .withNewSelector() .addToMatchLabels("app", "web-serving-app") .endSelector() .withNewTemplate() .withNewMetadata() .addToLabels("app", "web-serving-app") .endMetadata() .withNewSpec() .addNewContainer() .withName("web-serving-app-container") .withImage("ghcr.io/dgawlik/webpage-serving:1.0.5") .withVolumeMounts(new VolumeMountBuilder() .withName("web-serving-app-config") .withMountPath("/static") .build()) .addNewPort() .withContainerPort(8080) .endPort() .endContainer() .withVolumes(new VolumeBuilder() .withName("web-serving-app-config") .withConfigMap(new ConfigMapVolumeSourceBuilder() .withName("web-serving-app-config") .build()) .build()) .withImagePullSecrets(new LocalObjectReferenceBuilder() .withName("regcred").build()) .endSpec() .endTemplate() .endSpec() .build(); } private static Service desiredWebServingService(WebServingResource crd) { return new ServiceBuilder() .editMetadata() .withName("web-serving-app-svc") .withOwnerReferences(createOwnerReference(crd)) .withNamespace(crd.getMetadata().getNamespace()) .endMetadata() .editSpec() .addNewPort() .withPort(8080) .withTargetPort(new IntOrString(8080)) .endPort() .addToSelector("app", "web-serving-app") .endSpec() .build(); } private static ConfigMap desiredConfigMap(WebServingResource crd) { return new ConfigMapBuilder() .withMetadata( new ObjectMetaBuilder() .withName("web-serving-app-config") .withNamespace(crd.getMetadata().getNamespace()) .withOwnerReferences(createOwnerReference(crd)) .build()) .withData(Map.of("page1", crd.getSpec().page1(), "page2", crd.getSpec().page2())) .build(); } private static OwnerReference createOwnerReference(WebServingResource crd) { return new OwnerReferenceBuilder() .withApiVersion(crd.getApiVersion()) .withKind(crd.getKind()) .withName(crd.getMetadata().getName()) .withUid(crd.getMetadata().getUid()) .withController(true) .build(); }
The magic of the OwnerReference is that you mark the resource which is it’s parent. Whenever you delete the parent k8s will delete automatically all the dependant resources.
But you can’t run it yet. You need a docker credentials in kubernetes:
kubectl delete secret regcred kubectl create secret docker-registry regcred \ --docker-server=ghcr.io \ --docker-username=dgawlik \ --docker-password=$GITHUB_TOKEN
Run this script once. Then we also need to set up the ingress:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: demo-ingress spec: rules: - http: paths: - path: / pathType: Prefix backend: service: name: web-serving-app-svc port: number: 8080
The workflow
So first you build the operator project. Then you take target/classes/META-INF/fabric8/webservingresources.com.github.webserving-v1.yml and apply it. From now on the kubernetes is ready to accept your crd. Here it is:
apiVersion: com.github.webserving/v1alpha1 kind: WebServingResource metadata: name: example-ws namespace: default spec: page1: | <h1>Hola amigos!</h1> <p>Buenos dias!</p> page2: | <h1>Hello my friend</h1> <p>Good evening</p>
You apply the crd kubectl apply -f src/main/resources/crd-instance.yaml. And then you run Main of the operator.
Then monitor the pod if it is up. Next just take the ip of the cluster:
minikube ip
And in your browser navigate to /page1 and /page2.
Then try to change the crd and apply it again. After a second you should see the changes.
The end.
A bright observer will notice that the code has some concurrency issues. A lot can happen in between the start and the end of the loop. But there are a lot of cases to consider and tried to keep it simple. You can do it as aftermath.
Like wise for the deployment. Instead of running it in IDE you can build the image the same way as for serving app and write deployment of it. That’s basically demystification of the operator — it is just a pod like every other.
I hope you found it useful.
Thanks for reading.
I almost forgot - here is the repo:
https://github.com/dgawlik/operator-hello-world
위 내용은 Java로 kooperator 작성하기의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!