前言

前陣子因為太忙,一直沒空處理 Side Project 的內容

前幾天原本在跑的 LINE Bot 伺服器突然就掛掉不動了,才想起來前陣子 Heroku 免費的服務要結束了 (Reference: Heroku’s Next Chapter | Heroku),原本放在上面跑的服務瞬間成了孤兒得找個新家

剛好最近工作上有機會能接觸到 Kubernetes,就想趁這個機會把原本的服務搬到 Kubernetes 上面,而這篇文章也是紀錄一下遷移的過程

需求

首先要先了解一下原本的服務需求,這個小 side project 包含了:

  1. Python 運行環境,用來處理 LINE Bot 和作為 API Server
  2. Redis,其中程式會用到 Redis 來做資料的暫存
  3. Cron Job,用來定時執行程式

Python 的部分當初就是使用 Heroku PaaS 的服務,而 Redis 當初則是使用 Redis Enterprise Cloud – Fully Managed Cloud Service | Redis

另外也有在 cron-job.org 上設定 Cron Job 用來定時執行程式,也寫了一個類似 Health Check 的 API 確保程式不會被 Heroku 免費機制關閉

整體的架構圖大概長這樣:

而研究後發現 DigitalOcean 上也有提供 Kubernetes 服務,剛好手邊也有先 credit usage 可以使用,所以這次的目標希望能把整個服務 (App, Redis, CronJob ) 搬到 Kubernetes 上面

遷移

App - Dockerize

首先是 App 的部分,要搬到 K8S 之前得先把原本的 App 拆成 Docker Image,這邊就不多說了,可以參考 How to “Dockerize” Your Python Applications - Docker

1
2
3
4
5
6
7
8
9
10
11
12
FROM python@sha256:62cb64d073a60a041978385fefea85495fd1c17e4299c36469474f49db3e8297

WORKDIR /app

ADD src /app

RUN pip3 install -r requirements.txt

EXPOSE 8000

# Run app.py when the container launches
CMD ["python", "main.py"]

值得注意的是,由於筆者開發環境是使用 Apple Silicon 晶片,為了避免 X86/64 與 ARM 架構所造成的問題,所以這邊特別去選了 Python 3.10.8-alpine 的 linux-amd64 版本,而 Dockerfile 寫法上記得要改成

1
python@sha256:${SHA256}

最後 docker build 時記得加上 --platform=linux/amd64

App - Kubernetes

打包完 Docker Image 後,就可以開始撰寫 K8S 的部分了。 這部分我們會配合使用 Helm 工具來幫助我們管理 Kubernetes 的資源,會包含的內容包含 DeploymentServiceIngressSecretCronJob,方便起見我們把今天的故事主角名稱就定為 helper 吧

Secret

首先,我們會將 App 內會用到如 token, Redis 密碼等等的機密資訊存放在 secret.yaml

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
data:
LINE_CHANNEL_ACCESS_TOKEN: {{ .Values.LINE_CHANNEL_ACCESS_TOKEN | b64enc }}
LINE_CHANNEL_SECRET: {{ .Values.LINE_CHANNEL_SECRET | b64enc }}
REDIS_URL: {{ .Values.REDIS_URL | b64enc }}
REDIS_PASSWORD: {{ .Values.REDIS_PASSWORD | b64enc }}
TOKEN: {{ .Values.TOKEN | b64enc }}
kind: Secret
metadata:
name: helper-secret
namespace: {{ .Release.Namespace }}
type: Opaque

Ingress

