Traefik on k8s で let's encrypt のワイルドカード TLS 証明書を自動発行する

Traefik (proxy)k8s Ingress controller として使うと、ワイルドカード証明書の自動発行が簡単そうなのでやってみた。 Traefik 公式ドキュメント が、わかりにくい、というか、設定のための情報が散逸してたり、そもそもプロダクトとして必ずしも k8s を前提としているわけではないのでじゃあ k8s 向けにはどうすんだ、みたいなのが難しかったので、やったことのメモ書き。

状況設定としては

  • LAN 内に 192.168.0.100 を IP としてもつサーバがあり、そこで k0s をシングルノードで動かしている
  • example.com という domain を保持している
  • LAN 内の別の PC から、サーバに対して https://nantoka.wildcard.example.com/ でアクセスしたい

みたいな感じ。

インストール

公式 Docker image を使って自力で manifest 書いてもなんとか動くとは思うんだけど、 CRD (Custom Resource Definitions) とかあるしけっこう骨だと思われるので、素直に公式の Helm chart を使ってインストールする。

基本的には公式ドキュメントの k8s 向けインストール手順に従えばいい。

公式レポジトリを

$ helm repo add traefik https://helm.traefik.io/traefik

追加し

$ helm inspect values traefik/traefik > values.yaml

のようにして設定ファイルを作成しておく。

設定を指定しつインストール

$ helm install -f values.yaml traefik traefik/traefik

インストール後に設定ファイル values.yaml を更新してそれを反映するときは

$ helm upgrade -f values.yaml traefik traefik/traefik

とする。

service.externalIPs を指定して外部からのアクセスをうけつける

これで Ingress Controller としては立ち上がったので通常の Ingress を作成するとクラスタ外部からアクセスできるようになる、はず、だが、 公式 Helm chart でインストールするとデフォルトでは Traefik の Service が外部に露出していない。

さきほど作成した values.yamlservice.externalIPs に露出する IP を指定する。

service:
  externalIPs:
    - 192.168.0.100

これでクラスタ外から Ingress / Service 経由で Pod 等にアクセスできるようになる。

Traefik の dashboard にアクセスする

https://doc.traefik.io/traefik/getting-started/install-traefik/#exposing-the-traefik-dashboard

いったんはこれの port-forward で dashboard にアクセスする。

Helm chart でインストールすると dashboard 用の Traefik IngressRoute がインストールされてはいるが、 entrypoint が traefik になっており、これは Helm chart でセットアップされる port としては expose: false になっているためそのままでは外部からアクセスできない。

このへんは TLS まわりのセットアップがおわってから整備していくことにする。

DNS の設定

LAN 内の別の PC から、サーバに対して https://nantoka.wildcard.example.com/ でアクセスしたい

なので、 DNS サーバで *.wildcard.example.com の A レコードを (今回の例だと) 192.168.0.100 に設定する。

Persistent Volume の設定

Traefik は Let's Encrypt (および ACME protocol サポートしている TLS 証明書発行者) の証明書の自動発行・自動更新に対応していると冒頭に書いたが、 そのためには Traefik サーバ側で発行された証明書を管理するために永続ストレージが必要となる。

k8s クラスタで PersistentVolumeClaim (PVC) に対応した PersistentVolume があるのであれば、 values.yaml

persistence:
  enabled: true

のように書くと、 PersistentVolume が /data というパス (実際には values.yamlpersistence.path で指定されている) にマウントされる。 ( traefik-helm-chart/values.yaml at 5d97a2e30076302950c31fc9a98f267bdd624fe8 · traefik/traefik-helm-chart · GitHub 参照)

hostPath Volume を利用する場合

自分の環境の場合、シングルノードでもありまだちゃんとした PersistentVolume はセットアップしていなかったので、いったん hostPath な Volume を利用することにした。

この場合、以下のような values.yaml を書くことになる。

deployment:
  additionalVolumes:
    - name: acmeStore
      hostPath:
        path: /volumes/acmeStore
        type: Directory

additionalVolumeMounts:
  - name: acmeStore
    mountPath: /acmeStore

