ブログ基盤をk8sに移行するにあたって、やったことのメモ


経緯

これまでの公開サーバはRaspberry Pi 3B単体で運用していた(参考記事)が、 以前ラズパイクラスタを作ったことだし、ついでにブログも丸々移行することにした。

構成

以下のような構成にした。

  • L7LB & SSL終端
    • NGINX Ingress Controller
  • 証明書の自動更新
    • cert-manager
  • ベアメタルLB
    • metalLB(L2 mode)
  • 記事を置くためのPV

手順

それでは実際の手順を紹介する。

Ingress経由でサービスを公開する

Nginx Ingress Controllerの導入

NginxをL7ロードバランサとして動かすために、helmでingress-nginxを導入する。

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
kubectl create ns ingress-system
helm install ingress-nginx ingress-nginx/ingress-nginx -n ingress-system
kubectl get svc ingress-nginx-controller # 確認

その後、下記マニフェストをapplyしてIngressの設定を行う。

ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  namespace: nginx
  name: ingress-nginx
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
    nginx.ingress.kubernetes.io/ssl-redirect: "false"
spec:
  ingressClassName: nginx #必須
  rules:

- http:
    paths:
  - path: /
      pathType: Prefix #必須
      backend:
        service:
          name: service-np-blog
          port:
            number: 8080

metalLBを導入している場合、kubectl get svc -n ingress-systemしたときにNginx Ingress ControllerがLoadBalancer typeのサービスとして動いていることが確認できる。

過去のk8sバージョンの情報がネット上に多いが、

apiVersion: extensions/v1beta1

とか

serviceName: service-lb-blog
servicePort: 8080

等は古い記法なので動かない。

一概にNginx Ingress Controllerといっても、コミュニティ版とNingx版がある。今回入れているのはコミュニティ版。

参考: コミュニティ版とNGINX版の違い

一旦hostPathで公開

hostPathを利用してhugoのブログを公開してみる

hostpath.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: nginx
---
apiVersion: v1
kind: Service
metadata:
  name: service-np-blog
  namespace: nginx
spec:
  type: NodePort
  ports:
    - name: http
      protocol: TCP
      port: 8080 # ServiceのIPでlistenするポート
      nodePort: 30080 # nodeのIPでlistenするポート(30000-32767)
      targetPort: 80 # 転送先(コンテナ)でlistenしているPort番号のポート
  externalTrafficPolicy: Local
  selector:
    app: blog # 転送先の Pod のラベル
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: deployment-blog
  namespace: nginx
spec:
  selector:
    matchLabels:
      app: blog
  replicas: 1
  template:
    metadata:
      name: blog
      namespace: nginx
      labels:
        app: blog
    spec:
      containers:
        - name: nginx-container
          image: nginx
          env:
            - name: nginx-container
          ports:
            - containerPort: 80
          volumeMounts:
            - name: document-root
              mountPath: /usr/share/nginx/html
      volumes:
        - name: document-root
          hostPath:
            path: /var/www/html/hugo

ここまでで、metalLB・Ingressの設定が適切に済んでいれば、IngressのExternal-IPにアクセスすることで外部からコンテンツが閲覧できる。

$ kubectl get svc -n ingress-system
NAME                                 TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)                      AGE
ingress-nginx-controller             LoadBalancer   10.109.60.51    192.168.1.208   80:27914/TCP,443:23986/TCP   13d
ingress-nginx-controller-admission   ClusterIP      10.104.185.55   <none>          443/TCP                      13d
# ingress-nginx-controllerのexternal IPはmetalLBのアドレスプールから自動で振られる

NFSサーバをPVとして利用

hostPathだと別ノードで動作しているPodがそれぞれ別のデータを参照するので(今回の例ではそれでも問題ないが)、こちらの記事で設定したNASをNFSサーバとして内部ネットワークに公開し、k8sから利用することにした。

NASでの設定

QNAP公式の手順を参考に実施。Squashオプションはユーザなしのスカッシュを選択する。

ローカルでNFSとしてマウントできるかテストを行う場合は以下のようにすれば可能

