👛

kubernetes でケチケチ節約して複数のサイトを管理する

に公開

はじめに

個人で管理するウェブサーバでは、しばしば複数のウェブサイトを管理する際にコストも掛かり気味ですが、kubernetes でもツールを適切に選べば複数サイトをケチケチしながら、うまく管理できるという話をしたいと思います。

複数のサイトに必要な物

複数のサイトを運営するとなれば、こういったものが必要かと思います。

  • ロードバランサと複数のドメイン
  • HTML で作られた複数の静的サイト
  • 複数サイトの証明書

ロードバランサと複数のドメイン

個人で管理するレベルの規模だと、複数のロードバランサをその数分借りるほどコストを掛けたくありません。できれば1つのグローバルアドレスを使い、バーチャルホストとして稼働させたいです。
ドメイン毎に Ingress を構築しても良いのですが、できれば管理コストを減らしたいです。また Ingress をサイト毎に分けると証明書もそれごとに発行しないといけなくなります。kubernetes で証明書を自動発行した事がある方であれば分かると思いますが、案外手間が係るのです。

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: multi-server-ingress
  namespace: default
  annotations:
    cert-manager.io/cluster-issuer: "http01-cluster-issuer"
    external-dns.alpha.kubernetes.io/hostname: "example1.jp,example2.jp,example3.jp,cname.example.jp"
    external-dns.alpha.kubernetes.io/cloudflare-record-comment: "Managed by external-dns"
    external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
spec:
  ingressClassName: traefik
  rules:
  - host: example1.jp
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: multi-server
            port:
              number: 80
  - host: example2.jp
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: multi-server
            port:
              number: 80
  - host: example3.jp
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: multi-server
            port:
              number: 80
  - host: example4.jp
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: multi-server
            port:
              number: 80
  tls:
  - hosts:
    - example1.jp
    - example2.jp
    - example3.jp
    - example4.jp
    secretName: multi-server-all-tls

例えば cloudflare を使い Ingress/Service に紐づいたグローバル IP アドレスをドメインの A レコードに紐づけるのであれば、external-dns を使うと便利です。external-dns を使うと有名どころの DNS プロバイダの DNS レコードを制御できます。

CNAME レコードで管理するドメイン

これは、ちょっとしたテクニックなのですが、cname.example.jp の様に、CNAME を向けるためのドメインを用意しておくと便利です。

例えば is-a.dev というドメインでは申請するとサブドメイン(例 mattn.is-a.dev) を貰う事ができます。貰ったドメインをこの cname.example.jp に向ける事であとは自分の管理下になるのです。
複数あるドメインを全て cname.example.jp に向けてあっても、Ingress が裁いてくれるので、難しく考える必要もありません。

is-a-dev では、A レコードによる申請と、CNAME による申請ができます。例えば IP アドレスで申請してしまうと、今後ロードバランサを作り直してグローバル IP アドレスが変わる度に、A レコードの更新をしなければなりません。クラウドサービスによってはグローバル IP アドレスを固定する事もできます。
is-a-dev では DNS レコードを申請する際に GitHub の pull-req を作る必要があり、またレユーして貰ってマージされるまでにも時間が掛かるため、若干ながら手間なのです。

A レコードで管理するドメイン

前述の様に kubernetes でリソースを管理しておけば、ロードバランサが作り直され、新しいグローバル IP アドレスが割り振られた際には、external-dns により自動で A レコードが更新されるという訳です。
cloudflare や Azure や Google Cloud、Route 53 等、有名な DNS プロバイダで管理しているドメインであればグローバル IP アドレスの張り替えが自動になります。

external-dns は各 Service にアノテーションを追記するだけで使えるのでとても便利です。

DDNS で管理するドメイン

いっぽうで DDNS の様に、一定期間でドメインのグローバル IP アドレスが変わってしまう様なケースだと定期的に A レコードを更新させなければなりません。

スクリプトから Ingress/Service に割り当てられたグローバル IP アドレスを得て、DDNS サービスに更新する CronJob を実行すると良いでしょう。
CronJob からサービスの情報を得なければならないので、サービスアカウントを作り、ロールをバインドします。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: ddns-updater
  namespace: traefik
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ddns-service-reader
  namespace: traefik
rules:
- apiGroups: [""]
  resources: ["services"]
  verbs: ["get"]
  resourceNames: ["traefik"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ddns-service-reader-binding
  namespace: traefik
subjects:
- kind: ServiceAccount
  name: ddns-updater
  namespace: traefik
roleRef:
  kind: Role
  name: ddns-service-reader
  apiGroup: rbac.authorization.k8s.io

僕は ingress-nginx-controller ではなく traefik を使っていますが、ingress-nginx-controller を使いの方は適宜修正ください。実際の CronJob は以下の様になります。

apiVersion: batch/v1
kind: CronJob
metadata:
  name: ddns-updater
  namespace: traefik
  labels:
    app: ddns-updater
spec:
  timeZone: 'Asia/Tokyo'
  schedule: '0 0 * * 0'
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 1
  suspend: false
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 300
  jobTemplate:
    metadata:
      labels:
        app: ddns-updater
    spec:
      parallelism: 1
      completions: 1
      backoffLimit: 1
      activeDeadlineSeconds: 1800
      ttlSecondsAfterFinished: 300
      template:
        metadata:
          labels:
            cronjob: ddns-updater
        spec:
          serviceAccountName: ddns-updater
          containers:
          - name: ddns-updater
            image: docker.io/alpine/k8s:1.31.13
            command:
              - /bin/sh
              - -c
              - |
                LB_IP=$(kubectl get svc -n traefik traefik -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
                echo $LB_IP

                curl -s "https://example.com/update?domains=myddns.example.net&token=xxxx&ip=$LB_IP"

          restartPolicy: Never

HTML で作られた複数の静的サイト

さて前述の Ingress では全て multi-server という Service に向けられているのが分かります。ここでもサービスは1個にケチる事ができます。

https://github.com/mattn/multi-server

このアプリケーションを使うと、ドメイン毎に分けられたディレクトリを使って複数のサイトの静的コンテンツをサーブする事ができます。

+- exmaple1.jp
|   +- index.html
|
+- example2.jp
|   +- index.html
|
+- example3.jp
    +- index.html

ディレクトリは、サーバに対してリクエストされた際に付けられた Host ヘッダにより振り分けられます。
コンテナが起動する前にデータ領域に管理された静的コンテンツを git clone しておくと便利です。サイトを更新したあと、この multi-server を rollout restart すれば新しいコンテンツに更新されます。(実際は mv/rm などを丁寧に処理してサイトが消える時間を減らすべきです)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: multi-server
  namespace: default
  labels:
    app: multi-server
spec:
  replicas: 1
  selector:
    matchLabels:
      app: multi-server
  template:
    metadata:
      labels:
        app: multi-server
    spec:
      initContainers:
      - name: init-websites
        image: ghcr.io/mattn/alpine-git-curl
        command:
          - /bin/sh
          - -c
          - |
            rm -rf /data/example1.jp
            git clone https://my-git-repo/example1.jp /data/example1.jp

            rm -rf /data/example2.jp
            git clone https://my-git-repo/example2.jp /data/example2.jp

            rm -rf /data/example3.jp
            git clone https://my-git-repo/example3.jp /data/example3.jp

            rm -rf /data/error-pages
            git clone https://my-git-repo/error-pages /data/error-pages

        volumeMounts:
        - name: ingress-static-files
          mountPath: /data

      containers:
      - name: multi-server
        image: ghcr.io/mattn/multi-server
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 8080
        volumeMounts:
        - name: ingress-static-files
          mountPath: /data
      volumes:
      - name: ingress-static-files
        persistentVolumeClaim:
          claimName: ingress-static-files-pvc
---
apiVersion: v1
kind: Service
metadata:
  name: multi-server
  namespace: default
  labels:
    app: multi-server
spec:
  type: ClusterIP
  ports:
  - port: 80
    name: http
    targetPort: 8080
  selector:
    app: multi-server

この例では、単純な git clone を行っていますが、ブログサイトにするのであれば、hugo 等を使って、この PersistentVolume に出力する処理が必要になります。

複数サイトの証明書

証明書はクラウドサービスの証明書を使っても良いですが、そちらにも期限があり、更新の際に都度 kubernetes の中に取り込むのが面倒です。個人サイトなので cert-manager を使い、Let's Encrypt で自動更新してしまうのがお安いです。

前述の様に、証明書の自動生成管理は割と大変です。サブジェクトの代替を使い、1つの証明書を使うのも良いでしょう。

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: multi-server-all-tls
  namespace: default
spec:
  secretName: multi-server-all-tls
  issuerRef:
    name: http01-cluster-issuer
    kind: ClusterIssuer
  dnsNames:
  - example1.jp
  - example2.jp
  - example3.jp
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: http01-cluster-issuer
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: me@my-domain.jp
    privateKeySecretRef:
      name: http01-cluster-issuer-tls-account-key
    solvers:
      - http01:
          ingress:
            class: traefik

この方法では traefik が http01 をうまく処理してくれますが、dns01 を使う場合は別途 DNS プロバイダの設定が必要です。

おまけ: エラーページ

Traefik の Middleware を使うと、エラーページを構築する事ができます。これまたお手製の静的サイトサーバを使い、共通のコンテンツのみサーブする様にします。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: static
  namespace: default
  labels:
    app: static
spec:
  replicas: 1
  selector:
    matchLabels:
      app: static
  template:
    metadata:
      labels:
        app: static
    spec:
      containers:
      - name: static
        image: ghcr.io/mattn/serve
        imagePullPolicy: IfNotPresent
        args:
          - /go/bin/serve
          - -r
          - /data/error-pages
        ports:
        - containerPort: 5000
        volumeMounts:
        - name: ingress-static-files
          mountPath: /data
      volumes:
      - name: ingress-static-files
        persistentVolumeClaim:
          claimName: ingress-static-files-pvc

Traefik Middleware は以下の通り。

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: traefik-error-page
spec:
  errors:
    status:
      - "404"
      - "500"
      - "501"
      - "503"
      - "505-599"
    statusRewrites:
      "418": 404
      "502-504": 500
    query: /{status}.html
    service:
      name: serve
      port: 80,

これで、どのサイトでも共通の 404 ページを作ることができます。

おわりに

これらを使うと、複数のドメインの複数の静的サイトを複数の証明書(実際は1つ)を使って一元管理する事ができます。
今回、Traefik、multi-server、serve と、3つもウェブサーバを起動している様に見えますが、いずれも Go で書かれており、Traefik は alpine、multi-server と serve は scratch をベースイメージにしている為、コンテナイメージのサイズは Traefik で 49.5 MB、multi-server で 2.95 MB、serve で 2.28 MB しかありません。とてもケチケチしてて良いですね。

いかがでしたか。ドケチに節約しまくる事で追加コストをまったく掛けずに複数サイトを管理できる様になります。ぜひお試し下さい。

Discussion