deployment.additionalVolumes[].hostPath.path は適宜 k8s ノード側のパスを指定すること (この例だと /volumes/acmeStore を用意した)。 公式の Helm chart だと /data ディレクトリをマウント先としているため、そことかぶらないようにした。

(ちなみにこのへんの設定は、結局 traefik-helm-chart/_podtemplate.tpl at master · traefik/traefik-helm-chart · GitHub などのファイルを読み解いた。このへんも公式 Helm chart のわかりづらいところだと思う)

また、 公式 Helm chart だと、プロセスが uid=65532, gid=65532 で動くので、そのアカウントにとって writable なディレクトリにしておく必要がある。

$ sudo chown 65532:65532 /volumes/acmeStore

証明書ストアの場所を Traefik に設定する

証明書ストアの場所だが https://doc.traefik.io/traefik/https/acme/#storage に書いてあるように、 Traefik の設定としては、たとえば以下のように書く必要がある。

certificatesResolvers:
  myresolver:
    acme:
      storage: /acmeStore/acme.json

が、これはあくまで Traefik の static configuration に書いておく必要があるのであって、これをそのまま values.yaml に書くのではない (わかりづらい)。

残念ながら現在の公式 Helm chart では certficicatesResolvers の便利な書き方がサポートされているわけではないので、以下のように書く必要がある。

additionalArguments:
  - "--certificatesResolvers.le.acme.storage=/acmeStore/acme.json"

certificatesResolver の名前としては、公式ドキュメントに倣って le (Let's Encrypt の略かな?) としたが、なんでも構わない。

ACME Challenge の設定

Let's Encrypt でワイルドカード証明書を取得するためには、 DNS-01 Challenge を利用する必要がある。

https://doc.traefik.io/traefik/https/acme/#dnschallenge

かんたんにいうと、 対象となるドメインの TXT レコードを設定し Let's Encrypt 側にそれを確認してもらう Challenge である。

なので HTTP-01 Challenge や TLS-ALPN-01 Challenge と異なり、今回のように対象ドメインの解決先が private IP でも利用できる (副産物であるが)。

Challenge の設定をするためには、 Traefik の設定として下記のように設定する。

certificatesResolvers:
  myresolver:
    acme:
      storage: /acmeStore/acme.json  # 設定済
      email: [email protected]
      dnsChallenge:
        provider: ***provider***

さきほど説明したように、これはあくまで Traefik の設定なので、 Helm chart でインストールしている場合は以下のように additionalArguments で設定する必要がある。

additionalArguments:
  - "--certificatesResolvers.le.acme.storage=/acmeStore/acme.json"  # 設定済
  - "[email protected]"
  - "--certificatesResolvers.le.acme.dnsChallenge:provider=***provider***"

さて、上記の例で ***provider*** と書いてあるところは、 DNS プロバイダを指定する。 どのような DNS プロバイダがサポートされているかは、以下に記載されている (ライブラリとして LEGO を利用しているようだ)。

https://doc.traefik.io/traefik/https/acme/#providers

今回自分は LuaDNS を利用したが、もちろん Route 53 や Google Cloud DNS (Google Domains の DNS ではないことに注意 *1 ) もサポートされている、だけではなくさくらのクラウドIIJ もサポートされているようだ。 また、 仮にサポートされていないとしても外部プログラムを用いて TXT レコードを設定することができれば利用できそうである。

LuaDNS の場合、

additionalArguments:
  - "--certificatesResolvers.le.acme.dnsChallenge:provider=luadns"

のように書く。

また、 LuaDNS 用の設定として環境変数を指定する必要がある。 環境変数values.yml に以下のように記述する。

env:
  - name: LUADNS_API_USERNAME
    value: "***username***"   # 実質 LuaDNS でのアカウントの e-mail アドレス
  - name: LUADNS_API_TOKEN
    value: "***API token***"

アプリケーションの公開

ということで、ようやくアプリケーションを LAN 内に公開できるようになった。

なんとなく定番っぽい whoami イメージを立ち上げることにする。

Deployment と Service については、特筆するべきことはないと思う。

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: whoami-deployment
spec:
  selector:
    matchLabels:
      app: whoami
  replicas: 1
  template:
    metadata:
      labels:
        app: whoami
    spec:
      containers:
      - name: whoami
        image: jwilder/whoami
        ports:
        - containerPort: 8000

Service

apiVersion: v1
kind: Service
metadata:
  name: whoami-service
  labels:
    app: whoami
spec:
  selector:
    app: whoami
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8000

Ingress

TLS 証明書自動発行のためには、 通常 Ingress として利用される networking.k8s.io/v1Ingress ではなく Traefik の CustomResource である traefik.containo.us/v1alpha1 の IngressRoute を利用する必要がある。

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: whoami-ingress
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`whoami.wildcard.example.com`)
      kind: Rule
      services:
        - name: whoami-service
          port: 80
  tls:
    certResolver: le
    domains:
      - main: "wildcard.example.com"
        sans:
          - "*.wildcard.example.com"