mkdir ./nfs
sudo mount -t nfs {NASのIP}:/k8s ./nfs

-tオプションでfstypeを指定する。 NFSv4を利用する場合は、fstypenfs4にする必要がある。

nfs-commonのインストール

PVとしてNFSを利用する際に必要なパッケージnfs-commonをクラスタにインストールする。(入っていなければ)

sudo apt install nfs-common

pv/pvc作成

apiVersion: v1
kind: Namespace
metadata:
  name: nginx
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: blog-pv
spec:
  capacity:
    storage: 10Gi
  persistentVolumeReclaimPolicy: Retain
  accessModes:
    - ReadOnlyMany
  nfs:
    server: {NASのIP}
    path: "/k8s/blog"
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: blog-pvc
  namespace: nginx
  annotations:
    volume.beta.kubernetes.io/persistent-volume: blog-pv
spec:
  accessModes:
    - ReadOnlyMany
  resources:
    requests:
      storage: 10Gi

pathは先程NAS側で行った設定に従ってよしなに。

blogサービス公開

blog.yaml
apiVersion: v1
kind: Service
metadata:
  name: service-np-blog
  namespace: nginx
spec:
  type: NodePort
  ports:
    - name: http
      protocol: TCP
      port: 8080 # ServiceのIPでlistenするポート
      nodePort: 30080 # nodeのIPでlistenするポート(30000-32767)
      targetPort: 80 # 転送先(コンテナ)でlistenしているPort番号のポート
  externalTrafficPolicy: Local
  selector:
    app: blog
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: deployment-blog
  namespace: nginx
spec:
  selector:
    matchLabels:
      app: blog
  replicas: 1
  template:
    metadata:
      name: blog
      namespace: nginx
      labels:
        app: blog
    spec:
      containers:
        - name: nginx-container
          image: nginx
          env:
            - name: nginx-container
          ports:
            - containerPort: 80
          volumeMounts:
            - name: document-root
              mountPath: /usr/share/nginx/html
      volumes:
        - name: document-root
          persistentVolumeClaim:
            claimName: blog-pvc

これで各Podが参照するストレージをNASに統一することができた。

ブログの自動デプロイ設定

今まではremote push時にGitHub Actionsで自宅のラズパイ公開サーバにrsyncしていたが、 NASは外部に公開したくないので、違う手法をとる必要があった。

そのため、masterへのpushにフックしてビルドとNFSサーバへのデプロイをするようにした。

やりかた

hugoのプロジェクトディレクトリの.git/hooks/pre-commitに以下を記載して、実行権限をつける

