티스토리 뷰
kafka를 쿠버네티스에 pod 로 올려서 연동테스트
Kubernetes 에서 Kafka 설치는 최근 Confluent , bitnami 에서도 설치를 지원한다.
Strimzi 라는 컨테이너 환경 특화용 Kafka 가 있는 것을 확인
1. Strimzi
Strimzi는 필요한 부분에 적절하게 공략한 듯 하다. 단순화 시켜버린 CRD 몇 종을 제공하면서 ‘Kafka 구성’ Resource 선언으로 Zookeeper와 Kafka Broker가 동작되고, ‘Topic 만들기’ 선언으로 Partition과 Repication 정의가 되며, ‘User 만들기’로 Topic에 접근을 정의할 수 있다. 모든 사항은 Kubernetes의 선언적 사용 방식에 맞게 해당 수준을 정의하면, Kubernetes는 위 사항을 맞추기 위해 노력해 주고 있고, 우리는 제공된 접점만으로 쉽게 사용 가능 하게 되었다.
2. Install
Strimzi 는 작업 당시 0.25 버전까지 나왔지만, 일부 에러가 발생하여 0.17 버전으로 설치하였다
다음은 kubernetes Cluster 정보이다.
Server | Hostname | IP Address |
Master | node1 | 192.168.5.191 |
Worker1 | node2 | 192.168.5.192 |
Worker2 | node3 | 192.168.5.193 |
# kubectl get node -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
node1 Ready control-plane,master 18d v1.20.7 192.168.5.191 <none> Ubuntu 20.04.2 LTS 5.4.0-80-generic docker://19.3.15
node2 Ready control-plane,master 18d v1.20.7 192.168.5.192 <none> Ubuntu 20.04.2 LTS 5.4.0-65-generic docker://19.3.15
node3 Ready control-plane,master 18d v1.20.7 192.168.5.193 <none> Ubuntu 20.04.2 LTS 5.4.0-80-generic docker://19.3.15
node4 Ready <none> 18d v1.20.7 192.168.5.194 <none> Ubuntu 20.04.2 LTS 5.4.0-65-generic docker://19.3.15
Downlod
## V 0.17.0
# wget https://github.com/strimzi/strimzi-kafka-operator/releases/download/0.17.0/strimzi-0.17.0.tar.gz
## V 0.24.0
wget https://github.com/strimzi/strimzi-kafka-operator/releases/download/0.24.0/strimzi-0.24.0.tar.gz
사전작업
우선 Strimzi Kafka Cluser Operator 를 관리할 목적의 namespace 를 생성한다
root@master:~# kubectl create ns kafka
Strimzi 에는 설치파일에 수정이 필요한 요소가 있다. Kubernetes 의 namespace 지정부부은 OS에 따라 변경한다.
On Linux
root@master:~/kafka/strimzi# sed -i 's/namespace: .*/namespace: kafka/' install/cluster-operator/*RoleBinding*.yaml
Kafka Cluster 사용목적의 Kubernetes Namespace 추가
Kafka Cluster 를 구성할 NAmespace 를 생성하고, 아래의 특정 Manifest에 Namespace를 변경한다.
root@master:~/kafka/strimzi# kubectl create ns my-kafka-project
‘install/cluster-operator/050-Deployment-strimzi-cluster-operator.yaml’ 파일 27라인 수준에 아래와 같이 ‘value: my-kafka-project’로 명시하자.
root@master:~/kafka/strimzi# vim install/cluster-operator/050-Deployment-strimzi-cluster-operator.yaml
env:
- name: STRIMZI_NAMESPACE
value: my-kafka-project --> 추가
#valueFrom: --> 주석처리
# fieldRef: --> 주석처리
# fieldPath: metadata.namespace --> 주석처리
CRD 생성
아래 명령으로 CRD를 Kafka Namespace에서 생성하고 RBAC를 활용하여 CRD를 관리 할수 있게 실행한다
root@master:~/kafka/strimzi# kubectl apply -f install/cluster-operator/ -n kafka
RBAC 권한 생성
kafka(관리 Namespace) 와 my-kafka-project(Kafka Cluster Namespace) Rolebinding을 부여하여 Operator가 my-kafka-project Namespace에 Resource를 제어할 수 있게 권한을 부여한다.
## V 0.17.0
root@master:~/kafka/strimzi# kubectl apply -f install/cluster-operator/020-RoleBinding-strimzi-cluster-operator.yaml -n my-kafka-project
root@master:~/kafka/strimzi# kubectl apply -f install/cluster-operator/032-RoleBinding-strimzi-cluster-operator-topic-operator-delegation.yaml -n my-kafka-project
root@master:~/kafka/strimzi# kubectl apply -f install/cluster-operator/031-RoleBinding-strimzi-cluster-operator-entity-operator-delegation.yaml -n my-kafka-project
## V 0.24.0
root@master:~/kafka/strimzi# kubectl create -f install/cluster-operator/020-RoleBinding-strimzi-cluster-operator.yaml -n my-kafka-project
root@master:~/kafka/strimzi# kubectl create -f install/cluster-operator/031-RoleBinding-strimzi-cluster-operator-entity-operator-delegation.yaml -n my-kafka-project
root@node1:~/kafka/strimzi# kubectl get pods -n kafka
NAME READY STATUS RESTARTS AGE
strimzi-cluster-operator-744f76b-j6j88 1/1 Running 2 3h41m
3. Kafka Cluster Configuration
이제부터는 Kafka Cluster 를 구성하는 단계이다. . Kafka CRD를 생성했고, Cluster를 구축할 작업공간인 Kubernetes Namespace에 RBAC 권한 부여까지가 위 과정이다. 남은 과정에서는 Kubernetes 특성에 따른 저장공간(Persistent Volume)을 생성하고, 정의된 CRD 에 맞는 Resource 선언만으로 사용이 완료 된다.
Persistent Volume 생성
Kubernetes가 아닌 VM 기반 또는 BareMetal Server에서 Kafka를 구성한다면, 물리적인 저장공간은 서버 디스크 또는 스토리지등 마운트 포인트를 기준으로 지정위치를 선언하는 것으로 편리하지만, 컨테이너 환경에서는 휘발성이 전제되어 있기에 저장공간을 하나의 Resource로써 관리하여 사용하고 있다.
여기서는 한정된 자원에서 효율적인 운영 방안을 위해서 local host의 디스크를 사용할 것이다.
우선 StorageClass 를 생성한다
root@master:~/kafka/strimzi# vim sc.yaml
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
name: local-storage
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
root@master:~/kafka/strimzi# kubectl apply -f sc.yaml
## You need to configure a default storageClass in your cluster so that the PersistentVolumeClaim can take the storage from there.
root@master:~/kafka/strimzi# kubectl patch storageclass local-storage -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
root@master:~/kafka/strimzi# kubectl get sc
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
local-storage (default) kubernetes.io/no-provisioner Delete WaitForFirstConsumer false 20h
root@master:~/kafka/strimzi# kubectl describe sc local-storage
Name: local-storage
IsDefaultClass: Yes
Annotations: kubectl.kubernetes.io/last-applied-configuration={"apiVersion":"storage.k8s.io/v1","kind":"StorageClass","metadata":{"annotations":{},"name":"local-storage"},"provisioner":"kubernetes.io/no-provisioner","volumeBindingMode":"WaitForFirstConsumer"}
,storageclass.kubernetes.io/is-default-class=true
Provisioner: kubernetes.io/no-provisioner
Parameters: <none>
AllowVolumeExpansion: <unset>
MountOptions: <none>
ReclaimPolicy: Delete
VolumeBindingMode: WaitForFirstConsumer
Events: <none>
다음은 Persistent Volume 을 생성한다.
root@master:~/kafka/strimzi# vim pv.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: kafka-pv-volume
labels:
type: local
spec:
storageClassName: local-storage
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
hostPath:
path: "/mnt/data"
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: kafka-pv-volume-2
labels:
type: local
spec:
storageClassName: local-storage
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
hostPath:
path: "/mnt/data"
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: kafka-pv-volume-3
labels:
type: local
spec:
storageClassName: local-storage
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
hostPath:
path: "/mnt/data"
root@master:~/kafka/strimzi# kubectl apply -f pv.yaml
root@master:~/kafka/strimzi# kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
kafka-pv-volume 10Gi RWO Retain Bound my-kafka-project/data-my-cluster-zookeeper-0 local-storage 3h1m
kafka-pv-volume-2 10Gi RWO Retain Bound my-kafka-project/data-0-my-cluster-kafka-0 local-storage 3h1m
kafka-pv-volume-3 10Gi RWO Retain Bound my-kafka-project/data-my-cluster-zookeeper-1 local-storage 3h1m
Kafka Cluster 생성
Kubernets Resource로 정의된 아래의 ‘Kafka’ Resource 구성요소를 살펴보면, Kafka의 Broker 수를 의미하는 replicas를 정의할 수 있고, zookeeper 역시 수를 정의할 수 있다. 또한 Config 섹션에서 옵션을 정의 할 수 있다.
root@master:~/kafka/strimzi# vim kafka_cluster.yaml
apiVersion: kafka.strimzi.io/v1beta1
kind: Kafka
metadata:
namespace: my-kafka-project
name: my-cluster
spec:
kafka:
replicas: 1
listeners:
plain: {}
tls: {}
external:
type: nodeport
tls: false
storage:
type: jbod
volumes:
- id: 0
type: persistent-claim
class: local-storage
size: 10Gi
deleteClaim: false
template:
pod:
securityContext:
runAsUser: 0
fsGroup: 0
config:
offsets.topic.replication.factor: 1
transaction.state.log.replication.factor: 1
transaction.state.log.min.isr: 1
zookeeper:
replicas: 1
storage:
type: persistent-claim
class: local-storage
size: 10Gi
deleteClaim: false
template:
pod:
securityContext:
runAsUser: 0
fsGroup: 0
entityOperator:
topicOperator: {}
userOperator: {}
root@master:~/kafka/strimzi# kubectl apply -f kafka_cluster.yaml
root@master:~/kafka/strimzi# kubectl get pods -n my-kafka-project
NAME READY STATUS RESTARTS AGE
my-cluster-entity-operator-75d9f795d9-5mnhb 3/3 Running 0 163m
my-cluster-kafka-0 2/2 Running 0 163m
my-cluster-zookeeper-0 2/2 Running 0 133m
strimzi 0.24.0 버전에서는 좀 다름
root@node1:strimzi# vim kafka_cluster.yaml
apiVersion: kafka.strimzi.io/v1beta2 ==> v1beta2
kind: Kafka
metadata:
namespace: my-kafka-project
name: my-cluster
spec:
kafka:
replicas: 1
listeners:
listeners:
- name: plain
port: 9092
type: internal
tls: false
- name: tls
port: 9093
type: internal
tls: true
authentication:
type: tls
- name: external
port: 9094
type: nodeport
tls: false
storage:
type: jbod
volumes:
- id: 0
type: persistent-claim
class: local-storage
size: 10Gi
deleteClaim: false
template:
pod:
securityContext:
runAsUser: 0
fsGroup: 0
config:
offsets.topic.replication.factor: 1
transaction.state.log.replication.factor: 1
transaction.state.log.min.isr: 1
zookeeper:
replicas: 1
storage:
type: persistent-claim
class: local-storage
size: 10Gi
deleteClaim: false
template:
pod:
securityContext:
runAsUser: 0
fsGroup: 0
entityOperator:
topicOperator: {}
userOperator: {}
root@node1:strimzi# kubectl apply -f kafka_cluster.yaml
root@node1:strimzi# kubectl get pods -n my-kafka-project
NAME READY STATUS RESTARTS AGE
my-cluster-entity-operator-6b597b9c5d-bn2xg 3/3 Running 0 8m18s
my-cluster-kafka-0 1/1 Running 0 8m53s
my-cluster-zookeeper-0 1/1 Running 0 9m28s
서비스의 경우 node port 로 연결을 할텐데, 이 경우 port 정보는 random 으로 할당받게 되어있기 때문에
이 부분을 고정으로 적용하고자 한다
apiVersion: kafka.strimzi.io/v1beta2
kind: Kafka
metadata:
namespace: my-kafka-project
name: my-cluster
spec:
kafka:
replicas: 1
listeners:
listeners:
- name: plain
port: 9092
type: internal
tls: false
- name: tls
port: 9093
type: internal
tls: true
authentication:
type: tls
- name: external
port: 9094
type: nodeport
tls: false
configuration: --> 추가
bootstrap: --> 추가
nodePort: 32100 --> 추가
storage:
type: jbod
volumes:
- id: 0
type: persistent-claim
class: local-storage
size: 10Gi
deleteClaim: false
template:
pod:
securityContext:
runAsUser: 0
fsGroup: 0
config:
offsets.topic.replication.factor: 1
transaction.state.log.replication.factor: 1
transaction.state.log.min.isr: 1
zookeeper:
replicas: 3
storage:
type: persistent-claim
class: local-storage
size: 10Gi
deleteClaim: false
template:
pod:
securityContext:
runAsUser: 0
fsGroup: 0
entityOperator:
topicOperator: {}
userOperator: {}
root@node1:strimzi# kubectl apply -f kafka_cluster.yaml
kafka.kafka.strimzi.io/my-cluster changed
root@node1:strimzi# kubectl get svc -n my-kafka-project
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
my-cluster-kafka-0 NodePort 10.233.2.141 <none> 9094:32000/TCP 17h
my-cluster-kafka-bootstrap ClusterIP 10.233.16.129 <none> 9091/TCP,9092/TCP,9093/TCP 17h
my-cluster-kafka-brokers ClusterIP None <none> 9090/TCP,9091/TCP,9092/TCP,9093/TCP 17h
my-cluster-kafka-external-bootstrap NodePort 10.233.58.178 <none> 9094:32100/TCP 17h
my-cluster-zookeeper-client ClusterIP 10.233.25.1 <none> 2181/TCP 17h
my-cluster-zookeeper-nodes ClusterIP None <none> 2181/TCP,2888/TCP,3888/TCP 17h
Kafka Topic 생성
‘KafkaTopic’ Resource 정의에는 Partition의 수와 Replicas의 복제수를 정의 할 수 있다. 참고로 Partition을 늘리는 것은 가능하지만, 줄이는 것은 불가하다고 안내되어 있다.
root@master:~/kafka/strimzi# vim topic.yaml
apiVersion: kafka.strimzi.io/v1beta1
kind: KafkaTopic
metadata:
name: my-topic
namespace: my-kafka-project
labels:
strimzi.io/cluster: "my-cluster"
spec:
partitions: 3
replicas: 1
root@master:~/kafka/strimzi# kubectl apply -f topic.yaml
root@master:~/kafka/strimzi# kubectl get kafkatopic -n my-kafka-project
NAME PARTITIONS REPLICATION FACTOR
consumer-offsets---84e7a678d08f4bd226872e5cdd4eb527fadc1c6a 50 1
my-topic 3 1
생성결과
Kubernetes 명령어로 생성된 Resource를 확인하면, 아래와 같이 확인 할 수 있다. ‘statefulset’ 으로 kafka와 zookeeper가 동작되었고, 외부 접근이 가능한 Kubernetes NodePort 타입으로 Kafka 외부 접근 주소가 제공되며, 내부접근 용도는 별도 분리되어 있다.
추가로 CRD로 생성한 ‘kafkatopic’, ‘kafka’ 정보도 확인하면, 생성한 Partition과 Replication 수와 일치한 정보를 제공한다. (Kafka는 Consumer에 의한 offset 처리도 내부적인 topic으로 관리하기에 kafkatopic CRD상에 보여진다)
root@master:~/kafka/strimzi# kubectl get all -n my-kafka-project
NAME READY STATUS RESTARTS AGE
pod/my-cluster-entity-operator-75d9f795d9-5mnhb 3/3 Running 0 166m
pod/my-cluster-kafka-0 2/2 Running 0 166m
pod/my-cluster-zookeeper-0 2/2 Running 0 135m
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/my-cluster-kafka-0 NodePort 10.233.20.178 <none> 9094:32471/TCP 166m
service/my-cluster-kafka-bootstrap ClusterIP 10.233.25.190 <none> 9091/TCP,9092/TCP,9093/TCP 166m
service/my-cluster-kafka-brokers ClusterIP None <none> 9091/TCP,9092/TCP,9093/TCP 166m
service/my-cluster-kafka-external-bootstrap NodePort 10.233.21.214 <none> 9094:30463/TCP 166m
service/my-cluster-zookeeper-client ClusterIP 10.233.2.96 <none> 2181/TCP 167m
service/my-cluster-zookeeper-nodes ClusterIP None <none> 2181/TCP,2888/TCP,3888/TCP 167m
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/my-cluster-entity-operator 1/1 1 1 166m
NAME DESIRED CURRENT READY AGE
replicaset.apps/my-cluster-entity-operator-75d9f795d9 1 1 1 166m
NAME READY AGE
statefulset.apps/my-cluster-kafka 1/1 166m
statefulset.apps/my-cluster-zookeeper 1/1 167m
root@master:~/kafka/strimzi# kubectl get pv,pvc,sc -n my-kafka-project
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
persistentvolume/kafka-pv-volume 10Gi RWO Retain Bound my-kafka-project/data-my-cluster-zookeeper-0 local-storage 3h7m
persistentvolume/kafka-pv-volume-2 10Gi RWO Retain Bound my-kafka-project/data-0-my-cluster-kafka-0 local-storage 3h7m
persistentvolume/kafka-pv-volume-3 10Gi RWO Retain Bound my-kafka-project/data-my-cluster-zookeeper-1 local-storage 3h7m
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
persistentvolumeclaim/data-0-my-cluster-kafka-0 Bound kafka-pv-volume-2 10Gi RWO local-storage 167m
persistentvolumeclaim/data-my-cluster-zookeeper-0 Bound kafka-pv-volume 10Gi RWO local-storage 4h5m
persistentvolumeclaim/data-my-cluster-zookeeper-1 Bound kafka-pv-volume-3 10Gi RWO local-storage 142m
persistentvolumeclaim/data-my-cluster-zookeeper-2 Pending local-storage 142m
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
storageclass.storage.k8s.io/local-storage (default) kubernetes.io/no-provisioner Delete WaitForFirstConsumer false 20h
4. Testing
모든 Kafka Cluster 구성은 완료 되었고, 실제 데이터 전달과정을 테스트 하기 위해서는 Apache Kafka를 다운로드 하여, Producer, Consumer 쉘을 사용하여 터미널에서 확인할 수 있다.
Download Apache Kafka - kubernetes cluster node 가 아닌 다른 서버에서 접근 테스트 해본다.
http://kafka.apache.org/downloads 주소에서 Binary 버전의 Kafka를 다운받을 수 있다. 다운받은 후 압축을 풀면 된다.
kafka_2.13-2.8.0.tgz 다운 받음
Producer 실행
Apache Kafka 위치에서 아래와 같은 명령을 실행한다. ‘broker-list’는 생성결과에서 NodePort의 정보가 되어 Kubernetes Node IP와 Port 정보를 기술한다. ‘topic’ 정보는 생성한 Topic 명칭이다. Producer는 데이터 전달의 역할이기 때문에 실행하면, 터미널 창은 입력 대기 상태가 되어 key를 입력받을 수 있고, key를 입력하면 이제 Consumer 실행 이후에 데이터가 전달 된다.
여기서 192.168.20.155 는 kafka 를 설치한 서버의 IP 이고. 30463 포트는 아래의 정보이다.
root@master:~/kafka/strimzi# kubectl get svc -n my-kafka-project
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
my-cluster-kafka-external-bootstrap NodePort 10.233.21.214 <none> 9094:30463/TCP 171m
[root@localhost kafka_2.13-2.8.0]# bin/kafka-console-producer.sh --broker-list 192.168.20.155:30463 --topic my-topic
>hello world!
>wow!!
>haha!!!!
>dada
>dffdsfd
Consumer 실행
새로운 터미널 창에서 Apache Kafka 위치에서 아래와 같이 명령을 실행한다. ‘bootstrap-server’ 항목은 broker 정보를 기술하고, topic정보도 동일하다. 실행하면, 메세지가 출력된다.
[root@localhost kafka_2.13-2.8.0]# bin/kafka-console-consumer.sh --bootstrap-server 192.168.20.155:30463 --topic my-topic --from-beginning
hello world!
wow!!
haha!!!!
dada
dffdsfd
기타) 설치하면 발생했던 이슈
1. Kafka Cluster 생성후 pod error 가 발생하였고, log 확인 결과 아래와 같은 내용이 있었다
mkdir: cannot create directory '/var/lib/zookeeper/data': Permission denied
아래 내용을 보면 이 부분을 추가하면 서 문제를 해결했다
template:
pod:
securityContext:
runAsUser: 0
fsGroup: 0
참고)
Strimzi Quick Start Guide (0.17.0)
Release 0.17.0 · strimzi/strimzi-kafka-operator · GitHub
Kafka 실전 코드 (with Strimzi) – T3 Guild
Strimzi Kafka on Kubernetes local bare metal - Stack Overflow
PV 관련
How to Set Up and Run Kafka on Kubernetes - Platform9
pv, pvc 강제 삭제시 delete 를 했을때 , 무응답일 경우
kubectl delete pv/pv-name or pvc/pvc-name으로 안될때
--grace-period=0 이 옵션을 해보고 안되면 --grace-period=0 --force
이것도 안될경우
edit후
- kubernetes.io/pv-protection
해당 부분이 존재하는지 확인해본다
만약 있다면
kubectl patch pvc pvc_name -p '{"metadata":{"finalizers":null}}'
kubectl patch pv pv_name -p '{"metadata":{"finalizers":null}}'
kubectl patch pod pod_name -p '{"metadata":{"finalizers":null}}'
Kafka local persistent volume with Kubernetes (codegravity.com)
nodeport 지정
Accessing Kafka: Part 2 - Node ports (strimzi.io)
Strimzi Quick Start guide (0.24.0)
'기록남기기' 카테고리의 다른 글
YAML 문법 검사 사이트 (0) | 2021.11.09 |
---|---|
Oracle 19.3 on Rocky linux (0) | 2021.09.04 |
mariadb Galera Cluster on kubernetes (0) | 2021.08.18 |
k8s log monitoring Loki (0) | 2021.08.10 |
[k8s] helm 설치 (0) | 2020.12.11 |
- Total
- Today
- Yesterday
- 우루과이
- 마라탕#하안동
- 먼 훗날 우리
- 검단
- 인터파크 티켓팅
- confluent #kafka # control center
- 하안동
- 평생학습원
- 영화
- 축구평가전
- ㅅ음
- ISA #연금저축펀드 #IRP
- 광명동굴
- 신천역
- 이자카야
- k8s #kubernetes
- MySQL
- 빗썸
- 인시그니아
- 비트코인
- 성수
- centos7 #docker
- ㅐ
- 구글홈
- 오징어청춘
- 오후전략 완료~ 신일전자 2100원/에스트래픽 4180원/분할매수/가치를 믿자!
- 스시
- 성수동
- redis
- ㅗ험
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |