Kubernetes 에서 ServiceAccount 를 생성하면 1.22 버전까지는 자동으로 token 을 생성하였다. 그러나 1.23 부터는 토큰을 자동으로 생성해 주지 않기 때문에 수동으로 생성해야 한다.
이 바뀐 기능은 ServiceAcount 와 RBAC 을 연동하여 권한을 제어하고자 할 때 문제가 되므로 수동으로 만드는 방법을 살펴본다.
먼저 테스트할 네임스페이스를 만든다.
$ kubectl create ns ask
ask 네임스페이스에 서비스 어카운트를 생성한다.
$ kubectl create sa ask-sa -n ask
1.24 버전부터는 sa 를 생성해도 token 이 자동으로 생성되지 않는다.
token 은 Secret 타입이므로 Secret 을 조회해 보면 token이 자동 생성되지 않았음을 알 수 있다.
참고: https://kubernetes.io/docs/concepts/configuration/secret/#service-account-token-secrets
ask-sa 에 해당하는 token 을 수동으로 생성한다.
$ cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Secret
metadata:
name: ask-sa
namespace: ask
annotations:
kubernetes.io/service-account.name: ask-sa
type: kubernetes.io/service-account-token
EOF
token 을 생성할 때 어노테이션으로 연결될 서비스 어카운트를 지정한다.
$ kubectl get secret -n ask
NAME TYPE DATA AGE
ask-sa kubernetes.io/service-account-token 3 7s
조회를 하면 service-account-toke 타입으로 secret 이 생성되었음을 알 수 있다.
혹은 token 을 수동으로 생성하는 방법도 있다.
$ kubectl create token ask-sa --bound-object-kind Secret --bound-object-name ask-sa --duration=999999h -n ask
------- output -----------
xxxxxxxxxxxxxxxxxxxxxxx
base64 로 변환하여 secret 에 data.token
값으로 저장한다.
$ kubectl create token ask-sa --bound-object-kind Secret --bound-object-name ask-sa --duration=999999h -n ask | base64 -w 0
------- output -----------
xxxxxxxxxxxxxxxxxxxxxxx
$ kubectl edit secret ask-sa -n ask
...
data:
token: xxxxxxxxxxxxxxxxxxxx
...
Role 과 RoleBinding 은 네임스페이스 별로 연결된다. 그러므로 생성한 권한은 해당 네임스페이스에만 권한이 주어진다.
먼저 Role 을 생성한다.
$ cat <<EOF | kubectl apply -f -
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ask-role
namespace: ask
rules:
- apiGroups: ["", "*"]
resources: ["*"]
verbs: ["*"]
EOF
apiGroup
에서 ""
은 core API group
으로 다음의 출력으로 확인할 수 있다.
APIVERSION 이 v1
인 리소스들이 core API group 이 되면 이들에 대해서 권한을 사용하겠다는 뜻이다.
$ kubectl api-resources -o wide
NAME SHORTNAMES APIVERSION NAMESPACED KIND VERBS
bindings v1 true Binding [create]
componentstatuses cs v1 false ComponentStatus [get list]
configmaps cm v1 true ConfigMap [create delete deletecollection get list patch update watch]
endpoints ep v1 true Endpoints [create delete deletecollection get list patch update watch]
events ev v1 true Event [create delete deletecollection get list patch update watch]
limitranges limits v1 true LimitRange [create delete deletecollection get list patch update watch]
namespaces ns v1 false Namespace [create delete get list patch update watch]
nodes no v1 false Node [create delete deletecollection get list patch update watch]
persistentvolumeclaims pvc v1 true PersistentVolumeClaim [create delete deletecollection get list patch update watch]
persistentvolumes pv v1 false PersistentVolume [create delete deletecollection get list patch update watch]
pods po v1 true Pod [create delete deletecollection get list patch update watch]
podtemplates v1 true PodTemplate [create delete deletecollection get list patch update watch]
replicationcontrollers rc v1 true ReplicationController [create delete deletecollection get list patch update watch]
resourcequotas quota v1 true ResourceQuota [create delete deletecollection get list patch update watch]
secrets v1 true Secret [create delete deletecollection get list patch update watch]
serviceaccounts sa v1 true ServiceAccount [create delete deletecollection get list patch update watch]
services svc v1 true Service [create delete deletecollection get list patch update watch]
다음은 Rolebinding을 생성한다.
$ cat <<EOF | kubectl apply -f -
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ask-role-binding
namespace: ask
subjects:
- kind: ServiceAccount
name: ask-sa
namespace: ask
roleRef:
kind: Role
name: ask-role
apiGroup: rbac.authorization.k8s.io
EOF
ServiceAcount 인 ask-sa
와 ask-role
Role 을 서로 연결 시킨 다는 의미이다.
이렇게 되면 이제 ask-sa
sa 는 ask-role
role 에 대한 권한만을 사용할 수 있다.
sa 를 만들었으니 이를 연동할 kubeconfig 를 만들어 본다.
token 을 조회해 보자.
$ kubectl get secret -n ask ask-sa -ojsonpath={.data.token} | base64 -d
----- output -----
xxxxxxxxxxxxxxxxxxxxxxxxx
token 값으로 kubeconfig 의 user 접속 token 에 넣는다.
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: xxxxxxxxxxxxxxxxxxxxxxxx
server: https://xxxxxxxxxx.ap-northeast-2.eks.amazonaws.com
name: mycluster
contexts:
- context:
cluster: mycluster
user: ask-sa
namespace: ask
name: mycluster
current-context: mycluster
kind: Config
users:
- name: ask-sa
user:
token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
위의 kubeconfig 로 접속하면 ask 네임스페이스에 대해서 kubectl 명령어를 실행할 수 있다.
$ kubectl --kubeconfig ask.kubeconfig get pods
Error from server (Forbidden): pods is forbidden: User "system:serviceaccount:ask:ask-sa" cannot list resource "pods" in API group "" in the namespace "default"
default 네임스페이스에는 권한이 없으므로 권한 없음 에러가 리턴된다.
$ kubectl --kubeconfig ask.kubeconfig get pods -n ask
No resources found in ask namespace.
ask 네임스페이스의 파드는 정상적으로 조회된다.
컨테이너 이미지 저장소를 독립적으로 구성하는 방법을 살펴본다.
일반적으로 컨테이너 이미지는 CNCF 의 프로젝트 중에 하나인 Harbor 를 사용하여 구성한다. Helm chart 가 잘 되어 있어 쿠버네티스 위에서 설치하는 것은 매우 쉬운데 운영 환경에 걸맞는 HA 구성은 여러가지 고려해야 할 사항들이 있다.
그래서 이번에는 Harbor 를 HA 로 구성하는 방법을 알아본다.
Harbor 를 HA 로 구성하려면 아래의 전제 조건이 필요하다.
이 중에서 1, 2, 3 번은 구성되어 있다고 가정하고 이후를 살펴본다.
또한 Public Cloud 는 AWS 를 사용했고 쿠버네티스 클러스터는 EKS 를 사용했다.
아키텍처는 아래 그림과 같다.
[출처: https://goharbor.io/docs/1.10/img/ha.png]
AWS 의 RDS 를 사용했으며 Harbor 에서 사용할 필요한 user 생성과 권한을 부여한다.
# psql -U postgres
postgres=# CREATE DATABASE registry;
postgres=# CREATE USER harbor WITH ENCRYPTED PASSWORD 'xxxxxxx';
postgres=# GRANT ALL PRIVILEGES ON DATABASE registry TO harbor;
어드민 권한으로 데이터베이스에 접속하여 Harbor 에서 사용할 registry
database 를 생성한다. user 는 harbor
이고 필요한 password 를 생성한 다음 registry 데이터베이스의 모든 권한을 harbor 유저에 부여한다.
테이블과 스퀀스를 다루기 위해서는 아래 추가적으로 권한을 부여해야 한다. (아래 권한이 추가되지 않으면 harbor 유저로 테이블과 시퀀스에 대한 생성/조회/삭제/수정을 하지 못한다.
postgres=# \c registry
registry=# GRANT ALL ON ALL TABLES IN SCHEMA public TO harbor;
registry=# GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO harbor;
Harbor 는 캐시로 레디스를 사용하며 이 때 레디스 구성은 독립 혹은 레디스 + 센티널(sentinel) 구성만을 지원한다. 한마디로 클러스터 모드의 레디스는 지원하지 않는다.
AWS Elasticache Redis 서비스는 센티널을 지원하지 않아 굳이 Elasticache 서비스를 사용할 이유가 없다.
Elasticache 서비스의 레디스 구성으로 1개의 컨트롤노드 - 멀티 워커노드 로 하여 데이터 복제는 가능하나 1개의 컨트롤 노드가 무너지면 역시 장애가 발생하므로 서비스를 사용하여 구성하지 않았다.
이 후 살펴볼 Harbor Helm chart 에서 쿠버네티스 위에 레디스를 1개로 띄우는 internal 생성 방식을 사용한다.
레디스 구성을 HA 로 하고 싶다면, 레디스를 멀티 노드 센티널 구성으로 쿠버네티스 위에 띄우는 방법도 있으니 이는 레디스 설치 문서를 참고하면 된다. (센티널 구성일 때 Harbor chart 의 value 값은 코멘트로 적혀있으니 쉽게 이해할 수 있다)
AWS 에서 지원하는 공유 스토리지는 EFS
가 있다. EFS 는 NFSv4
프로토콜을 지원하니 공유 스토리지로 사용 가능하다.
먼저 AWS EFS 서비스에서 파일스토리지를 생성한다.
생성된 EFS 는 실제로 파일시스템의 스토리지가 생성된 것은 아니다. 일종의 정보를 생성한 것이며 필요에 따라 실제 스토리지를 생성하고 할당 받는 방식이다.
쿠버네티스에서는 Provisioner
, StroageClass
PVC
, PV
라는 스토리지 표준 관리 방법이 있다.
흔히 Provisioner 라고 말하는 CSI Driver
를 설치한다.
EKS 에서는 추가 기능으로 Amazon EFS CSI Driver
를 추가할 수 있다.
이 때 권한에서 중요한 한가지가 있는데 EKS node 에서 사용하는 role 에 (role 명은 eks 의 태그 정보를 확인해 보면 된다) AmazonEFSCSIDriverPolicy
정책이 반드시 추가되어 있어야 한다.
이제 스토리지 클래스를 설치하자.
$ curl -Lo efs-sc.yaml https://raw.githubusercontent.com/kubernetes-sigs/aws-efs-csi-driver/master/examples/kubernetes/dynamic_provisioning/specs/storageclass.yaml
$ vi efs-sc.yaml
---
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
name: taco-efs-storage
provisioner: efs.csi.aws.com
parameters:
provisioningMode: efs-ap
fileSystemId: fs-xxxxxxx # EFS 에서 생성한 fs id
directoryPerms: "700"
$ kubectl apply -f efs-sc.yaml
변경해야 할 것은 fileSystemId
로 앞서 EFS 에서 생성한 파일스토리지의 fs id
값으로 변경해 준다.
스토리지클래스가 잘 작동하는지 확인하기 위해서 아래와 같이 테스트 파드를 생성해 본다.
$ kubectl create ns harbor-ask
$ vi efs-test-pod.yaml
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: efs-claim
namespace: harbor-ask
spec:
accessModes:
- ReadWriteMany
storageClassName: taco-efs-storage
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: Pod
metadata:
name: efs-app
namespace: harbor-ask
spec:
containers:
- name: app
image: centos
command: ["/bin/sh"]
args: ["-c", "while true; do echo $(date -u) >> /data/out; sleep 5; done"]
volumeMounts:
- name: persistent-storage
mountPath: /data
volumes:
- name: persistent-storage
persistentVolumeClaim:
claimName: efs-claim
$ kubectl apply -f efs-test-pod.yaml
Pod 가 생성되고 Pod 로 접속하면 /data/out
파일에 시간이 출력되고 있으면 정상적으로 작동하는 것이다.
PVC 를 생성할 때 accessModes
가 ReadWriteMany
인 것도 확인하자.
이제 필요한 사전 구성은 마쳤으니 chart 로 설치를 진행한다.
먼저 chart 를 등록하고 다운 받는다.
$ helm repo add harbor https://helm.goharbor.io
$ helm repo update
$ helm fetch harbor/harbor --untar
차트를 다운 받을 필요는 없으니 관련 values.yaml 을 확인하기 위해서 참고용으로 다운 받았다.
필요한 value 를 설정한다.
$ vi ask-values.yaml
---
harborAdminPassword: "xxxxx"
expose:
type: ingress
tls:
enabled: true
certSource: secret
secret:
secretName: "taco-cat-tls"
ingress:
hosts:
core: harbor.xxx
className: "nginx"
externalURL: https://harbor.xxx
harborAdminPassword
는 Harbor 웹 화면에서 admin
계정으로 접속할 때 필요한 패스워드 이다.
expose
는 ingress 에 노출되는 값이며 도메인이 harbor.xxx
으로 DNS 에 연결되어 있으며 (DNS 가 없으면 로컬 컴퓨터에 /etc/hosts
파일에 등록해서 사용한다), 도메인 인증서는 앞에서 생성한 harbor-ask
네임스페이스에 taco-cat-tls
라는 secret 이름으로 저장되어 있다.
persistence:
enabled: true
resourcePolicy: "keep"
persistentVolumeClaim:
registry:
storageClass: "taco-efs-storage"
accessMode: ReadWriteMany
size: 1024Gi
jobservice:
jobLog:
storageClass: "taco-efs-storage"
accessMode: ReadWriteMany
size: 128Gi
redis:
storageClass: "taco-efs-storage"
accessMode: ReadWriteMany
size: 256Gi
trivy:
storageClass: "taco-efs-storage"
accessMode: ReadWriteMany
size: 128Gi
imageChartStorage:
disableredirect: false
type: filesystem
filesystem:
rootdirectory: /storage
persistence
는 Harbor 컴포넌트에서 사용하는 스토리지 정보이다. Harbor 차트에서 설치하는 컴포넌트는 registry
, jobservice
, redis
, trivy
, core
, portal
등이 있으며 스토리지가 필요한 컴포넌트만 기술하면 된다.
registry:
replicas: 2
portal:
replicas: 2
core:
replicas: 2
jobservice:
replicas: 2
trivy:
enabled: true
replicas: 2
notary:
enabled: false
cache:
enabled: true
expireHours: 24
각 컴포넌트의 Pod 갯수를 넣는다. HA 구성 이므로 최소 2 이상을 넣는다.
notary
는 이미지 서명 관련 컴포넌트로 이번 구성에서는 설치하지 않았다.
database:
type: external
external:
host: xxxxxx.ap-northeast-2.rds.amazonaws.com
port: "5432"
username: "harbor"
password: "xxxxxxx"
coreDatabase: "registry"
RDS 에 만들어진 외부 데이터베이스 정보를 넣는다.
redis:
type: internal
레디스는 내부에서 단일 Pod 로 생성한다.
전체 values 는 다음과 같다.
$ vi ask-ha-values.yaml
---
harborAdminPassword: "xxxxxx"
expose:
type: ingress
tls:
enabled: true
certSource: secret
secret:
secretName: "taco-cat-tls"
ingress:
hosts:
core: harbor.xxx
className: "nginx"
externalURL: https://harbor.xxx
persistence:
enabled: true
resourcePolicy: "keep"
persistentVolumeClaim:
registry:
storageClass: "taco-efs-storage"
accessMode: ReadWriteMany
size: 1024Gi
jobservice:
jobLog:
storageClass: "taco-efs-storage"
accessMode: ReadWriteMany
size: 128Gi
redis:
storageClass: "taco-efs-storage"
accessMode: ReadWriteMany
size: 256Gi
trivy:
storageClass: "taco-efs-storage"
accessMode: ReadWriteMany
size: 128Gi
imageChartStorage:
disableredirect: false
type: filesystem
filesystem:
rootdirectory: /storage
registry:
replicas: 2
portal:
replicas: 2
core:
replicas: 2
jobservice:
replicas: 2
trivy:
enabled: true
replicas: 2
notary:
enabled: false
cache:
enabled: true
expireHours: 24
database:
type: external
external:
host: xxxxxx.ap-northeast-2.rds.amazonaws.com
port: "5432"
username: "harbor"
password: "xxxxxxx"
coreDatabase: "registry"
redis:
type: internal
쿠버네티스에 배포한다.
$ helm upgrade -i harbor-ask harbor/harbor --version 1.12.3 -n harbor-ask -f ask-ha-values.yaml
Harbor 웹에 접속하여 사용자(tks)와 프로젝트(tks)를 만든다.
해당 프로젝트의 Members
탭에는 사용자가 등록되어 있어야 한다. (그래야 컨테이너 이미지를 올릴 수 있는 권한이 있다)
$ docker login harbor.xxx -u tks
Password:
$ docker pull hello-world
$ docker tag hello-world harbor.xxx/tks/hello-world
$ docker push harbor.xxx/tks/hello-world
MSA 패턴(CQRS, SAGA 등)을 코드로 구현하기 위해서 SpringBoot 으로 REST API 어플리케이션을 만드는 것을 정리하고 있는데 자료 구조외에 자주쓰게 되는 코드들이 있어서 이를 정리해 봤다.
어플리케이션 개발에 Java 를 사용하는 이유는 아직 우리나라에서는 Java 가 많이 쓰이기 때문이다. SpringBoot 으로 어느 정도 정리되면 이후에는 Gin (Go 언어 기반 웹 프레임워크)으로 정리할 예정이다.
Primitive Type 보다는 Object 를 많이 사용하기 때문에 null check 를 항상 해야 된다.
String nullString = null;
String emptyString = "";
if (Objects.isNull(nullString))
System.out.println("nullString is null");
if (Objects.nonNull(emptyString))
System.out.println("emptyString is not null");
--------- output -----------
nullString is null
emptyString is not null
Objects 클래스를 사용하여 가독성있게 null 체크를 할 수 있다.
null check 와 기본 값 세팅을 메소드 체이닝으로 바로 할 수 있다.
BigDecimal bd = null;
BigDecimal defaultValue = Optional.ofNullable(bd).orElse(BigDecimal.ZERO);
System.out.println(defaultValue);
--------- output -----------
0
Optional.ofNullable
은 static 메소드로 null
혹은 객체
를 담고있는 Optional 을 리턴한다.
Optional.orElse
는 메소드로 객체의 값이 null
일 경우 넘겨주는 아큐먼트로 그 값을 채운다.
여기서는 BigDecimal.ZERO 로 채워진 것을 알 수 있다.
null check 와 null 일 때 예외를 던질 수 있다.
BigDecimal bd = null;
Optional.ofNullable(bd).orElseThrow(() -> new IllegalArgumentException("db is null"));
System.out.println(defaultValue);
--------- output -----------
Exception in thread "main" java.lang.IllegalArgumentException: db is null
at com.ask.example.Main.lambda$setDefaultValue$0(Main.java:45)
at java.base/java.util.Optional.orElseThrow(Optional.java:403)
at com.ask.example.Main.setDefaultValue(Main.java:45)
at com.ask.example.Main.main(Main.java:24)
orElseThrow
메소드는 function 을 파라미터로 받으며 여기서는 람다함수를 사용하여 간단히 IllegalArgumentException 을 생성하여 던진다.
Stream은 Java 8부터 추가된 기능으로, Collection(Array, List, Map 등)과 같은 데이터 요소들을 처리하는데 도움을 주는 기능이다. Stream을 이용하면 다양한 작업을 수행할 수 있다.
filter()
메서드를 사용하여 특정 조건을 만족하는 요소들만 걸러낼 수 있다.map()
메서드를 사용하여 요소들을 다른 형태로 변환할 수 있다.sorted()
메서드를 사용하여 요소들을 정렬할 수 있다.collect()
메서드를 사용하여 요소들을 그룹화할 수 있다.reduce()
메서드를 사용하여 요소들을 축소하여 하나의 값으로 만들 수 있다.limit()
메서드를 사용하여 스트림의 크기를 제한할 수 있다.skip()
메서드를 사용하여 스트림의 앞부분 요소들을 건너뛸 수 있다.parallelStream()
메서드를 사용하여 스트림 요소들을 병렬로 처리할 수 있다.distinct()
메서드를 사용하여 스트림의 중복 요소들을 제거할 수 있다.joining()
메서드를 사용하여 문자열 요소들을 합쳐서 하나의 문자열로 만들 수 있다.먼저 Stream 을 만들어 보자.
String[] fruits = { "apple", "banana", "orange", "grape", "banana" };
Stream stream = Arrays.stream(fruits);
System.out.println(stream);
--------- output -----------
java.util.stream.ReferencePipeline$Head@36baf30c
List<String> fruits = new ArrayList<String>(
Arrays.asList("apple", "banana", "orange", "grape", "banana"));
Stream stream = fruits.stream();
System.out.println(stream);
--------- output -----------
java.util.stream.ReferencePipeline$Head@36baf30c
Stream 은 Collectors 클래스를 사용하여 다른 Collection 으로 쉽게 변경이 가능하다.
참고로 distinct 로도 중복 제거가 가능하다.
List<String> fruits = new ArrayList<String>(
Arrays.asList("apple", "banana", "orange", "grape", "banana"));
Set<String> newSet = fruits.stream()
.collect(Collectors.toSet());
newSet.forEach(item -> System.out.println(item));
--------- output -----------
banana
orange
apple
grape
중복된 banana 는 하나만 출력된다.
List<String> fruits = new ArrayList<String>(
Arrays.asList("apple", "banana", "orange", "grape", "banana"));
List<String> newList = fruits.stream()
.collect(Collectors.toCollection(
LinkedList::new
));
newList.forEach(item -> System.out.println(item));
--------- output -----------
apple
banana
orange
grape
banana
List<String> fruits = new ArrayList<String>(
Arrays.asList("apple", "banana", "orange", "grape", "banana"));
Map<String, Integer> newMap = fruits.stream()
.collect(Collectors.toSet()).stream()
.collect(Collectors.toMap(
Function.identity(),
String::length
));
newMap.forEach((key, value) -> System.out.println(key + " " + value));
--------- output -----------
banana 6
orange 6
apple 5
grape 5
Map 은 중복 키를 가질 수 없기 때문에 Set 으로 변환한 다음에 Map 으로 변환하였다.
여기서 Function.identity()
는 item 그 자체의 값으로 apple
과 같은 리스트의 요소를 가리키며, Map의 키로 사용되었다. String::Length
는 스트링 객체의 길이를 구하는 메소드를 호출한 리턴 값으로 Map 의 값으로 사용되었다.
map()
은 Item 각각에 대해서 다른 형태로 변경할 수 있다.
List<String> fruits = new ArrayList<String>(
Arrays.asList("apple", "banana", "orange", "grape", "banana"));
List<String> newList = fruits.stream()
.map(item -> item.toUpperCase())
.collect(Collectors.toList());
newList.forEach(item -> System.out.println(item));
--------- output -----------
APPLE
BANANA
ORANGE
GRAPE
BANANA
map()
은 아규먼트로 function
을 받으며 결과로 Stream 을 반환한다. 아규먼트로 들어가는 함수는 각 요소를 변경하여 반환하는 함수이어야 한다.
flatMap()
은 중첩된 Collection 을 한꺼풀 벗긴다고 생각하면 된다.
List<List<String>> listInList = Arrays.asList(
Arrays.asList("apple", "banana"),
Arrays.asList("orange", "grape", "banana"));
List<String> newList = listInList.stream()
.flatMap(Collection::stream)
.distinct()
.collect(Collectors.toList());
newList.forEach(item -> System.out.println(item));
--------- output -----------
apple
banana
orange
grape
flatMap()
도 function
를 아규먼트로 받고 결과를 Stream 으로 변환한다. 단 아규먼트로 들어가는 함수는 각 요소에 영향을 주면서 Stream
을 반환하는 함수이어야 한다.
enum 은 enum 타입이 반환된다.
enum Gender {
MALE("male"),
FEMALE("female");
private final String param;
Gender(String param) {
this.param = param;
}
String getParam() {
return this.param;
}
}
System.out.println(Gender.MALE.getParam());
--------- output -----------
male
Gender.MALE
값은 enum Gender
타입이다.
하지만 아래와 같이 문자열을 enum 값으로 변환할 수 있다. 이 방법은 Json
을 marshal , unmarshal 할 때 많이 사용된다.
enum Gender {
MALE("male"),
FEMALE("female");
private final String param;
private static final Map<String, Gender> paramMap =
Arrays.stream(Gender.values())
.collect(Collectors.toMap(
Gender::getParam,
Function.identity()
));
Gender(String param) {
this.param = param;
}
static Gender fromParam(String param) {
return Optional.ofNullable(param)
.map(paramMap::get)
.orElseThrow(() -> new IllegalArgumentException("param is not valid"));
}
String getParam() {
return this.param;
}
}
System.out.println(Gender.fromParam("male"));
--------- output -----------
MALE
static 으로 Map<String, Gender>
변수를 선언하여 거기에 값을 채워넣고, static fromParam
메소드를 사용하여 String 값으로 Gender
값을 가져온다.
Map 에는 다음과 같은 종류가 있다.
다만, MultiValueMap 을 사용하기 위해서는 spring-core
패키지를 import 해야 한다. (apache commons 의 commons-collections4 에도 MultiValueMap 은 있지만 spring-core 가 더 사용하기 편하다)
pom.xml 에 아래와 같은 dependcy 를 추가한다.
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>6.0.9</version>
</dependency>
</dependencies>
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("Accept-Encoding", "compress;q=0.5");
map.add("Accept-Encoding", "gzip;q=1.0");
map.forEach((key, value) -> System.out.println(key + " " + value));
--------- output -----------
Accept-Encoding [compress;q=0.5, gzip;q=1.0]
REST API 를 만들 때 Header 에 값을 넣기 위해서 위와 같이 MultiValueMap 을 사용한다.
Kubernetes 에는 이미 CronJob 이라는 리소스 타입이 있지만, Kubebuilder 을 이용하여 Custom Controller 로 재작성 해보는 연습을 해보도록 하자.
먼저, Project 구조를 만들기 위해 아래와 같이 kubebuilder init
명령어를 실행한다.
$ mkdir -p cronjob-kubebuilder
$ cd cronjob-kubebuilder
$ kubebuilder init --domain tutorial.kubebuilder.io --repo tutorial.kubebuilder.io/project
도메인을 tutorial.kubebuilder.io
로 했으므로 모든 API Group 은 <group>.tutorial.kubebuilder.io
방식으로 정해지게 된다. 또한 특별히 프로젝트 이름은 지정하지 않았는데, --project-name=<dns1123-label-string>
과 같이 옵션을 추가하지 않으면 폴더의 이름이 기본적으로 프로젝트 이름이 된다. (여기서 프로젝트명은 DNS-1123 label 규칙을 따라야 한다)
한가지 주의해야 할 점은 cronjob-kubebuilder
디렉토리는 $GOPATH
경로 아래에 있어서는 안된다. 이는 Go modules
의 규칙 때문인데 좀 더 자세히 알고 싶으면 https://go.dev/blog/using-go-modules 블로그 포스트를 읽어보자.
만들어진 프로젝트의 구조는 다음과 같다.
$ tree -L 2
.
├── Dockerfile
├── Makefile
├── PROJECT
├── README.md
├── cmd
│ └── main.go
├── config
│ ├── default
│ ├── manager
│ ├── prometheus
│ └── rbac
├── go.mod
├── go.sum
└── hack
└── boilerplate.go.txt
7 directories, 8 files
go.mod
파일은 모듈 디펜던시를 표시하고, Makefile
은 custom controller 를 빌드하고 디플로이 할 수 있다.
config
디렉토리 아래에는 Kustomize 로 작성되어 CustomResourceDefinition
, RBAC
, WebhookConfiguration
등의 yaml 파일들이 정의되어 있다.
특히, config/manager
디렉토리에는 Cluster 에 Custom Controller 를 파드 형태로 배포할 수 있는 Kustomize yaml 이 있고, config/rbac
디렉토리에는 서비스 어카운트로 Custom Controller 의 권한이 정의되어 있다.
Custom Controller 의 Entrypoint 는 cmd/main.go
파일이다.
처음 필요한 모듈을 임포트 한 것을 보면 아래 2개가 보인다.
controller-runtime
라이브러리Zap
package main
import (
"flag"
"fmt"
"os"
_ "k8s.io/client-go/plugin/pkg/client/auth"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
_ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
// +kubebuilder:scaffold:imports
)
모든 컨트롤러에는 Scheme
이 필요하다. 스킴은 Kind
와 Go types
간의 매핑을 제공해 준다.
var (
scheme = runtime.NewScheme()
setupLog = ctrl.Log.WithName("setup")
)
func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
//+kubebuilder:scaffold:scheme
}
main function
에는 아래의 내용들이 들어가 있다.
manager
를 생성하여 모든 Custom Controller 의 실행을 추적하고, shared cache 세팅하고, scheme
을 아규먼트로 넘기주어 클라이언트를 API 서버에 설정한다.func main() {
var metricsAddr string
var enableLeaderElection bool
var probeAddr string
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
"Enable leader election for controller manager. "+
"Enabling this will ensure there is only one active controller manager.")
opts := zap.Options{
Development: true,
}
opts.BindFlags(flag.CommandLine)
flag.Parse()
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
MetricsBindAddress: metricsAddr,
Port: 9443,
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "80807133.tutorial.kubebuilder.io",
})
if err != nil {
setupLog.Error(err, "unable to start manager")
os.Exit(1)
}
manager
생성 시에 컨트롤러가 특정 네임스페이스의 리소스만을 감시할 수 있도록 할 수 있다.
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Namespace: namespace,
MetricsBindAddress: metricsAddr,
Port: 9443,
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "80807133.tutorial.kubebuilder.io",
})
이렇게 특정 네임스페이스를 지정한 경우에는 권한을 ClusterRole
과 ClusterRoleBinding
에서 Role
과 RoleBinding
으로 변경하는 것을 권장한다.
그리고 MutiNamespacedCacheBuilder
를 사용하면 특정 네임스페이스의 묶음의 리소스만을 감시하게 제한할 수 있다.
var namespaces []string // List of Namespaces
cache.Options.Namespaces = namespaces
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
NewCache: cache.MultiNamespacedCacheBuilder(namespaces),
MetricsBindAddress: fmt.Sprintf("%s:%d", metricsHost, metricsPort),
Port: 9443,
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "80807133.tutorial.kubebuilder.io",
})
MultiNamespacedCacheBuilder
는 deprecated api 이므로 cache.Options.Namespaces
를 사용한다. (https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/cache#Options)
쿠버네티스에서 API 에 대해서 이야기할 때는 groups, versions, kinds and resources 4개의 용어를 사용한다.
쿠버네티스의 API Group 은 단순히 관련 기능의 모음이다. 각 Group 에는 하나 이상의 Version 이 있으며, 이름에서 알 수 있듯이 시간이 지남에 따라 API의 작동 방식을 변경할 수 있다.
각 API group-version 에는 하나 이상의 API type 이 포함되며, 이를 Kind 라고 부른다. Kind 는 Version 간에 양식을 변경할 수 있지만, 각 양식은 어떻게든 다른 양식의 모든 데이터를 저장할 수 있어야 한다(데이터를 필드 또는 주석에 저장할 수 있음). 즉, 이전 API 버전을 사용해도 최신 데이터가 손실되거나 손상되지 않는다.
Resource 란 간단히 말해서 API 안에서 Kind 를 사용하는 것이다. 종종, Kind 와 Resource 는 일대일로 매핑된다. 예를 들어, Pod Resource 는 Pod Kind 에 해당한다. 그러나 때로는 여러 Resource 에서 동일한 Kind를 반환할 수도 있다. 예를 들어, Scale Kind 는 deployments/scale 또는 replicasets/scale 과 같은 모든 scale 하위 리소스에 의해 반환된다. 이것이 바로 Kubernetes HorizontalPodAutoscaler 가 서로 다른 resource 와 상호 작용할 수 있는 이유다. 그러나 CRD를 사용하면 각 Kind 는 단일 resource 에 해당한다.
resource 는 항상 소문자이며, 관례에 따라 소문자 형태의 Kind를 사용한다.
특정 group-version 에서 어떤 kind 를 지칭할 때는 줄여서 GroupVersionKind 혹은 줄여서 GVK 라고 부른다. 같은 방식으로 resource 도 GroupVersionResource 혹은 GVR 이라고 부른다.
GVK 는 패키지에서 Go type 에 해당한다.
API 는 왜 만들어야 할까?
Kind 에 대해서 Custom Resource (CR) 과 Custom Resource Definition (CRD) 을 만들어야 한다. 그 이유는 CustomResourceDefinitions 으로 Kubernetes API 를 확장할 수 있기 때문이다.
새롭게 만드는 API 는 쿠버네티스에게 custom object 를 가리치는 방법이다.
기본으로 CRD 는 customized Objects 의 정의이며, CR 은 그것에 대한 인스턴스이다.
아래 명령으로 새로운 Kind 를 추가하자.
$ kubebuilder create api --group batch --version v1 --kind CronJob
Create Resource 와 Create Controller 를 하겠냐고 물으면 y
로 대답한다.
$ tree -L 2
.
├── Dockerfile
├── Makefile
├── PROJECT
├── README.md
├── api
│ └── v1
├── bin
│ └── controller-gen
├── cmd
│ └── main.go
├── config
│ ├── crd
│ ├── default
│ ├── manager
│ ├── prometheus
│ ├── rbac
│ └── samples
├── go.mod
├── go.sum
├── hack
│ └── boilerplate.go.txt
└── internal
└── controller
이 경우 batch.tutorial.kubebuilder.io/v1
에 해당하는 api/v1
디렉토리가 생성된다.
api/v1/cronjob_types.go
파일을 보면, 모든 쿠버네티스 Kind 에 공통으로 포함된 metadata 를 가리키는 meta/v1
API group 을 임포트 하고 있다.
package v1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
다음으로 Kind 의 Spec
과 Status
에 대한 type 을 정의 한다.
쿠버네티스는 원하는 상태(Spec)를 실제 클러스터 상태(Status) 및 외부 상태와 조정한 다음 관찰한 것(Status)를 기록하는 방식으로 작동한다. 따라서 모든 기능 object 는 spec 과 status 를 포함한다. ConfigMap 과 같은 몇몇 타입은 원하는 상태를 인코딩하지 않기 때문에 이 패턴을 따르지 않지만 대부분의 타입은 이 패턴을 따른다.
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
// CronJobSpec defines the desired state of CronJob
type CronJobSpec struct {
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
// Important: Run "make" to regenerate code after modifying this file
}
// CronJobStatus defines the observed state of CronJob
type CronJobStatus struct {
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
// Important: Run "make" to regenerate code after modifying this file
}
실제 Kind 에 해당하는 타입인 CronJob 과 CronJobList 를 정의한다. CronJob 은 루트 타입이며, CronJob kind를 설명한다. 모든 쿠버네티스 오브젝트와 마찬가지로, API version 과 Kind 를 설명하는 TypeMeta를 포함하며, name, namespace, labes 과 같은 것을 보유하는 ObjectMeta 도 포함한다.
CronJobList 는 단순히 여러 CronJob 을 위한 컨테이너이다. LIST와 같은 대량 작업에 사용되는 Kind 이다.
//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
// CronJob is the Schema for the cronjobs API
type CronJob struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec CronJobSpec `json:"spec,omitempty"`
Status CronJobStatus `json:"status,omitempty"`
}
//+kubebuilder:object:root=true
// CronJobList contains a list of CronJob
type CronJobList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []CronJob `json:"items"`
}
마지막으로 API group 에 Go 타입을 추가한다. 이렇게 하면 이 API group 의 타입을 모든 Scheme 에 추가할 수 있다.
func init() {
SchemeBuilder.Register(&CronJob{}, &CronJobList{})
}
쿠버네티스에는 API를 설계하는 방법에 대한 몇 가지 규칙이 있다. 즉, 직렬화된 모든 필드는 camelCase
여야 하며 JSON 구조체 태그를 사용하여 이를 지정한다. 또한, 필드가 비어 있을 때 직렬화에서 필드를 생략해야 한다는 것을 표시하기 위해 omitempty
구조체 태그를 사용할 수도 있다.
필드는 대부분의 기본 유형을 사용할 수 있다. 다만 숫자는 예외이다. API 호환성을 위해 정수의 경우 int32
및 int64
, 소수의 경우 resource.Quantity
와 같이 3가지 형식의 숫자를 허용한다.
Quantity 는 10진수에 대한 특수 표기법으로, 머신 간에 이식성을 높이기 위해 명시적으로 고정된 표현을 가지고 있다.
예를 들어 2m
값은 십진수 표기법에서 0.002
를 의미한다. 2Ki
는 십진수로 2048
을 의미하고, 2K
는 십진수로 2000
을 의미한다. 분수를 지정하려면 정수를 사용할 수 있는 접미사로 전환하면 된다(예: 2.5
는 2500m
).
지원되는 베이스는 두 가지이다: 10과 2(각각 10진수 및 2진수라고 함)이다. 10진수는 "nomal" SI 접미사(예: M
및 K
)로 표시되며, 2진수는 "mebi" 표기법(예: Mi
및 Ki
)으로 지정된다. 메가바이트와 메비바이트를 생각하면 된다.
우리가 사용하는 또 다른 특수 유형이 하나 더 있는데, 바로 metav1.Time
이다. 이것은 고정된 이식 가능한 직렬화 형식을 가지고 있다는 점을 제외하면 time.Time
과 동일하게 작동한다.
package v1
import (
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to b
CronJob 을 세부적으로 살펴보자.
먼저 spec 을 보면, spec 에는 원하는 상태가 저장되므로 controller 에 대한 모든 "입력" 은 여기에 저장된다.
기본적으로 크론잡에는 다음과 같은 요소가 필요하다:
편하게 만들어줄 몇 가지 추가 기능도 필요하다:
자신의 상태를 읽지 않기 때문에 job 이 실행되었는지 여부를 추적할 수 있는 다른 방법이 필요하다. 이를 위해 적어도 하나의 이전 job 을 사용할 수 있다.
// CronJobSpec defines the desired state of CronJob
type CronJobSpec struct {
//+kubebuilder:validation:MinLength=0
// The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron.
Schedule string `json:"schedule"`
//+kubebuilder:validation:Minimum=0
// Optional deadline in seconds for starting the job if it misses scheduled
// time for any reason. Missed jobs executions will be counted as failed ones.
// +optional
StartingDeadlineSeconds *int64 `json:"startingDeadlineSeconds,omitempty"`
// Specifies how to treat concurrent executions of a Job.
// Valid values are:
// - "Allow" (default): allows CronJobs to run concurrently;
// - "Forbid": forbids concurrent runs, skipping next run if previous run hasn't finished yet;
// - "Replace": cancels currently running job and replaces it with a new one
// +optional
ConcurrencyPolicy ConcurrencyPolicy `json:"concurrencyPolicy,omitempty"`
// This flag tells the controller to suspend subsequent executions, it does
// not apply to already started executions. Defaults to false.
// +optional
Suspend *bool `json:"suspend,omitempty"`
// Specifies the job that will be created when executing a CronJob.
JobTemplate batchv1.JobTemplateSpec `json:"jobTemplate"`
//+kubebuilder:validation:Minimum=0
// The number of successful finished jobs to retain.
// This is a pointer to distinguish between explicit zero and not specified.
// +optional
SuccessfulJobsHistoryLimit *int32 `json:"successfulJobsHistoryLimit,omitempty"`
//+kubebuilder:validation:Minimum=0
// The number of failed finished jobs to retain.
// This is a pointer to distinguish between explicit zero and not specified.
// +optional
FailedJobsHistoryLimit *int32 `json:"failedJobsHistoryLimit,omitempty"`
}
ConcurrencyPolicy 는 실제로는 string 이지만, 재사용과 유효성 검사를 쉽게 할 수 있으므로 타입을 재정의 했다.
// ConcurrencyPolicy describes how the job will be handled.
// Only one of the following concurrent policies may be specified.
// If none of the following policies is specified, the default one
// is AllowConcurrent.
// +kubebuilder:validation:Enum=Allow;Forbid;Replace
type ConcurrencyPolicy string
const (
// AllowConcurrent allows CronJobs to run concurrently.
AllowConcurrent ConcurrencyPolicy = "Allow"
// ForbidConcurrent forbids concurrent runs, skipping next run if previous
// hasn't finished yet.
ForbidConcurrent ConcurrencyPolicy = "Forbid"
// ReplaceConcurrent cancels currently running job and replaces it with a new one.
ReplaceConcurrent ConcurrencyPolicy = "Replace"
)
다음은 관찰된 상태를 저장하는 status 를 디자인해 보자.
현재 실행중인 job 목록과 마지막으로 job 을 성공적으로 실행한 시간을 유지한다. 그리고 직렬화를 위해서 time.Time
대신 metav1.Time
을 사용한다.
// CronJobStatus defines the observed state of CronJob
type CronJobStatus struct {
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
// Important: Run "make" to regenerate code after modifying this file
// A list of pointers to currently running jobs.
// +optional
Active []corev1.ObjectReference `json:"active,omitempty"`
// Information when was the last time the job was successfully scheduled.
// +optional
LastScheduleTime *metav1.Time `json:"lastScheduleTime,omitempty"`
}
컨트롤러는 쿠버네티스와 모든 operator 의 핵심이다.
컨트롤러의 역할은 주어진 오브젝트에 대해 실세계의 실제 상태(클러스터 상태와 잠재적으로 외부 상태(예: Kubelet의 경우 컨테이너 실행 또는 Cloud Provider 의 경우 로드밸런서)가 오브젝트의 원하는 상태와 일치하는지 확인하는 것이다. 각 컨트롤러는 하나의 루트 Kind 에 중점을 두지만 다른 Kind 와 상호 작용할 수 있다.
이 프로세스를 reconciling
이라고 부른다.
controller-runtime 에서 특정 kind 에 대한 reconciling 을 구현하는 로직을 Reconciler
라고 한다.
internal/controller/cronjob_controller.go
파일을 살펴 보자.
기본으로 임포트하는 모듈이 있는데, core controller-runtime 라이브러리와 client 패키지, API 타입 패키지가 있다.
package controllers
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
batchv1 "tutorial.kubebuilder.io/project/api/v1"
)
컨트롤러의 기본 로직은 다음과 같다.
임포트 모듈을 추가한다.
package controller
import (
"context"
"fmt"
"sort"
"time"
"github.com/robfig/cron"
kbatch "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ref "k8s.io/client-go/tools/reference"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
batchv1 "tutorial.kubebuilder.io/project/api/v1"
)
테스트를 위해서 Clock 을 추가한다.
// CronJobReconciler reconciles a CronJob object
type CronJobReconciler struct {
client.Client
Scheme *runtime.Scheme
Clock
}
type realClock struct{}
func (_ realClock) Now() time.Time { return time.Now() }
// clock knows how to get the current time.
// It can be used to fake out timing for testing.
type Clock interface {
Now() time.Time
}
RBAC 을 위해 batch group 의 job 을 핸들링 할 수 있는 권한을 추가한다.
//+kubebuilder:rbac:groups=batch.tutorial.kubebuilder.io,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=batch.tutorial.kubebuilder.io,resources=cronjobs/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=batch.tutorial.kubebuilder.io,resources=cronjobs/finalizers,verbs=update
//+kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=batch,resources=jobs/status,verbs=get
annotation 을 위한 변수를 추가한다.
var (
scheduledTimeAnnotation = "batch.tutorial.kubebuilder.io/scheduled-at"
)
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the CronJob object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.15.0/pkg/reconcile
func (r *CronJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
client 를 사용하여 CronJob 을 가져온다. 모든 client 의 메소드에는 취소가 가능하게 context 를 아규먼트로 받는다.
var cronJob batchv1.CronJob
if err := r.Get(ctx, req.NamespacedName, &cronJob); err != nil {
log.Error(err, "unable to fetch CronJob")
// we'll ignore not-found errors, since they can't be fixed by an immediate
// requeue (we'll need to wait for a new notification), and we can get them
// on deleted requests.
return ctrl.Result{}, client.IgnoreNotFound(err)
}
var childJobs kbatch.JobList
if err := r.List(ctx, &childJobs, client.InNamespace(req.Namespace), client.MatchingFields{jobOwnerKey: req.Name}); err != nil {
log.Error(err, "unable to list child Jobs")
return ctrl.Result{}, err
}
active job 을 조회했으면 이를 active, successful, failded job 으로 분류한다.
// find the active list of jobs
var activeJobs []*kbatch.Job
var successfulJobs []*kbatch.Job
var failedJobs []*kbatch.Job
var mostRecentTime *time.Time // find the last run so we can update the status
isJobFinished := func(job *kbatch.Job) (bool, kbatch.JobConditionType) {
for _, c := range job.Status.Conditions {
if (c.Type == kbatch.JobComplete || c.Type == kbatch.JobFailed) && c.Status == corev1.ConditionTrue {
return true, c.Type
}
}
return false, ""
}
getScheduledTimeForJob := func(job *kbatch.Job) (*time.Time, error) {
timeRaw := job.Annotations[scheduledTimeAnnotation]
if len(timeRaw) == 0 {
return nil, nil
}
timeParsed, err := time.Parse(time.RFC3339, timeRaw)
if err != nil {
return nil, err
}
return &timeParsed, nil
}
for i, job := range childJobs.Items {
_, finishedType := isJobFinished(&job)
switch finishedType {
case "": // ongoing
activeJobs = append(activeJobs, &childJobs.Items[i])
case kbatch.JobFailed:
failedJobs = append(failedJobs, &childJobs.Items[i])
case kbatch.JobComplete:
successfulJobs = append(successfulJobs, &childJobs.Items[i])
}
// We'll store the launch time in an annotation, so we'll reconstitute that from
// the active jobs themselves.
scheduledTimeForJob, err := getScheduledTimeForJob(&job)
if err != nil {
log.Error(err, "unable to parse schedule time for child job", "job", &job)
continue
}
if scheduledTimeForJob != nil {
if mostRecentTime == nil {
mostRecentTime = scheduledTimeForJob
} else if mostRecentTime.Before(*scheduledTimeForJob) {
mostRecentTime = scheduledTimeForJob
}
}
}
if mostRecentTime != nil {
cronJob.Status.LastScheduleTime = &metav1.Time{Time: *mostRecentTime}
} else {
cronJob.Status.LastScheduleTime = nil
}
cronJob.Status.Active = nil
for _, activeJob := range activeJobs {
jobRef, err := ref.GetReference(r.Scheme, activeJob)
if err != nil {
log.Error(err, "unable to make reference to active job", "job", activeJob)
continue
}
cronJob.Status.Active = append(cronJob.Status.Active, *jobRef)
}
디버깅을 위해서 log 를 남긴다.
log.V(1).Info("job count", "active jobs", len(activeJobs), "successful jobs", len(successfulJobs), "failed jobs", len(failedJobs))
status 를 업데이트 한다.
if err := r.Status().Update(ctx, &cronJob); err != nil {
log.Error(err, "unable to update CronJob status")
return ctrl.Result{}, err
}
// NB: deleting these are "best effort" -- if we fail on a particular one,
// we won't requeue just to finish the deleting.
if cronJob.Spec.FailedJobsHistoryLimit != nil {
sort.Slice(failedJobs, func(i, j int) bool {
if failedJobs[i].Status.StartTime == nil {
return failedJobs[j].Status.StartTime != nil
}
return failedJobs[i].Status.StartTime.Before(failedJobs[j].Status.StartTime)
})
for i, job := range failedJobs {
if int32(i) >= int32(len(failedJobs))-*cronJob.Spec.FailedJobsHistoryLimit {
break
}
if err := r.Delete(ctx, job, client.PropagationPolicy(metav1.DeletePropagationBackground)); client.IgnoreNotFound(err) != nil {
log.Error(err, "unable to delete old failed job", "job", job)
} else {
log.V(0).Info("deleted old failed job", "job", job)
}
}
}
if cronJob.Spec.SuccessfulJobsHistoryLimit != nil {
sort.Slice(successfulJobs, func(i, j int) bool {
if successfulJobs[i].Status.StartTime == nil {
return successfulJobs[j].Status.StartTime != nil
}
return successfulJobs[i].Status.StartTime.Before(successfulJobs[j].Status.StartTime)
})
for i, job := range successfulJobs {
if int32(i) >= int32(len(successfulJobs))-*cronJob.Spec.SuccessfulJobsHistoryLimit {
break
}
if err := r.Delete(ctx, job, client.PropagationPolicy(metav1.DeletePropagationBackground)); (err) != nil {
log.Error(err, "unable to delete old successful job", "job", job)
} else {
log.V(0).Info("deleted old successful job", "job", job)
}
}
}
CronJob 객체에 suspend 값이 세팅되어 있다면 CronJob 을 일시 중단한다. CronJob 을 삭제하지 않고 잠시 멈추고 싶을 때 사용할 수 있다.
if cronJob.Spec.Suspend != nil && *cronJob.Spec.Suspend {
log.V(1).Info("cronjob suspended, skipping")
return ctrl.Result{}, nil
}
잠시 멈춤 상태가 아니라면 다음 스케줄을 가져온다.
getNextSchedule := func(cronJob *batchv1.CronJob, now time.Time) (lastMissed time.Time, next time.Time, err error) {
sched, err := cron.ParseStandard(cronJob.Spec.Schedule)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("Unparseable schedule %q: %v", cronJob.Spec.Schedule, err)
}
// for optimization purposes, cheat a bit and start from our last observed run time
// we could reconstitute this here, but there's not much point, since we've
// just updated it.
var earliestTime time.Time
if cronJob.Status.LastScheduleTime != nil {
earliestTime = cronJob.Status.LastScheduleTime.Time
} else {
earliestTime = cronJob.ObjectMeta.CreationTimestamp.Time
}
if cronJob.Spec.StartingDeadlineSeconds != nil {
// controller is not going to schedule anything below this point
schedulingDeadline := now.Add(-time.Second * time.Duration(*cronJob.Spec.StartingDeadlineSeconds))
if schedulingDeadline.After(earliestTime) {
earliestTime = schedulingDeadline
}
}
if earliestTime.After(now) {
return time.Time{}, sched.Next(now), nil
}
starts := 0
for t := sched.Next(earliestTime); !t.After(now); t = sched.Next(t) {
lastMissed = t
// An object might miss several starts. For example, if
// controller gets wedged on Friday at 5:01pm when everyone has
// gone home, and someone comes in on Tuesday AM and discovers
// the problem and restarts the controller, then all the hourly
// jobs, more than 80 of them for one hourly scheduledJob, should
// all start running with no further intervention (if the scheduledJob
// allows concurrency and late starts).
//
// However, if there is a bug somewhere, or incorrect clock
// on controller's server or apiservers (for setting creationTimestamp)
// then there could be so many missed start times (it could be off
// by decades or more), that it would eat up all the CPU and memory
// of this controller. In that case, we want to not try to list
// all the missed start times.
starts++
if starts > 100 {
// We can't get the most recent times so just return an empty slice
return time.Time{}, time.Time{}, fmt.Errorf("Too many missed start times (> 100). Set or decrease .spec.startingDeadlineSeconds or check clock skew.")
}
}
return lastMissed, sched.Next(now), nil
}
// figure out the next times that we need to create
// jobs at (or anything we missed).
missedRun, nextRun, err := getNextSchedule(&cronJob, r.Now())
if err != nil {
log.Error(err, "unable to figure out CronJob schedule")
// we don't really care about requeuing until we get an update that
// fixes the schedule, so don't return an error
return ctrl.Result{}, nil
}
requeue 할 값을 준비만 해 놓는다.
scheduledResult := ctrl.Result{RequeueAfter: nextRun.Sub(r.Now())} // save this so we can re-use it elsewhere
log = log.WithValues("now", r.Now(), "next run", nextRun)
if missedRun.IsZero() {
log.V(1).Info("no upcoming scheduled times, sleeping until next")
return scheduledResult, nil
}
// make sure we're not too late to start the run
log = log.WithValues("current run", missedRun)
tooLate := false
if cronJob.Spec.StartingDeadlineSeconds != nil {
tooLate = missedRun.Add(time.Duration(*cronJob.Spec.StartingDeadlineSeconds) * time.Second).Before(r.Now())
}
if tooLate {
log.V(1).Info("missed starting deadline for last run, sleeping till next")
// TODO(directxman12): events
return scheduledResult, nil
}
// figure out how to run this job -- concurrency policy might forbid us from running
// multiple at the same time...
if cronJob.Spec.ConcurrencyPolicy == batchv1.ForbidConcurrent && len(activeJobs) > 0 {
log.V(1).Info("concurrency policy blocks concurrent runs, skipping", "num active", len(activeJobs))
return scheduledResult, nil
}
// ...or instruct us to replace existing ones...
if cronJob.Spec.ConcurrencyPolicy == batchv1.ReplaceConcurrent {
for _, activeJob := range activeJobs {
// we don't care if the job was already deleted
if err := r.Delete(ctx, activeJob, client.PropagationPolicy(metav1.DeletePropagationBackground)); client.IgnoreNotFound(err) != nil {
log.Error(err, "unable to delete active job", "job", activeJob)
return ctrl.Result{}, err
}
}
}
constructJobForCronJob := func(cronJob *batchv1.CronJob, scheduledTime time.Time) (*kbatch.Job, error) {
// We want job names for a given nominal start time to have a deterministic name to avoid the same job being created twice
name := fmt.Sprintf("%s-%d", cronJob.Name, scheduledTime.Unix())
job := &kbatch.Job{
ObjectMeta: metav1.ObjectMeta{
Labels: make(map[string]string),
Annotations: make(map[string]string),
Name: name,
Namespace: cronJob.Namespace,
},
Spec: *cronJob.Spec.JobTemplate.Spec.DeepCopy(),
}
for k, v := range cronJob.Spec.JobTemplate.Annotations {
job.Annotations[k] = v
}
job.Annotations[scheduledTimeAnnotation] = scheduledTime.Format(time.RFC3339)
for k, v := range cronJob.Spec.JobTemplate.Labels {
job.Labels[k] = v
}
if err := ctrl.SetControllerReference(cronJob, job, r.Scheme); err != nil {
return nil, err
}
return job, nil
}
// actually make the job...
job, err := constructJobForCronJob(&cronJob, missedRun)
if err != nil {
log.Error(err, "unable to construct job from template")
// don't bother requeuing until we get a change to the spec
return scheduledResult, nil
}
// ...and create it on the cluster
if err := r.Create(ctx, job); err != nil {
log.Error(err, "unable to create Job for CronJob", "job", job)
return ctrl.Result{}, err
}
log.V(1).Info("created Job for CronJob run", "job", job)
// we'll requeue once we see the running job, and update our status
return scheduledResult, nil
}
var (
jobOwnerKey = ".metadata.controller"
apiGVStr = batchv1.GroupVersion.String()
)
// SetupWithManager sets up the controller with the Manager.
func (r *CronJobReconciler) SetupWithManager(mgr ctrl.Manager) error {
// set up a real clock, since we're not in a test
if r.Clock == nil {
r.Clock = realClock{}
}
if err := mgr.GetFieldIndexer().IndexField(context.Background(), &kbatch.Job{}, jobOwnerKey, func(rawObj client.Object) []string {
// grab the job object, extract the owner...
job := rawObj.(*kbatch.Job)
owner := metav1.GetControllerOf(job)
if owner == nil {
return nil
}
// ...make sure it's a CronJob...
if owner.APIVersion != apiGVStr || owner.Kind != "CronJob" {
return nil
}
// ...and if so, return it
return []string{owner.Name}
}); err != nil {
return err
}
return ctrl.NewControllerManagedBy(mgr).
For(&batchv1.CronJob{}).
Owns(&kbatch.Job{}).
Complete(r)
}
$ kubebuilder create webhook --group batch --version v1 --kind CronJob --defaulting --programmatic-validation
api/v1/cronjob_webhook.go
파일이 생성된다. 해당 파일에 체크 로직을 추가한다.
// Default implements webhook.Defaulter so a webhook will be registered for the type
func (r *CronJob) Default() {
cronjoblog.Info("default", "name", r.Name)
if r.Spec.ConcurrencyPolicy == "" {
r.Spec.ConcurrencyPolicy = AllowConcurrent
}
if r.Spec.Suspend == nil {
r.Spec.Suspend = new(bool)
}
if r.Spec.SuccessfulJobsHistoryLimit == nil {
r.Spec.SuccessfulJobsHistoryLimit = new(int32)
*r.Spec.SuccessfulJobsHistoryLimit = 3
}
if r.Spec.FailedJobsHistoryLimit == nil {
r.Spec.FailedJobsHistoryLimit = new(int32)
*r.Spec.FailedJobsHistoryLimit = 1
}
}
var _ webhook.Validator = &CronJob{}
// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
func (r *CronJob) ValidateCreate() error {
cronjoblog.Info("validate create", "name", r.Name)
return r.validateCronJob()
}
// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
func (r *CronJob) ValidateUpdate(old runtime.Object) error {
cronjoblog.Info("validate update", "name", r.Name)
return r.validateCronJob()
}
// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
func (r *CronJob) ValidateDelete() error {
cronjoblog.Info("validate delete", "name", r.Name)
// TODO(user): fill in your validation logic upon object deletion.
return nil
}
func (r *CronJob) validateCronJob() error {
var allErrs field.ErrorList
if err := r.validateCronJobName(); err != nil {
allErrs = append(allErrs, err)
}
if err := r.validateCronJobSpec(); err != nil {
allErrs = append(allErrs, err)
}
if len(allErrs) == 0 {
return nil
}
return apierrors.NewInvalid(
schema.GroupKind{Group: "batch.tutorial.kubebuilder.io", Kind: "CronJob"},
r.Name, allErrs)
}
func (r *CronJob) validateCronJobSpec() *field.Error {
// The field helpers from the kubernetes API machinery help us return nicely
// structured validation errors.
return validateScheduleFormat(
r.Spec.Schedule,
field.NewPath("spec").Child("schedule"))
}
func validateScheduleFormat(schedule string, fldPath *field.Path) *field.Error {
if _, err := cron.ParseStandard(schedule); err != nil {
return field.Invalid(fldPath, schedule, err.Error())
}
return nil
}
func (r *CronJob) validateCronJobName() *field.Error {
if len(r.ObjectMeta.Name) > validationutils.DNS1035LabelMaxLength-11 {
// The job name length is 63 character like all Kubernetes objects
// (which must fit in a DNS subdomain). The cronjob controller appends
// a 11-character suffix to the cronjob (`-$TIMESTAMP`) when creating
// a job. The job name length limit is 63 characters. Therefore cronjob
// names must have length <= 63-11=52. If we don't validate this here,
// then job creation will fail later.
return field.Invalid(field.NewPath("metadata").Child("name"), r.Name, "must be no more than 52 characters")
}
return nil
}
CR 과 CRD yaml 을 만드는 명령어를 수행한다.
$ make manifests
CRD 를 배포한다.
$ make install
WebHook 를 로컬에서 다른 터미널로 실행한다.
$ export ENABLE_WEBHOOKS=false
$ make run
config/samples/batch_v1_cronjob.yaml
파일에 값을 추가한다.
apiVersion: batch.tutorial.kubebuilder.io/v1
kind: CronJob
metadata:
labels:
app.kubernetes.io/name: cronjob
app.kubernetes.io/instance: cronjob-sample
app.kubernetes.io/part-of: cronjob-kubebuilder
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/created-by: cronjob-kubebuilder
name: cronjob-sample
spec:
schedule: "*/1 * * * *"
startingDeadlineSeconds: 60
concurrencyPolicy: Allow # explicitly specify, but Allow is also default.
jobTemplate:
spec:
template:
spec:
containers:
- name: hello
image: busybox
args:
- /bin/sh
- -c
- date; echo Hello from the Kubernetes cluster
restartPolicy: OnFailure
Kubebuilder 의 아키텍처에 대해서 살펴보고 Kubebuilder 로 프로젝트를 생성하는 방법을 알아본다.
[출처: https://book.kubebuilder.io/architecture.html]
위의 다이어그램에서 Kubebuilder 는 controller-runtime 모듈을 사용하는 것을 알 수 있다. 또한 사용자의 비즈니스 로직은 Reconciler 에 위치 시킨다는 것을 알 수 있다.
Kubebuilder 를 사용하기 위해서 사전 준비 작업이 필요하다.
kubebuilder 는 간단히 다운 받아서 설치할 수 있다. ~/bin/
디렉토리가 path
로 잡혀있기 때문에 다운 받은 바이너리 파일을 이 곳으로 이동시켰다.
$ cd ~/Documents/tmp
$ curl -L -o kubebuilder https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)
$ chmod +x kubebuilder
$ mv ~/Documents/tmp/kubebuilder ~/bin/kubebuilder
$ kubebuilder version
--- output ---
Version: main.version{KubeBuilderVersion:"3.10.0", KubernetesVendor:"1.26.1", GitCommit:"0fa57405d4a892efceec3c5a902f634277e30732", BuildDate:"2023-04-15T08:10:35Z", GoOs:"darwin", GoArch:"amd64"}
$ cd ~/Documents/tmp
$ curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
$ mv ~/Documents/tmp/kustomize ~/bin/kustomize
$ kustomize version
--- output ---
v5.0.3
$ cd ~/Documents/tmp
$ [ $(uname -m) = x86_64 ]&& curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.19.0/kind-darwin-amd64
$ chmod +x kind
$ mv ~/Documents/tmp/kind ~/bin/kind
$ kind version
--- output ---
kind v0.19.0 go1.20.4 darwin/amd64
$ kind create cluster
$ cd ~/Documents/tmp
$ curl -LO "https://dl.k8s.io/release/v1.27.1/bin/darwin/amd64/kubectl"
$ chmod +x kubectl
$ mv kubectl ~/bin/kubectl
$ kubectl version
Client Version: version.Info{Major:"1", Minor:"27", GitVersion:"v1.27.1", GitCommit:"4c9411232e10168d7b050c49a1b59f6df9d7ea4b", GitTreeState:"clean", BuildDate:"2023-04-14T13:21:19Z", GoVersion:"go1.20.3", Compiler:"gc", Platform:"darwin/amd64"}
Kustomize Version: v5.0.1
Server Version: version.Info{Major:"1", Minor:"27", GitVersion:"v1.27.1", GitCommit:"4c9411232e10168d7b050c49a1b59f6df9d7ea4b", GitTreeState:"clean", BuildDate:"2023-05-12T19:03:40Z", GoVersion:"go1.20.3", Compiler:"gc", Platform:"linux/amd64"}
kubebuilder 명령어로 간단히 프로젝트와 API 를 생성할 수 있다. 즉, 필요한 코드들이 자동으로 생성된다.
먼저 프로젝트를 생성한다.
$ mkdir -p guestbook-kubebuilder
$ cd guestbook-kubebuilder
$ kubebuilder init --domain my.domain --repo my.domain/guestbook
--- output ---
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
Get controller runtime:
$ go get sigs.k8s.io/controller-runtime@v0.14.4
go: downloading sigs.k8s.io/controller-runtime v0.14.4
go: downloading k8s.io/apimachinery v0.26.1
go: downloading github.com/prometheus/client_golang v1.14.0
go: downloading k8s.io/client-go v0.26.1
go: downloading k8s.io/utils v0.0.0-20221128185143-99ec85e7a448
go: downloading github.com/prometheus/client_model v0.3.0
go: downloading k8s.io/api v0.26.1
go: downloading k8s.io/component-base v0.26.1
go: downloading golang.org/x/time v0.3.0
go: downloading k8s.io/apiextensions-apiserver v0.26.1
go: downloading github.com/matttproud/golang_protobuf_extensions v1.0.2
go: downloading golang.org/x/net v0.3.1-0.20221206200815-1e63c2f08a10
go: downloading github.com/imdario/mergo v0.3.6
go: downloading k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280
go: downloading golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b
Update dependencies:
$ go mod tidy
go: downloading go.uber.org/goleak v1.2.0
Next: define a resource with:
$ kubebuilder create api
다음으로 api 를 생성한다.
$ kubebuilder create api --group webapp --version v1 --kind Guestbook
--- output ---
Create Resource [y/n]
y
Create Controller [y/n]
y
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
api/v1/guestbook_types.go
api/v1/groupversion_info.go
internal/controller/suite_test.go
internal/controller/guestbook_controller.go
Update dependencies:
$ go mod tidy
Running make:
$ make generate
mkdir -p /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin
test -s /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen && /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen --version | grep -q v0.11.3 || \
GOBIN=/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.11.3
go: downloading sigs.k8s.io/controller-tools v0.11.3
go: downloading golang.org/x/tools v0.4.0
go: downloading k8s.io/utils v0.0.0-20221107191617-1a15be271d1d
go: downloading github.com/mattn/go-colorable v0.1.9
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
Next: implement your new API and generate the manifests (e.g. CRDs,CRs) with:
$ make manifests
CR 이나 CRD 를 수정하면 마지막의 make manifests
를 수행하여 다신 generation 해야 한다고 친절히 알려주고 있다.
CR 과 CRD 는 아래 guestbook_types.go
파일에 struct 로 생성되어 있다. 이곳을 원하는 대로 변경하면 된다.
테스트로 아래과 같이 변경하자.
// GuestbookSpec defines the desired state of Guestbook
type GuestbookSpec struct {
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
// Important: Run "make" to regenerate code after modifying this file
// Quantity of instances
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=10
Size int32 `json:"size"`
// Name of the ConfigMap for GuestbookSpec's configuration
// +kubebuilder:validation:MaxLength=15
// +kubebuilder:validation:MinLength=1
ConfigMapName string `json:"configMapName"`
// +kubebuilder:validation:Enum=Phone;Address;Name
Type string `json:"alias,omitempty"`
}
// GuestbookStatus defines the observed state of Guestbook
type GuestbookStatus struct {
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
// Important: Run "make" to regenerate code after modifying this file
// PodName of the active Guestbook node.
Active string `json:"active"`
// PodNames of the standby Guestbook nodes.
Standby []string `json:"standby"`
}
//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
// Guestbook is the Schema for the guestbooks API
type Guestbook struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec GuestbookSpec `json:"spec,omitempty"`
Status GuestbookStatus `json:"status,omitempty"`
}
//+kubebuilder:object:root=true
// GuestbookList contains a list of Guestbook
type GuestbookList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Guestbook `json:"items"`
}
Guestbook
struct 에 있는 metav1.TypeMeta
와 metav1.ObjectMeta
를 설명하면, 전자는 우리가 흔히 보는 yaml 파일에서 apiVersion
과 Kind
이고 후자는 metadata
의 name
, namespace
등을 나타낸다. 다음에 우리가 정의한 Spec
과 Status
가 있음을 알 수 있다.
CRD 를 cluster 에 설치한다.
$ make install
--- output ---
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/kustomize || { curl -Ss "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" --output install_kustomize.sh && bash install_kustomize.sh 5.0.0 /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin; rm install_kustomize.sh; }
v5.0.0
kustomize installed to /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/kustomize
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/kustomize build config/crd | kubectl apply -f -
customresourcedefinition.apiextensions.k8s.io/guestbooks.webapp.my.domain created
controller 를 실행시킨다. (터미널에서 포그라운드로 실행한다)
$ make run
--- output ---
test -s /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen && /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen --version | grep -q v0.11.3 || \
GOBIN=/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.11.3
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
go vet ./...
go run ./cmd/main.go
2023-05-24T17:18:18+09:00 INFO controller-runtime.metrics Metrics server is starting to listen {"addr": ":8080"}
2023-05-24T17:18:18+09:00 INFO setup starting manager
2023-05-24T17:18:18+09:00 INFO Starting server {"kind": "health probe", "addr": "[::]:8081"}
2023-05-24T17:18:18+09:00 INFO Starting server {"path": "/metrics", "kind": "metrics", "addr": "[::]:8080"}
2023-05-24T17:18:18+09:00 INFO Starting EventSource {"controller": "guestbook", "controllerGroup": "webapp.my.domain", "controllerKind": "Guestbook", "source": "kind source: *v1.Guestbook"}
2023-05-24T17:18:18+09:00 INFO Starting Controller {"controller": "guestbook", "controllerGroup": "webapp.my.domain", "controllerKind": "Guestbook"}
2023-05-24T17:18:18+09:00 INFO Starting workers {"controller": "guestbook", "controllerGroup": "webapp.my.domain", "controllerKind": "Guestbook", "worker count": 1}
참고로 앞서 api 를 생성할 때 Create Resource [y/n] y
로 했다면 CR 이 config/samples
디렉토리 아래에 생성되어 있다.
여기에 Spec 부분만 추가한다.
$ tree config/samples
config/samples
├── kustomization.yaml
└── webapp_v1_guestbook.yaml
$ vi config/samples/webapp_v1_guestbook.yaml
--- output ---
apiVersion: webapp.my.domain/v1
kind: Guestbook
metadata:
labels:
app.kubernetes.io/name: guestbook
app.kubernetes.io/instance: guestbook-sample
app.kubernetes.io/part-of: guestbook-kubebuilder
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/created-by: guestbook-kubebuilder
name: guestbook-sample
spec:
# TODO(user): Add fields here
size: 1
configMapName: "myconfig"
alias: "Address"
터미널을 새로 열어서 이를 설치한다.
$ kubectl apply -k config/samples/
--- output ---
guestbook.webapp.my.domain/guestbook-sample created
$ kubectl get guestbook
--- output ---
NAME AGE
guestbook-sample 29s
controller 를 cluster 안에서 돌리기 위해서는 먼저 이미지를 만들어야 한다.
$ docker login -u seungkyua
--- output ---
Password:
Login Succeeded
$ make docker-build docker-push IMG=docker.io/seungkyua/guestbook-kubebuilder:1.0
다음은 image 를 가지고 deploy 한다.
$ make deploy IMG=docker.io/seungkyua/guestbook-kubebuilder:1.0
--- output ---
test -s /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen && /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen --version | grep -q v0.11.3 || \
GOBIN=/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.11.3
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/kustomize || { curl -Ss "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" --output install_kustomize.sh && bash install_kustomize.sh 5.0.0 /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin; rm install_kustomize.sh; }
cd config/manager && /Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/kustomize edit set image controller=docker.io/seungkyua/guestbook-kubebuilder:1.0
/Users/ahnsk/Documents/github.com/seungkyua/guestbook-kubebuilder/bin/kustomize build config/default | kubectl apply -f -
# Warning: 'patchesStrategicMerge' is deprecated. Please use 'patches' instead. Run 'kustomize edit fix' to update your Kustomization automatically.
namespace/guestbook-kubebuilder-system created
customresourcedefinition.apiextensions.k8s.io/guestbooks.webapp.my.domain configured
serviceaccount/guestbook-kubebuilder-controller-manager created
role.rbac.authorization.k8s.io/guestbook-kubebuilder-leader-election-role created
clusterrole.rbac.authorization.k8s.io/guestbook-kubebuilder-manager-role created
clusterrole.rbac.authorization.k8s.io/guestbook-kubebuilder-metrics-reader created
clusterrole.rbac.authorization.k8s.io/guestbook-kubebuilder-proxy-role created
rolebinding.rbac.authorization.k8s.io/guestbook-kubebuilder-leader-election-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/guestbook-kubebuilder-manager-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/guestbook-kubebuilder-proxy-rolebinding created
service/guestbook-kubebuilder-controller-manager-metrics-service created
deployment.apps/guestbook-kubebuilder-controller-manager created
확인하면 다음과 같이 pod 가 설치된 것을 알 수 있다.
$ kubectl get pods -n guestbook-kubebuilder-system
--- output ---
NAME READY STATUS RESTARTS AGE
guestbook-kubebuilder-controller-manager-5f74f9d765-r68gn 2/2 Running 0 2m55s
$ make uninstall
$ make undeploy
DevOps 는 개발자와 운영자의 역할을 함께 수행하는 것으로 개발과 운영의 책임을 공동으로 진다. 처음 이 단어를 접한 것이 2011년 OpenStack Summit 에 참석했을 때인데 클라우드, 그 중에서 IaaS(Infrastructure as a Service)가 널리 퍼지기 시작했을 때다. DevOps 는 클라우드 기반에서 빠르게 개발하고, 배포하고, 운영하기 위해서 스타트업 회사를 중심으로 빠르게 퍼지기 시작했다.
아래는 클라우드 가상머신 기반의 DevOps 영역 중 CI/CD 에 대한 프로세스이다. 개발 언어는 Java 를 기준으로 표현하였으며, 이하 모든 설명은 Java 를 기준으로 설명한다.
2015년 7월 쿠버네티스 1.0 버전이 릴리즈 되면서 DevOps 는 가상머신이 아니라 컨테이너 기반으로 점차 변화하였다. 쿠버네티스 이전의 Ops는 가상머신을 빠르게 만들고 개발된 소스 코드를 자동으로 통합 빌드하여 배포하는 영역이었다. 하지만 쿠버네티스가 나오고, 컨테이너 관리가 효율적/안정적으로 변하면서 Ops 는 소스 코드 통합 빌드, 컨테이너 이미지 만들기와 쿠버네티스에 배포, 운영하는 영역으로 바뀌었다. 즉, Ops 영역을 맡은 운영자는 컨테이너도 알아야 하고, 쿠버네티스도 알아야 한다는 의미이다.
결국 쿠버네티스 기반의 DevOps 는 소스 코드 개발, 통합 빌드, 컨테이너 이미지화, 배포의 영역 모두를 의미한다. 이를 간략하게 프로세스로 표시하면 다음과 같다.
여기서 부터 문제가 발생한다. 기존의 Dev 역할은 인프라스트럭처가 가상 머신이든 쿠버네티스이든 상관이 없지만 Ops 역할은 컨테이너와 쿠버네티스라는 새로운 기술을 알아야 하는데 해당 기술을 습득하기까지는 어느 정도의 기술 허들을 넘어야 하고 일정 기간이 지나야 한다. (클라우드 기술이 널리 퍼지기까지 기간을 생각해 보면 쉽게 이해될 것이다)
또한 배포 영역을 생각해 보면 결코 쉬운 문제가 아니다. 배포 전략에는 아래와 같은 3가지 방법이 존재한다. (크게는 4가지 이지만 가장 단순한 Recreate 배포는 생략하였다)
사족이지만 카나리아 배포를 “까나리”라고 발음하지 말자. “까나리”는 액젓이다.
개발자는 개발의 영역 즉, Dev 영역에 집중하게 하자. 어려운 Ops 영역은 시스템으로 자동으로 동작하도록 제공하자.
앞서 간단히 살펴본 개발/배포 프로세스를 다시 살펴보자.
1번과 2번은 개발자가 이제까지 하던 방식 그대로 개발하면 된다. 우리가 시스템으로 만들어 제공해야 할 부분은 3, 4, 5 번 영역이다.
해당 시스템에 대한 아키텍처를 구성하면 다음과 같다.
Kubernetes custom controller 개발에 가장 잘 맞는 프로그래밍 언어는 Go 이다. Kubernetes 가 Go 로 개발된 S/W 이다 보니 Custom controller 도 Go 로 만드는 것이 좋을 것 같다는 생각이다.
그래서 겸사겸사 Custom Controller 개발에 필요한 Go 문법만 정리해 보기로 했다.
변수는 var 키워드로 쉽게 선언할 수 있다.
// var 변수명 변수타입
var message string
변수를 선언하면서 값을 대입하면 마지막의 변수 타입은 생략 가능하다.
var message = "Hi, Welcome!"
:= operator 를 사용하면 shortcut 으로 선언하여 var 도 생략할 수 있다.
message := "Hi, Welcome!"
모듈로 만들어서 import 하여 사용할 수 있다.
아래와 같이 디렉토리를 만들어보자. greetings 는 모듈로 선언하고 basic 에서 greetings 모듈을 import 하여 사용할 예정이다.
$ mkdir -p go-sample
$ cd go-sample
$ mkdir -p {basic,greetings}
$ cd greetings
go-sample 이라는 프로젝트 아래에 greetings 라는 모듈을 만든다.
모듈을 만들기 위해서 go.mod 파일을 만든다.
$ go mod init github.com/seungkyua/go-sample/greetings
go.mod 파일이 생성되며 파일 내용은 아래와 같다.
module github.com/seungkyua/go-sample/greetings
go 1.19
greetings.go 파일을 만들어서 아래와 같이 입력한다.
여기도 error 핸들링과 string format 을 위해서 모듈을 import 하고 있는데 이를 이해할려고 하지 말고 그냥 Hello function 이 있다는 것만 이해하자.
package greetings
import (
"errors"
"fmt"
)
func Hello(name string) (string, error) {
if name == "" {
return "", errors.New("empty name")
}
message := fmt.Sprintf("Hi, %v. Welcome!", name)
return message, nil
}
go-sample 홈디렉토리에서 보는 greetings 디렉토리 구조는 아래와 같다.
greetings
├── go.mod
└── greetings.go
golang 1.19 버전 부터는 로컬 하위 디렉토리를 인식하기 위해서 [go.work](<http://go.work>) 를 사용한다. 그리니 아래와 같이 하여 파일을 만들어 보자.
$ go work use ./basic
$ go work use ./greetins
go-sample 홈디렉토리 아래에 [go.work](<http://go.work>) 파일이 아래와 같이 만들어 진다.
go 1.19
use (
./greetings
./basic
)
이제 basic 디렉토리로 가서 똑같이 go.mod 파일을 만들고 main.go 파일도 만들어 본다.
$ cd basic
$ go mod init github.com/seungkyua/go-sample/basic
go.mod 파일이 아래와 같이 생성되었음을 알 수 있다.
module github.com/seungkyua/go-sample/basic
go 1.19
greetings 모듈을 로컬로 인식하게 변경한다.
$ go mod edit -replace github.com/seungkyua/go-sample/greetings=../greetings
그리고 로컬 버전을 사용하기 위해서 pseudo 버전을 사용하게 tidy 명령을 활용한다.
$ go mod tidy
그럼 최종 go.mod 는 다음과 같다.
module github.com/seungkyua/go-sample/basic
go 1.19
// go mod edit 으로 로컬 경로를 보도록 수정
replace github.com/seungkyua/go-sample/greetings => ../greetings
// 로컬이므로 pseudo 버전을 만든다
require github.com/seungkyua/go-sample/greetings v0.0.0-00010101000000-000000000000
main.go 파일을 만들어서 greetings 모듈을 import 하여 활용해 본다.
package main
import (
"fmt"
"log"
"github.com/seungkyua/go-sample/greetings"
)
func main() {
message, err := greetings.Hello("Seungkyu")
if err != nil {
log.Fatal(err)
}
fmt.Println(message)
}
go-sample 디렉토리 구조는 아래와 같다.
basic
├── go.mod
└── main.go
greetings
├── go.mod
└── greetings.go
go.work
struct 를 json data 로 변환하는 것을 marshal (encoding) 이라고 하고 json data를 struct 로 변환하는 것을 unmarshal (decoding) 이라고 한다.
json 패키지의 Marshal function 은 다음과 같다.
func Marshal(v interface{}) ([]byte, error)
Struct 를 만들어서 json data (byte slice) 로 변환해 보자.
type album struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Price float64 `json:"price"`
}
a := album{ID: "1", Title: "Blue Train", Artist: "John Coltrane", Price: 56.99}
b, err := json.Marshal(a)
if err != nil {
log.Fatal(err)
}
b2 := []byte(`{"id":"1","title":"Blue Train","artist":"John Coltrane","price":56.99}`)
fmt.Println(bytes.Equal(b, b2))
Unmarshal function 은 다음과 같다.
func Unmarshal(data []byte, v interface{}) error
json data (byte slice) 를 struct 로 다시 변환한다.
var a2 album
err = json.Unmarshal(b, &a2)
if err != nil {
log.Fatal(err)
}
fmt.Println(a2)
Receiver function 은 struct 의 멤버 변수를 활용할 수 있는 function 이다.
Vertex struct 를 만들고 그에 속한 멤버 변수를 활용하는 function 을 만들면 된다.
func 와 함수명 사이에 Struct 를 변수와 함께 넣으면 Receiver function 이 된다.
type Vertex struct {
X, Y float64
}
func (v Vertex) Abs() float64 {
return math.Sqrt(v.X*v.X + v.Y*v.Y)
}
func (v *Vertex) Scale(f float64) {
v.X = v.X * f
v.Y = v.Y * f
}
아래와 같이 main 함수를 실행하면 값은 50 이 나온다.
func main() {
v := Vertex{3, 4}
v.Scale(10)
fmt.Println(v.Abs())
}
그런데 만약 위의 func (v *Vertex) Scale(f float64) **에서 Vertex 를 포인터가 아닌 value 로 만들면 어떤 결과가 나올까? 위의 함수에서 * 를 지우고 다시 실행해 보자.
결과는 5 가 된다.
즉, Struct 의 멤버 변수의 값을 바꾸고 싶으면 Pointer Receiver 를 사용해야 한다.
Interface 는 method 를 모아둔 것이라 할 수 있다. interface 역시 type 키워드로 선언하기 때문에 interface 타입이라고도 말한다.
아래와 같이 Abs() 메소드를 선언하고 나서 Abs 를 Receiver function 으로 구현했다면 Abs 를 구현한 타입은 Abser 타입이라고 할 수 있다.
type Abser interface {
Abs() float64
}
아래는 MyFloat 타입도 Abser 타입이라고 할 수 있다. 하지만 Abs 의 Receiver 는 value Receiver 이다.
type MyFloat float64
func (f MyFloat) Abs() float64 {
if f < 0 {
return float64(-f)
}
return float64(f)
}
Vertex 타입도 역시 Abser 타입이며 pointer Receiver 이다.
type Vertex struct {
X, Y float64
}
func (v *Vertex) Abs() float64 {
return math.Sqrt(v.X*v.X + v.Y*v.Y)
}
main 함수에서 interface 를 활용해 본다.
a = v 에서 에러가 발생한다.
func main() {
var a Abser
f := MyFloat(-math.Sqrt2)
v := Vertex{3, 4}
a = f // MyFloat 은 Abser 인터페이스를 구현했으니 가능함
a = &v // *Vertex 는 Abser 인터페이스를 구현했으니 가능함
a = v // v 는 Vertext 타입임 (*Vertex 타입이 아님), value receiver 는 구현안했으니 에러
fmt.Println(a.Abs())
}
interface 는 interface 를 포함할 수 있다.
아래와 같이 Client interface 가 Reader interface 를 가지면 Reader interface 에 선언된 함수가 그 대로 Client 에도 속하게 된다.
package interfaces
import (
"fmt"
)
type Reader interface {
Get(obj string) error
List(list []string) error
}
type Client interface {
Reader
}
var ReconcileClient Client = &client{}
type client struct {
name string
}
func (c *client) Get(obj string) error {
fmt.Println(obj)
return nil
}
func (c *client) List(list []string) error {
fmt.Println(list)
return nil
}
그런 다음 struct 에서 interface 를 또 다시 포함할 수 있다.
아래와 같이 GuestbookReconciler struct 에 Client interface 를 포함하면 GuestbookReconciler 는 마치 Client interface 가 가지는 함수를 자신의 메소드 처럼 사용할 수 있다.
package main
import "github.com/seungkyua/go-sample/interfaces"
type GuestbookReconciler struct {
interfaces.Client
}
func main() {
g := GuestbookReconciler{interfaces.ReconcileClient}
g.Get("Seungkyu")
g.Client.Get("Seungkyu")
}
이 때 메소드 활용은 g.Get 혹은 g.Client.Get 둘 다 가능하다.
지난 설명에 이어서 이번에는 tmux 의 설정에 대해서 자세히 살펴보자.
tmux 는 conf 파일로 단축키와 기능을 설정할 수 있다.
tmux.conf
파일을 홈디렉토리 아래에 다음과 같이 생성한다.
$ vi ~/.tmux.conf
bind r source-file ~/.tmux.conf \; display "Reloaded!"
위의 설정은 현재의 session 에서 설정 파일인 tmux.conf
을 동적으로 바로 리로딩하는 기능을 가진다.
새로운 session 을 만들고 설정 파일을 리로딩해보자.
$ tmux new -s sample
이제 prefix
+ r
을 해보면 설정 파일이 리로딩된 것을 볼 수 있다. (prefix
는 ctrl + b
를 나타내며 Reloaded!
문구가 나타난 후 바로 삭제돼서 화면 캡쳐가 어렵다. ㅠㅠ)
설정 파일에 적용할 수 있는 내용을 더 알아보자.
set -s escape-time 1
prefix
와 command
사이에 인식할 수 있는 간격이 1초라는 의미이다. 리로딩의 경우 prefix
를 누르고 r
을 1초 이내에 눌러야 한다.
set -g base-index 1
setw -g pane-base-index 1
윈도우와 패인의 번호가 0
번이 아닌 1
번 부터 시작한다.
bind -r H resize-pane -L 5
bind -r J resize-pane -D 5
bind -r K resize-pane -U 5
bind -r L resize-pane -R 5
패인의 사이즈를 조절할 수 있다. prefix
+ H
는 왼쪽으로 5 만큼 늘린다는 의미이며, 수평(좌우)으로 2개의 패인으로 나눠진 경우에서 우측 패인에서 사용할 수 있다. 패인이 수직(위아래)으로 나눠진 경우에는 prefix
+ H
가 아니라 prefix
+ J
혹은 K
를 사용할 수 있다.
아래 이미지를 보면 오른쪽 패인이 조금 더 넓은 것을 볼 수 있다.
set -g mouse on
마우스 클릭으로 패인의 포커스를 이동할 수 있다.
setw -g pane-border-style fg=green,bg=black
setw -g pane-active-border-style fg=white,bg=yellow
현재 선택된 패인을 컬러바로 보여준다. 바로 위의 그림에서 노란색바가 아래에 있는데 이는 우측 패인이 선택되었다는 의미이다.
set -g status-left-length 40
set -g status-left "#[fg=green][#S] "
맨 아래 status 라인 왼쪽 사이드에 세션의 이름을 녹색으로 보여준다. 여기서는 [sample]
로 보여진다.
bind _ split-window -v -c "#{pane_current_path}"
bind / split-window -h -c "#{pane_current_path}"
패인을 나누면서 현재 패인의 디렉토리를 그대로 적용해 준다. prefix
+ %
는 수평(좌우)로 나누지만 세션을 만들 때의 디렉토리를 적용해준다. 하지만 prefix
+ /
는 수평(좌우)로 나누면서도 좌측 패인의 디렉토리를 우측패인에 그대로 적용해 준다.
아래 seungkyua 라는 디렉토리 위치가 그대로 적용된 것을 볼 수 있
세션에 있는 윈도우를 또 다른 세션으로 옮길 수 있으며, 서로 다른 윈도우를 하나의 윈도우에 패인으로 나눠서 통합할 수 있으며, 윈도우에 있는 패인을 또 다른 윈도우로 옮길 수 있다.
추가로 panes
세션을 만들고 거기에 first
윈도우를 만든다.
$ tmux new -s panes -n first -d
panes
세션에 second
윈도우를 만든다.
$ tmux new-window -t panes -n second
panes
세션에 들어간다. 윈도우는 second
윈도우에 접속된다.
$ tmux attach -t panes
first
윈도우를 second
윈도우에 패인으로 붙힌다.
prefix + : ---> 명령 모드로 변경됨
join-pane -s panes:1 ---> 명령 모드에서 명령어를 실행
결과로는 first
윈도우가 second
윈도우로 합쳐지면서 second
윈도우는 2개의 패인으로 남는다.
prefix
+ space
는 수직(위아래) 패인 레이아웃을 수평(좌우) 패인 레이아웃으로 바꿀 수 있다. 이 때 토글로 작동한다.
prefix
+ ctrl + s
로 여러 패인에 동시에 명령어를 실행할 수 있다. 이 명령어도 토글로 작동된다.
prefix
+ !
는 현재 패인을 새로운 윈도우로 만들면서 옮길 수 있다. 위에 2:second
윈도우가 있고 우측 패인이 있는데, 우측 패인에서 해당 키를 수행하면 1:zsh
윈도우가 만들어지면서 해당 패인이 윈도우가 된다.
prefix
+ z
는 현재 패인을 최대화해서 보여준다. 보통 패인은 창을 나눠서 쓰기 때문에 화면이 좁을 수 있는데 일시적으로 현재 패인을 전체화면 윈도우로 만들어 준다고 생각하면 쉽다.
토글 기능이라 전체화면 윈도우에서 prefix
+ z
를 수행하면 원래 패인 크기도 돌아온다.
최대화가 되면 화면 아래에 status 에서 패인이름 끝에 Z
표시가 붙어 있어 현재 상태를 알기 쉽게 되어 있다.
이전 글에서는 Kubernetes Cluster 상에서 App 을 Scratch 방식으로 Blue/Green 배포를 하였다. 이번에는 Argo Rollout 을 사용한 Blue/Green 배포하는 방식을 살표보자.
Nginx 혹은 AWS ALB 를 직접 연결하여 사용할 수 있지만, Blue/Green 배포는 Traffic Shifting 이 필요하지 않으므로 AWS LB → Ingress Controller 를 연결한 상태를 만들어 놓고 배포하는 방식을 설명한다.
helm chart 를 이용하여 argo rollout 을 설치한다.
argo rollout dashboard 를 포햄하여 설치하고 싶으면 dashboard.enabled=true
를 추가하면 된다.
$ helm repo add argo https://argoproj.github.io/argo-helm
$ helm repo update
$ helm search repo argo/argo-rollouts -l
NAME CHART VERSION APP VERSION DESCRIPTION
argo/argo-rollouts 2.22.2 v1.4.0 A Helm chart for Argo Rollouts
argo/argo-rollouts 2.22.1 v1.4.0 A Helm chart for Argo Rollouts
$ helm upgrade -i argo-rollout argo/argo-rollouts --version 2.22.2 -n argo --set dashboard.enabled=true --create-namespace
argo rollout dashboard 는 인증 체계가 없다. 그러므로 포트 포워딩으로 dashboard 에 접속 하는 것을 추천한다.
$ kubectl port-forward service/argo-rollouts-dashboard 31000:3100
kubectl 로 cli 호출이 가능하도록 plugin 을 설치한다.
$ curl -LO https://github.com/argoproj/argo-rollouts/releases/download/v1.4.0/kubectl-argo-rollouts-linux-amd64
$ chmod +x kubectl-argo-rollouts-linux-amd64
$ sudo mv kubectl-argo-rollouts-linux-amd64 /usr/local/bin/kubectl-argo-rollouts
$ kubectl argo rollouts version
--- output ---
kubectl-argo-rollouts: v1.4.0+e40c9fe
BuildDate: 2023-01-09T20:20:38Z
GitCommit: e40c9fe8a2f7fee9d8ee1c56b4c6c7b983fce135
GitTreeState: clean
GoVersion: go1.19.4
Compiler: gc
Platform: linux/amd64
argo rollout bash complete 도 설치한다.
$ kubectl argo rollouts completion bash | tee /home/ubuntu/.kube/kubectl-argo-rollouts > /dev/null
$ vi ~/.bash_profile
source '/home/ubuntu/.kube/completion.bash.inc'
source '/home/ubuntu/.kube/kubectl-argo-rollouts'
PATH=/home/ubuntu/bin:$PATH
if [ -f ~/.bashrc ]; then
. ~/.bashrc
fi
초기 app (blue)을 배포한다. 이전 글에서 사용된 seungkyua/nginx:blue
이미지를 배포한다. 단 replicas 를 0
으로 배포한다. 이렇게 배포하면 실제 pod 는 실행되지 않지만 pod template 은 배포된 상태가 된다. pod template 은 나중에 rollout 에서 참조하여 사용한다.
$ cat nginx-blue-deploy.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: nginx-blue-green
name: nginx-blue-green
spec:
replicas: 0
selector:
matchLabels:
app: nginx-blue-green
version: blue-green
template:
metadata:
labels:
app: nginx-blue-green
version: blue-green
spec:
containers:
- image: seungkyua/nginx:blue
name: nginx
$ kubectl apply -f nginx-blue-deploy.yaml
$ kubectl get deploy,pod
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/nginx-blue-green 0/0 0 0 11s
Service 를 배포한다.
$ cat nginx-blue-green-svc.yaml
---
apiVersion: v1
kind: Service
metadata:
labels:
app: nginx-blue-green
name: nginx-blue-green-svc
spec:
ports:
- port: 80
protocol: TCP
targetPort: 80
selector:
app: nginx-blue-green
version: blue-green
type: ClusterIP
$ kubectl apply -f nginx-blue-green-svc.yaml
그리고 blue deployment app 에 웹접속이 가능하게 ingress 를 배포한다. ingress 를 배포하더라도 아직 웹 접속은 불가능하다. 앞에서 deployment 의 replicas 를 0 으로 생성했기 때문에 실행되고 있는 pod 가 없기 때문이다. (나중에 접속을 위해서 /etc/hosts
에 nginx-blue-green.taco-cat.xyz
를 등록해 놓자)
$ cat nginx-blue-green-ingress.yaml
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nginx-blue-green-ingress
spec:
ingressClassName: nginx
rules:
- host: nginx-blue-green.taco-cat.xyz
http:
paths:
- pathType: ImplementationSpecific
backend:
service:
name: nginx-blue-green-svc
port:
number: 80
$ kubectl apply -f nginx-blue-green-ingress.yaml
이제 초기 환경으로 Rollout 을 배포한다. Rollout 을 배포하면 pod 가 생성된다. workloadRef
영역은 Deployment 에서 Pod Template
영역과 일치한다. 그래서 이미 배포된 Deployment 를 참조하게 정의했다.
마지막 라인의 autoPromotionEnabled
는 false
로 하여 수동으로 Blue/Green 을 확인하면서 배포를 할 수 있게 한다.
$ cat nginx-rollout.yaml
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: nginx-rollout
spec:
replicas: 2
revisionHistoryLimit: 5
selector:
matchLabels:
app: nginx-blue-green
workloadRef:
apiVersion: apps/v1
kind: Deployment
name: nginx-blue-green
strategy:
blueGreen:
activeService: nginx-blue-green-svc
autoPromotionEnabled: false
$ kubectl apply -f nginx-rollout.yaml
이제 배포가 완료되면 아래와 같이 리소스가 생성되었음을 확인할 수 있다. rollout pod 와 rollout 에서 사용하는 ReplicaSet 이 생성되어 있음을 알 수 있다.
$ kubectl argo rollouts list rollout
NAME STRATEGY STATUS STEP SET-WEIGHT READY DESIRED UP-TO-DATE AVAILABLE
nginx-rollout BlueGreen Healthy - - 2/2 2 2 2
$ kubectl get pods,deploy,rs
NAME READY STATUS RESTARTS AGE
pod/nginx-rollout-85c4bfb654-jmts7 1/1 Running 0 2m29s
pod/nginx-rollout-85c4bfb654-s46sm 1/1 Running 0 2m29s
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/nginx-blue-green 0/0 0 0 4m50s
NAME DESIRED CURRENT READY AGE
replicaset.apps/nginx-blue-green-8b4f9cddb 0 0 0 4m50s
replicaset.apps/nginx-rollout-85c4bfb654 2 2 2 2m29s
웹으로 접속하면 아래와 같은 화면을 볼 수 있다.
혹은 curl 로도 확인할 수 있다.
$ curl nginx-blue-green.taco-cat.xyz
--- output ---
<!DOCTYPE html>
<html>
<body style="background-color:blue;">
<h1>This is a blue webserver</h1>
</body>
</html>
Rollout dashboard 에는 아래와 같이 나온다.
app 을 수정하여 배포해 보자. app 은 Deployment 를 수정해서 배포하면 된다.
$ cat nginx-green-deploy.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: nginx-blue-green
name: nginx-blue-green
spec:
replicas: 0
selector:
matchLabels:
app: nginx-blue-green
version: blue-green
template:
metadata:
labels:
app: nginx-blue-green
version: blue-green
spec:
containers:
- image: seungkyua/nginx:green
name: nginx
$ kubectl apply -f nginx-green-deploy.yaml
Deployment 가 업그레이드 되어 배포했기 때문에 Rollout 이 이를 인식하고 Green 에 해당하는 추가 Pod 와 ReplicaSet 을 생성한다. 그리고 Rollout 리소스의 상태는 Paused
가 된. (앞에서 Rollout 리소스 배포 시에 autoPromotionEnabled
는 false
로 하였기 때문이다)
$ kubectl argo rollouts list rollout
NAME STRATEGY STATUS STEP SET-WEIGHT READY DESIRED UP-TO-DATE AVAILABLE
nginx-rollout BlueGreen Paused - - 2/4 2 2 2
Pod 와 ReplicaSet 을 조회해 보면 아래와 같다. Blue 해당하는 ReplicaSet 1 개, Pod 2개, Green 에 해당하는 ReplicaSet 1개 Pod 2 개가 떠 있는 것을 알 수 있다.
Rollout Dashboar 는 아래와 같은 Pause 상태이다.
완전히 Green 으로 변경하려면 Rollout 을 promote
하여 최종 적용을 하던지 abort
하여 중단, 혹은 undo
하여 Pause 보다 이전 단계이 최초 Blue app 배포 단계로 돌아가는 방법이 있다.
Green 으로 진행하는 promote 를 해보자.
$ kubectl argo rollouts promote nginx-rollout
--- output ---
rollout 'nginx-rollout' promoted
ReplicaSet 은 남아 있지만 Pod 는 Green 으로 배포된 것만 남아있는 것을 확인할 수 있다.
$ kubectl get pods,rs -l rollouts-pod-template-hash --show-labels
NAME READY STATUS RESTARTS AGE LABELS
pod/nginx-rollout-569b8595bf-8s94v 1/1 Running 0 10m app=nginx-blue-green,rollouts-pod-template-hash=569b8595bf,version=blue-green
pod/nginx-rollout-569b8595bf-c7fgg 1/1 Running 0 10m app=nginx-blue-green,rollouts-pod-template-hash=569b8595bf,version=blue-green
NAME DESIRED CURRENT READY AGE LABELS
replicaset.apps/nginx-rollout-569b8595bf 2 2 2 10m app=nginx-blue-green,rollouts-pod-template-hash=569b8595bf,version=blue-green
replicaset.apps/nginx-rollout-85c4bfb654 0 0 0 19m app=nginx-blue-green,rollouts-pod-template-hash=85c4bfb654,version=blue-green
Rollout Dashboard 에서도 완료된 것을 알 수 있다.
마지막으로 웹 화면으로 확인한다.
Argo Rollout 은 Deployment 변경에서만 인식을 한다. ConfigMap 이나 Secret 과 같은 다른 리소스는 지원하지 않으니 Rollout 에서 이를 지원하는 방법은 추가로 고민해야 한다.