hugo --minify
PASSWORD="xxx"
ROOT=`git rev-parse --show-toplevel`
mkdir -p ${ROOT}/nfs
echo $PASSWORD | sudo -S mount -t nfs -o rw -O user=${NFS_USERNAME},password=${NFS_PASSWORD} {NASのIP}:/k8s/blog ${ROOT}/nfs
echo $PASSWORD | sudo -S rsync -lOrtcv --delete --progress ${ROOT}/public/* ${ROOT}/nfs
echo $PASSWORD | sudo -S umount ${ROOT}/nfs
rm -rf ${ROOT}/nfs

こうしておくと、remoteにpushしたとき、nfsサーバへのrsyncが走るようになる。

参考: How to backup files from a Linux OS to NAS using Rsync? - QNAP

今回の方法以外にも、iSCSIでPVとして利用する方法がある。

こちらはブロックストレージなので、速度はNFSに比べて速いが、 今回の用途ではNFSのほうが使い勝手が良かったため採用しなかった。

参考リンク

https対応

今まではcertbotを利用してcronで自動化していた証明書更新だったが、k8s移行にあたってその代替手法を検討した。

調べてみると、IngressをSSL終端として、k8sクラスタ上で証明書更新をいい感じにやってくれるcert-managerというツールがあるらしい。ので、これを使ってみることにした。

cert-managerインストール

ingress同様helm経由で。現時点での最新版v1.11.0をインストール。

helm install \
  cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --version v1.11.0 \
  --set installCRDs=true 

Issuerデプロイ

cert-managerで証明書を発行するリソースであるIssuerを作成する。

IssuerにはIssuerClusterIssuerの2種類があり、 Issuerはnamespaceで閉じているが、ClusterIssuerはすべてのnamespaceから利用可能。

チャレンジの種類は一番お手軽なhttp-01で(参考: チャレンジの種類)

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-issuer
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory # Production用URL
    email: 'メアド'
    privateKeySecretRef:
      name: acme-client-letsencrypt
    solvers:
      - http01:
          ingress:
            class: nginx

Ingress更新

変更点は以下

ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  namespace: nginx
  name: ingress-nginx
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
    nginx.ingress.kubernetes.io/ssl-redirect: "false"
    cert-manager.io/cluster-issuer: "letsencrypt-issuer"
spec:
  ingressClassName: nginx #必須
  tls:
    - hosts:
        - 4nm1tsu.com
      secretName: letsencrypt-cert
  rules:
  - host: 4nm1tsu.com
    http:
      paths:
      - path: /
        pathType: Prefix #必須
        backend:
          service:
            name: service-np-blog
            port:
              number: 8080

ハマったポイント

ingressを更新したあと、どうしてもセルフチェックが通らなかった。具体的には以下のエラーが出た。

kubectl get challenges -A -o wide
> NAMESPACE   NAME                                           STATE     DOMAIN        REASON                                                                                                                                                                                                                                                                                                                                      AGE
> nginx       letsencrypt-cert-sp27q-3208533145-1674215623   pending   4nm1tsu.com   Waiting for HTTP-01 challenge propagation: failed to perform self check GET request 'http://4nm1tsu.com/.well-known/acme-challenge/なんか長いhash': Get "http://4nm1tsu.com/.well-known/acme-challenge/なんか長いhash": dial tcp {おうちのグローバルIP}:80: connect: connection refused   10m

原因はドメイン名が内部アドレスに解決できないことだった。俗に言うヘアピンNAT

これは、coreDNSに以下の書き換えルールを適用することで回避した。 (参考リンク)

ただ、あくまでこれは暫定対応に過ぎない(サーバの再起動やk8s自体のアップデートで設定が戻る)ので、恒久対応としてヘアピンNAT対応ルータをポチった。

...
apiVersion: v1
data:
  Corefile: |
      .:53 {
              errors
              health {
                         lameduck 5s
                      }
              rewrite name 4nm1tsu.com ingress-nginx-controller.ingress-system.svc.cluster.local
              ready
              kubernetes cluster.local in-addr.arpa ip6.arpa {
                         pods insecure
                         fallthrough in-addr.arpa ip6.arpa
                         ttl 30
                      }
              prometheus :9153
              forward . /etc/resolv.conf {
                         max_concurrent 1000
                      }
              cache 30
              loop
              reload
              loadbalance
          }
kind: ConfigMap
...

これで先程のエラーは解消されたが、その後も以下のエラーが出てしまった。

Waiting for HTTP-01 challenge propagation: did not get expected response when querying endpoint, expected "なんか長いhash" but got:

この原因はingress.yamlに先程のエラーのトラブルシューティングで記載していた以下の設定が残っていたことだった。

acme.cert-manager.io/http01-edit-in-place: "true"

これに気づくのに相当な時間を要してしまった。

cert-manager関連のトラブルシューティングは、とりあえず以下を試しておけば、どこでコケているか分かる。

kubectl get cert,cr,order -A
kubectl describe challenge -A
kubectl get challenges -A -o wide

公式ページもとてもわかりやすいので、参考にしたい。

おわりに

今回の作業は、GWにk8sのお勉強がてらKubernetes Up & Running 3rd EditionというO’reillyの本を片手に行ったのだが、やはり手を動かしながらだと理解が進むのが早くて良い。

英語の本ではあるが、DeepLとかで翻訳すれば母国語並みのスピード感で読めるので、まったく良い時代になったなと思う。