再來就是服務對外連線的部分,我們會使用 NGINX Ingress Controller 作為 Ingress Controller,它會幫我們根據來源 host 的不同而轉發到不同的 service,檔案名稱就叫做 ingress.yaml 吧~

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
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
nginx.ingress.kubernetes.io/backend-protocol: HTTPS
nginx.ingress.kubernetes.io/use-regex: 'true'
name: ingress-nginx
namespace: {{ .Release.namespace }}
spec:
ingressClassName: nginx
rules:
- host: {{ .Values.HELPER_FQDN }}
http:
paths:
- backend:
service:
name: helper-service
port:
number: 443
path: /*
pathType: Prefix
tls:
- hosts:
- {{ .Values.HELPER_FQDN }}
secretName: tls-secret

不過這邊注意到,因為我們需要用到 HTTPS 服務,所以我有特別設定 spec.tls 的內容,所以剛剛 secret.yaml 要再加上一段

Service

接著 Service 會依據不同的 URL 對應到不同的 Pod 上,不過這邊還(ㄌㄢˇ)沒(ㄉㄨㄛˋ)把服務切成 micro service,所以就全部轉發到同一個 Pod 吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: Service
metadata:
name: helper-service
namespace: {{ .Release.Namespace }}
spec:
type: NodePort
selector:
app: pod
ports:
- name: https
port: 443
targetPort: 443
protocol: TCP

Deployment

最後就是故事主角啦,這邊我們把他取名 deployment.yaml

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
apiVersion: apps/v1
kind: Deployment
metadata:
name: helper
namespace: {{ .Release.Namespace }}
spec:
replicas: 1
selector:
matchLabels:
app: helper
strategy:
rollingUpdate:
maxSurge: 1
maxUnavailable: 1
type: RollingUpdate
template:
metadata:
labels:
app: genshin-helper
spec:
containers:
- name: app
image: {{ .Values.ACR_PREFIX }}/{{ .Values.APP_IMAGE }}
imagePullPolicy: IfNotPresent
envFrom:
- secretRef:
name: helper-secret
ports:
- containerPort: 8000
name: http
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: 443
scheme: HTTPS
initialDelaySeconds: 15
periodSeconds: 60
timeoutSeconds: 3
resources:
limits:
cpu: 300m
memory: 256Mi
requests:
memory: 256Mi

CronJob

程式基本現在是會動了,可是我們還有個定時執行的需求,這邊就簡單建立一個 cronjob.yaml 透過 curl 的方式打到 pod API 執行內容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: genshin-signin
spec:
schedule: "0 16 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1
jobTemplate:
spec:
template:
spec:
containers:
- name: signin
image: curlimages/curl
command:
- curl
- https://{{ .Values.HELPER_FQDN }}/api/start?token={{ .Values.TOKEN }}
restartPolicy: Never

Crontab schedule 的部分可以參考 Crontab.guru - The cron schedule expression editor 工具,之所以會設定 16 點的原因是因為我希望在半夜 12:00 整執行,但因爲 GMT + 8 的關係,所以這邊把時間調整為 16 點開始跑

NGINX

不過,現在程式還只能吃 HTTP 的內容而已,這邊使用 NGINX Sidecar 的方式來為程式賦予處理 HTTPS 請求的能力

這邊方便起見,我是使用 Terraform 作為 IaC 工具,以下的內容用 Helm 方式操作其實也是可以的。

首先,我在 values.yaml 內加上 NGINX Sidecar 的內容

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
32
33
# Default values for genshin_helper
sidecarContainers:
- name: tls-sidecar
image: nginx
imagePullPolicy: IfNotPresent
resources:
requests:
memory: 50M
limits:
cpu: 100m
memory: 100M
volumeMounts:
- name: secret-volume
mountPath: /app/cert
- name: config-volume
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
ports:
- name: https
containerPort: 443

sidecarVolumes:
- name: secret-volume
secret:
secretName: tls-secret
items:
- key: tls.crt
path: tls.crt
- key: tls.key
path: tls.key
- name: config-volume
configMap:
name: nginx-config

其中會需要在原本的 secret.yaml 中加上新的

1
2
3
4
5
6
7
8
9
apiVersion: v1
data:
tls.crt: {{ .Values.TLS_SIDECAR_CRT | b64enc }}
tls.key: {{ .Values.TLS_SIDECAR_KEY | b64enc }}
kind: Secret
metadata:
name: tls-secret
namespace: {{ .Release.Namespace }}
type: kubernetes.io/tls

ingress.yaml 中也得特別設定 spec.tls 的內容

1
2
3
4
5
6
...
pathType: Prefix
tls:
- hosts:
- {{ .Values.HELPER_FQDN }}
secretName: tls-secret

至於 NGINX 的設定可以透過 ConfigMap 的方式掛載上去,內容部分我是參考 透過 sidecar 容器啟用 TLS - Azure Container Instances | Microsoft Learn

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
namespace: {{ .Release.Namespace }}
data:
nginx.conf: |
# nginx Configuration File
# https://wiki.nginx.org/Configuration

# Run as a less privileged user for security reasons.
user nginx;

worker_processes auto;

events {
worker_connections 1024;
}

pid /var/run/nginx.pid;

http {
server {
listen [::]:443 ssl;
listen 443 ssl;

server_name localhost;

# Protect against the BEAST attack by not using SSLv3 at all. If you need to support older browsers (IE6) you may need to add
# SSLv3 to the list of protocols below.
ssl_protocols TLSv1.2;

# Ciphers set to best allow protection from Beast, while providing forwarding secrecy, as defined by Mozilla - https://wiki.mozilla.org/Security/Server_Side_TLS#Nginx
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:ECDHE-RSA-RC4-SHA:ECDHE-ECDSA-RC4-SHA:AES128:AES256:RC4-SHA:HIGH:!aNULL:!eNULL:!EXPORT:!DES:!3DES:!MD5:!PSK;
ssl_prefer_server_ciphers on;

# Optimize TLS/SSL by caching session parameters for 10 minutes. This cuts down on the number of expensive TLS/SSL handshakes.
# The handshake is the most CPU-intensive operation, and by default it is re-negotiated on every new/parallel connection.
# By enabling a cache (of type "shared between all Nginx workers"), we tell the client to re-use the already negotiated state.
# Further optimization can be achieved by raising keepalive_timeout, but that shouldn't be done unless you serve primarily HTTPS.
ssl_session_cache shared:SSL:10m; # a 1mb cache can hold about 4000 sessions, so we can hold 40000 sessions
ssl_session_timeout 24h;


# Use a higher keepalive timeout to reduce the need for repeated handshakes
keepalive_timeout 300; # up from 75 secs default

# remember the certificate for a year and automatically connect to HTTPS
add_header Strict-Transport-Security 'max-age=31536000; includeSubDomains';

ssl_certificate /app/cert/tls.crt;
ssl_certificate_key /app/cert/tls.key;

location / {
proxy_pass http://localhost:8000;

proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
}
}
}

最後將原本的 deployment.yaml 也只需要再原先最下面加上 yaml 判斷式就能將 NGINX 很輕鬆的透過 Sidecar 掛載到原先的 pod 上囉

1
2
3
4
5
6
7
8
9
...
memory: 256Mi
{{- if .Values.sidecarContainers }}
{{- toYaml .Values.sidecarContainers | nindent 8 }}
{{- end }}
volumes:
{{- if .Values.sidecarContainers }}
{{- toYaml .Values.sidecarVolumes | nindent 8 }}
{{- end }}

Redis

最後就剩下 Redis 搬到 K8S 上的任務了,這邊我是使用 Terraform by HashiCorp 這套 IaC 工具配合 Helm provider 使用

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
32
33
34
35
36
37
resource "random_password" "redis_password" {
length = 64
special = false
min_lower = 1
min_numeric = 1
min_upper = 1
}

resource "helm_release" "redis" {
depends_on = [
random_password.redis_password,
]

name = "helper-redis"
namespace = var.namespace
chart = "https://charts.bitnami.com/bitnami/redis-17.3.13.tgz"
recreate_pods = true
max_history = 3

set {
name = "master.persistence.size"
value = "1Gi"
type = "string"
}

set {
name = "replica.persistence.size"
value = "1Gi"
type = "string"
}

set_sensitive {
name = "auth.password"
value = random_password.redis_password.result
type = "string"
}
}

至此就完工啦~

後記

話說回來其實也好久沒動筆寫文章了,透過這次 Side Project 搬家也是幫助自己更熟悉並試著將這些知識能傳遞下去

DevOps 和 Backend 其實也就算左右腳吧,期許自己在 Kubernetes 這塊也能學到更多內容再來和大家分享

最後附上 Source Code,有興趣的話可以參考看看 FawenYo/Genshin_Helper (github.com)