Ghost Season 5 - Helm

Page content

ghost를 설치한 지 몇 년이 지났는데 그 동안 여러 가지 방법으로 Ghost 운용 환경을 구축해왔다.

Host 환경, Docker, Ansible, kubernetes 에 이어 이번은 5번째 시즌인데 Helm Chart 를 시용해서 설치해 보는 것이다. 처음 시작은 helm repository에 있는 공식(?) 공개된 helm chart를 이용하여 values.yaml 파일만 내 환경에 맞게 변경해서 사용하려던 것이었는데 아쉽게 아직은 그렇게 하기 힘든 것으로 보여 직접 helm chart를 만들어서 사용하고 있다. 이 문서는 그 과정을 기술한 것으로 향후 공식 helm chart를 활용할 수 있는 때가 오면 시즌 6에 해당하는 글을 또 쓸 듯 하다.

PV

PV(Persistent Volume을 만드는 건 Helm의 역할이 아닌 듯. 대부분의 Helm Chart는 필요한 PVC(Persistent Volume Claim)은 정의하고 있어도 PV 생성에 대한 책임은 지고 있지 않은 듯 하다.

ghost-pv.yaml

kind: PersistentVolume
apiVersion: v1
metadata:
  name: ghost-pv
  labels:
    type: local
spec:
  storageClassName: manual
  capacity:
    storage: 12Gi
  accessModes:
    - ReadWriteOnce
  hostPath:
    path: "/home/cychong/Dropbox/Apps/ghost/content"

Kubectl 명령을 이용하여 직접 PV 생성한다.

cychong@mini1:~/work$ kubectl create -f ghost-in-k8s/ghost-pv.yaml 
persistentvolume/ghost-pv created
cychong@mini1:~/work$ kubectl get pv
NAME       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS      CLAIM   STORAGECLASS   REASON   AGE
ghost-pv   12Gi       RWO            Retain           Available           manual                  8s

다음은 helm을 이용해서 ghost를 deploy한다.

처음 helm을 이용해서 손쉽게 ghost를 설치할 수 있다는 걸 알고( How to Install Apps on Kubernetes with Helm ) 부푼 기대감을 가지고 설치해 봤는데 아쉽게 github에 있는 건 mariadb를 backend DB로 사용하는 형태였다. Sqlite를 backend로 해서 가볍게 개인 블로그를 운용하고 있는 입장에서는 이걸 위해 mariadb를 사용하기는 배보다 배꼽이 더 큰 상황이 아닌가 싶어 mariadb를 사용하지 않고 ghost를 설치할 수 있는 지 helm chart를 살펴봤다. 그런데 아무리 봐도 mariadb 혹은 별도의 외부 DB가 없으면 제대로 동작하지 않는(ghost를 deploy할 수 없는) 듯 해 보였다. https://github.com/helm/charts/issues/16989 에도 issue를 올렸는데 반응을 보인 한 명이 별 문제 없을 것 같은데 라고 하길래 현상을 자세히 적어줬지만 그 다음부터는 감감무소식이라. 오픈 소스가 원래 그런 거 지만… 다들 문제가 없는 건지 내가 하는 것처럼 ghost + kubernetes + helm + sqlite 의 조합으로 사용을 안하는 건지…

결국 목마른 사람이 우물을 판다고 이전에 직접 만들어서 사용했던 YAML 파일 들을 이용해서 직접 Helm chart를 만들어 보기로 마음 먹었다. 겸사겸사 이러다 보면 Helm chart의 복잡한(?) 문법도 어쩔 수 없이 조금은 이해할 수 밖에 없는 상황이 되지 않을까 하고

접근 방법은 Helm chart의 convention과는 조금 다르지만 근본적으로 결국 deployment, service 등의 조합으로 이루어지는 거라 일단 잘(적어도 내가 원하는 형태대로) 동작하는 YAML 파일들helm create 명령을 통해 만들어진 기본 형태의 파일들과 비교해 가면서 기존 YAML 파일들의 내용을 helm chart에 반영해서 제대로 된 동작하는 helm chart를 만들기로 했다. 제대로의 기준 중 하나는 helm이 추구하는 것과 같이 배포 site와 무관한 내용은 YAML이나 template 파일들에 정의하고 배포 site별로 다를 수 있는 내용은 values.yaml 파일에 정의하는 것이다. 이를 테면 블로그 URL등을 이전에는 deployment.yaml파일에 직접 정의했지만 이번에는 values.yaml에 정의하고 이 값을 참조하도록 했다.

처음 helm을 사용해 보는 거라 쉽게 되지는 않았다. 밤 늦게 퇴근해서 뭔가를 한다는 건 정말 힘든 일이었고, 주말에 어쩌다 시간이 나면 시도해 보는 건데 이마저도 제대로 집중해서 보질 못하니. 그렇게 trial & error를 통해 만든 ghost helm chart를 github repo 에 올렸다. 참고로 이 repo에 있는 내용은 새로 ghost를 만드는 경우에도 잘 동작하는 지는 확인하지 못했다. 기존 sqlite DB파일을 가지고 있는 내 동작 환경에서만 확인해 본 거라 아직은 얼마나 범용성을 가지고 있는 지는 잘 모르겠다. 그렇게 하려면 완전히 새로운 환경에서 해 봐야 하는데 내 코가 석자라 그건 나중에 시간이 나면…

이 helm chart를 이용해 ghost를 설치하는 과정은 다음과 같다.

helm chart를 ghost-with-helm 이라는 디렉토리에 만들었다.

$ tree -l ghost-with-helm
ghost-with-helm
├── Chart.yaml
├── README.md
├── charts
├── templates
│   ├── NOTES.txt
│   ├── _helpers.tpl
│   ├── deployment.yaml
│   ├── ingress.yaml
│   ├── pvc.yaml
│   ├── service.yaml
│   └── tests
│       └── test-connection.yaml
└── values.yaml

참고로 ingress.yaml은 아직 사용하지 않고 직접 NodePort를 이용해서 처리하고 있다. 이 부분은 향후 개선할 점 중 하나.

helm chart의 values.yaml을 맞게 고친 후 설치한다.

cychong@mini1:~/work$ helm install --name my-ghost ghost-with-helm
NAME:   my-ghost
LAST DEPLOYED: Sun Sep 22 21:39:34 2019
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/Deployment
NAME      READY  UP-TO-DATE  AVAILABLE  AGE
my-ghost  0/1    1           0          0s

==> v1/PersistentVolumeClaim
NAME       STATUS  VOLUME    CAPACITY  ACCESS MODES  STORAGECLASS  AGE
ghost-pvc  Bound   ghost-pv  12Gi      RWO           manual        1s

==> v1/Pod(related)
NAME                       READY  STATUS   RESTARTS  AGE
my-ghost-5f6578fd76-djd2g  0/1    Pending  0         0s

==> v1/Service
NAME      TYPE      CLUSTER-IP     EXTERNAL-IP  PORT(S)         AGE
my-ghost  NodePort  10.97.215.158  <none>       2368:30025/TCP  1s


NOTES:
1. Get the application URL by running these commands:
  export NODE_PORT=$(kubectl get --namespace default -o jsonpath="{.spec.ports[0].nodePort}" services my-ghost)
  export NODE_IP=$(kubectl get nodes --namespace default -o jsonpath="{.items[0].status.addresses[0].address}")
  echo http://$NODE_IP:$NODE_PORT

helm install 명령을 내리면 chart로 생성된 application과 관련된 resource 들 pod, service, PVCs 등의 상태가 한번에 모두 확인된다. Pod는 명령을 내리자 마자 상태를 조회한 거라 STATUS가 아직 Pending 상태로 나온다. kubectl 명령으로 pod의 상태를 다시 확인해 본다.

cychong@mini1:~/work$ kubectl get pod my-ghost-5f6578fd76-djd2g
NAME                        READY   STATUS    RESTARTS   AGE
my-ghost-5f6578fd76-djd2g   1/1     Running   0          2m43s

Pod의 STATUS가 정상적으로 RUNNING으로 출력된다. 이제 pod는 정상적으로 deploy가 된 상태인데 Service를 NodePort 타입으로 지정한 경우 Host에서 접속하려면 실제 pod가 listening하는 것과 다른 port를 사용하므로 위 NOTES에 있는 대로 실제로 서비스에 할당된 port 정보를 확인해야 한다.

cychong@mini1:~/work$   export NODE_PORT=$(kubectl get --namespace default -o jsonpath="{.spec.ports[0].nodePort}" services my-ghost)
cychong@mini1:~/work$   export NODE_IP=$(kubectl get nodes --namespace default -o jsonpath="{.items[0].status.addresses[0].address}")
cychong@mini1:~/work$   echo http://$NODE_IP:$NODE_PORT
http://192.168.1.100:30025

이번에 할당된 port는 30025다. 이제 NODE_IP와 NODE_PORT값을 이용해서 해당 주소에 접속하면 정상적으로 ghost 화면이 나온다.

ghost-in-local-ip

이 port 정보를 이용해서 공유기의 port forwarding 규칙에 추가한다. 외부에서 sosa0sa.com:2368로 접슨 시 실제 ghost 가 실행된 mini1의 내부 IP로 포워딩 하되 port 번호를 30025로 변환해서(NAPT) 전달하도록 변경한 후 접속해 본다.

ghost-from-remote

On-premise 환경에서 kubernetes를 사용하는 경우 LoadBalancer를 별도로 설치하지 않고 외부와 통신하기 위해 사용하는 NodePort는 이렇게 service를 deploy할 때마다 port가 바뀌는 문제가 존재한다. 내 경우 공유기의 port forwarding을 이용해서 블로그에 접속할 수 있도록 하고 있어 매번 공유기의 port forwarding 규칙을 고쳐야 하므로 개선이 필요하다(포트 값을 고정값으로 할당하거나, Ingress Controller혹은 MetalLB등을 사용하여 NodePort가 아닌 LoadBalancer 타입으로 변경할 예정이다)

helm-test

cychong@mini1:~/work/ghost-with-helm$ helm test my-ghost
RUNNING: my-ghost-test-connection
PASSED: my-ghost-test-connection

Port Number

NodePort를 사용하면 해당 서비스를 위한 30000에서 32767 사이의 포트 번호가 할당된다. 그런데 위와 같이 port와 TargetPort를 지정하면 위 30000번대 포트 외에 의도한 포트로도 접근이 가능하다.

cychong@mini1:~/work/ghost-with-helm$ echo $(kubectl get --namespace default -o jsonpath="{.spec.ports[0].nodePort}" services my-ghost)
32326

cychong@mini1:~/work/ghost-with-helm$ sudo netstat -atnp |grep kube-proxy
[sudo] password for cychong:
tcp        0      0 127.0.0.1:10249         0.0.0.0:*               LISTEN      18392/kube-proxy
tcp        0      0 192.168.1.100:2368      0.0.0.0:*               LISTEN      18392/kube-proxy
tcp        0      0 192.168.1.100:49778     192.168.1.100:6443      ESTABLISHED 18392/kube-proxy
tcp6       0      0 :::32326                :::*                    LISTEN      18392/kube-proxy
tcp6       0      0 :::10256                :::*                    LISTEN      18392/kube-proxy

위 내용을 보면 my-ghost 서비스에 할당된 NodePort 32326 외에 TargetPort인 2368이 그대로 보이는 것을 알 수 있다.

Upgrade

Helm Chart에 변경이 있는 경우에는 upgrade 명령을 이용한다. 만일 value 파일에 변화가 있는 경우에는 전체를 다시 deploy하는 듯 하다. 아래는 targetPort 값을 임시로 다른 값으로 변경해서 upgrade 했다 다시 원복한 경우다.

cychong@mini1:~/work$ helm upgrade --debug my-ghost ghost-with-helm
[debug] Created tunnel using local port: '44111'

[debug] SERVER: "127.0.0.1:44111"

REVISION: 5
RELEASED: Thu Sep 26 01:06:32 2019
CHART: ghost-0.1.0
USER-SUPPLIED VALUES:
{}

...

Release "my-ghost" has been upgraded.
LAST DEPLOYED: Thu Sep 26 01:06:32 2019
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/Deployment
NAME      READY  UP-TO-DATE  AVAILABLE  AGE
my-ghost  1/1    1           1          18m

==> v1/PersistentVolumeClaim
NAME       STATUS  VOLUME    CAPACITY  ACCESS MODES  STORAGECLASS  AGE
ghost-pvc  Bound   ghost-pv  12Gi      RWO           manual        18m

==> v1/Pod(related)
NAME                       READY  STATUS   RESTARTS  AGE
my-ghost-5f6578fd76-lb7xc  1/1    Running  0         18m

==> v1/Service
NAME      TYPE      CLUSTER-IP     EXTERNAL-IP    PORT(S)         AGE
my-ghost  NodePort  10.105.125.54  192.168.1.100  2368:32326/TCP  18m

Troubleshooting

동일한 helm release 이름을 다시 사용하려면

삭제한 helm release를 재사용하려면 delete 외에 delete —purge 옵션으로 삭제할 것

cychong@mini1:~/work$ helm install --name my-ghost ghost-with-helm
Error: a release named my-ghost already exists.
Run: helm ls --all my-ghost; to check the status of the release
Or run: helm del --purge my-ghost; to delete it
cychong@mini1:~/work$ helm ls
cychong@mini1:~/work$ helm ls -A
Error: unknown shorthand flag: 'A' in -A
cychong@mini1:~/work$ helm ls --all my-ghost
NAME    	REVISION	UPDATED                 	STATUS 	CHART      	APP VERSION	NAMESPACE
my-ghost	1       	Sun Sep 22 21:36:42 2019	DELETED	ghost-0.1.0	1.0        	default  
cychong@mini1:~/work$ helm del --purge my-ghost
release "my-ghost" deleted

helm delete 후 PV 연결이 안되는 경우

한번 Release를 생성한 후 삭제한 경우 PVC가 제대로 할당되지 않는 현상이 있다. 이 경우 PV를 삭제한 후 다시 Helm install을 수행한다.

cychong@mini1:~/work$ kubectl get pv
NAME       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM               STORAGECLASS   REASON   AGE
ghost-pv   12Gi       RWO            Retain           Bound    default/ghost-pvc   manual                  13m

cychong@mini1:~/work$ helm ls
helm delete NAME    	REVISION	UPDATED                 	STATUS  	CHART      	APP VERSION	NAMESPACE
my-ghost	1       	Sun Sep 22 21:39:34 2019	DEPLOYED	ghost-0.1.0	1.0        	default  
cychong@mini1:~/work$ helm delete --purge my-ghost
release "my-ghost" deleted
cychong@mini1:~/work$ helm ls

cychong@mini1:~/work$ kubectl get pv
NAME       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS     CLAIM               STORAGECLASS   REASON   AGE
ghost-pv   12Gi       RWO            Retain           Released   default/ghost-pvc   manual                  14m

이 상태에서 다시 helm install을 수행하면

cychong@mini1:~/work$ helm install --name my-ghost ghost-with-helm
NAME:   my-ghost
LAST DEPLOYED: Sun Sep 22 21:53:40 2019
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/Deployment
NAME      READY  UP-TO-DATE  AVAILABLE  AGE
my-ghost  0/1    1           0          1s

==> v1/PersistentVolumeClaim
NAME       STATUS   VOLUME  CAPACITY  ACCESS MODES  STORAGECLASS  AGE
ghost-pvc  Pending  manual  1s

==> v1/Pod(related)
NAME                       READY  STATUS   RESTARTS  AGE
my-ghost-5f6578fd76-kddlg  0/1    Pending  0         1s

==> v1/Service
NAME      TYPE      CLUSTER-IP     EXTERNAL-IP  PORT(S)         AGE
my-ghost  NodePort  10.96.105.207  <none>       2368:32615/TCP  1s


NOTES:
1. Get the application URL by running these commands:
  export NODE_PORT=$(kubectl get --namespace default -o jsonpath="{.spec.ports[0].nodePort}" services my-ghost)
  export NODE_IP=$(kubectl get nodes --namespace default -o jsonpath="{.items[0].status.addresses[0].address}")
  echo http://$NODE_IP:$NODE_PORT
cychong@mini1:~/work$ kubectl describe pod my-ghost-5f6578fd76-kddlg
Name:           my-ghost-5f6578fd76-kddlg
Namespace:      default
Priority:       0
Node:           <none>
Labels:         app.kubernetes.io/instance=my-ghost
                app.kubernetes.io/name=ghost
                pod-template-hash=5f6578fd76
Annotations:    <none>
Status:         Pending
…
Events:
  Type     Reason            Age                From               Message
  ----     ------            ----               ----               -------
  Warning  FailedScheduling  26s (x2 over 87s)  default-scheduler  pod has unbound immediate PersistentVolumeClaims

이 경우 다시 PV를 삭제하고 새로 PV를 정의한 후 helm install을 실행해야 한다.

cychong@mini1:~/work$ helm ls
helm del NAME    	REVISION	UPDATED                 	STATUS  	CHART      	APP VERSION	NAMESPACE
my-ghost	1       	Sun Sep 22 21:53:40 2019	DEPLOYED	ghost-0.1.0	1.0        	default  
cychong@mini1:~/work$ helm del --purge my-ghost
release "my-ghost" deleted
cychong@mini1:~/work$ kubectl delete -f ghost-in-k8s/ghost-pv.yaml 
persistentvolume "ghost-pv" deleted
cychong@mini1:~/work$ kubectl create -f ghost-in-k8s/ghost-pv.yaml 
persistentvolume/ghost-pv created
cychong@mini1:~/work$ helm install --name my-ghost ghost-with-helm
NAME:   my-ghost
LAST DEPLOYED: Sun Sep 22 21:56:42 2019
NAMESPACE: default
STATUS: DEPLOYED
…

cychong@mini1:~/work$ kubectl get pod
NAME                        READY   STATUS    RESTARTS   AGE
my-ghost-5f6578fd76-k5xcs   1/1     Running   0          50s