spec.tls.certResolver のところで、上記で設定した certificatesResolvers である le を指定している。

また、 domains のところで指定したとおり、

  • wildcard.example.com をメインの domain としつつ
  • SANs (サブジェクト代替名) としてワイルドカード*.wildcard.example.com を含む

TLS 証明書が Let's encrypt から発行される。

ここは main として *.wildcard.example.com を指定しても (ドキュメントによれば) うまくいくと思うが、 ドキュメントのサンプル設定にしたがってこのようにした。

この IngressRoute resource により、クライアントから https://whoami.wildcard.example.com/ にアクセスすると

  • まだ TLS 証明書を発行していなければ発行する
  • すでに発行されているが古ければ再発行する
  • さもなければすでに発行されている証明書を利用する

といった動作となる。

Traefik dashboard を LAN からアクセスできるようにする

これで任意の *.wildcard.example.com に対して TLS アクセスができるようになった。

さきほどまで port-forward で利用していた Traefik dashboard も IngressRoute で公開してみる。

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: dashboard-traefik
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`traefik.wildcard.example.com`) && (PathPrefix(`/dashboard`) || PathPrefix(`/api`))
      kind: Rule
      services:
        - name: api@internal
          kind: TraefikService
  tls:
    certResolver: le
    domains:
      - main: "wildcard.example.com"
        sans:
          - "*.wildcard.example.com"

このリソースを作成することで、 https://traefik.wildcard.example.com/dashboard/ にアクセスすると dashboard にアクセスできるようになる。

すこしだけ IngressRoute の TLS 設定を簡略化する

毎回毎回証明書の main と SANs を指定していくのはダルいし、もしかすると別のリソースで間違った SANs を指定してしまうかもしれない。

entryPoints の static configuration として TLS 設定をしておくと、各 IngressRoute のほうの設定が少し楽になる。

values.ymlports: に entryPoint の設定があるので、

ports:
  websecure:
    port: 8443
    expose: true
    exposedPort: 443
    protocol: TCP
    tls:
      enabled: false
      options: ""
      certResolver: ""
      domains: []

となっているところを

ports:
  websecure:
    tls:
      enabled: false
      options: ""
      certResolver: le
      domains:
        - main: "wildcard.example.com"
          sans:
            - "*.wildcard.example.com"

のようにする。

ports.websecure.tls.enabled は false のままでよいと思う。 もしかすると k8s クラスタで利用する TLS 証明書が単一の場合はここを true にしておくことで、 IngressRoute 側で一切指定をしなくても TLS 証明書つきアクセスを提供できるのかもしれない。 https://doc.traefik.io/traefik/user-guides/crd-acme/#traefik-routers を参照したところ spec.tls.certResolver の設定だけやってますね。 時間ができたらやってみる。

これで IngressRoute のほうは

spec:
  tls:
    certResolver: le
    domains:
      - main: "wildcard.example.com"

のように指定するだけで、ワイルドカード証明書を利用できるようになった。

まあ一行減っただけだし、そもそも main としてワイルドカードドメインを指定していれば減るわけでもないので、ふつうはここまでやる必要はないのかもしれない。

*1:Google Domains の DNS は TXT レコードの API 更新が存在しないため現在はサポートされていない