GKE で TLS 証明書を自動管理(cert-manager DNS-01 編)

この記事は [Kubernetes Advent Calendar 2017](https://qiita.com/advent-calendar/2017/kubernetes) の9日目です。

## 追記

Google Kubernetes Engine の中の人の @ahmetb が GKE と cert-manager HTTP-01 を組み合わせるチュートリアルを書いているので、英語で良いならそちらも推奨します。
https://github.com/ahmetb/gke-letsencrypt

## まえがき

昨年は [kube-lego](https://qiita.com/apstndb/items/2fef0a80d4510516cb1f)や [kube-cert-manager](https://qiita.com/apstndb/items/198ad2bca40595d112bf) を使って Let's Encrypt で証明書を自動取得する記事を書きました。また当時はまだ証明書を Kubernetes で自動管理する手法は出てきたばかりでしたが、この1年間カジュアルに運用してみたという話はかなり多かったように思います。

実際に運用してみると、 `kube-lego` に苦しまされた方が多かったようです。理由としては、 Ingress を動的に更新することで ACME の HTTP-01 チャレンジに対応しており、更新時の挙動が安定しなかった [GCE Ingress Controller](https://github.com/kubernetes/ingress-gce)(GKE でデフォルトで動作) で不可解な挙動になることが多かったことがあげられます。

また `kube-lego` は環境変数で設定するため、 Let's Encrypt のアカウントの設定やステージング・本番環境などを切り替えることが比較的困難でした。色々試しているうちに[レートリミット](https://letsencrypt.org/docs/rate-limits/)に引っかかってしまうという話もありました。

更に [kube-lego](https://github.com/jetstack/kube-lego) の説明には "Non-production use case 😆" という表記が消える気配がありません。

いつになったらプロダクションで使えるようになるのかと思って確認してみると、開発している JetStack は今は `cert-manager` を開発していて `kube-lego` はプロダクションユースに耐える状態になる可能性は低いことがわかります。

https://github.com/jetstack/kube-lego/issues/156#issuecomment-338867450
https://github.com/jetstack/kube-lego/issues/26#issuecomment-339993658

ということで、今後決定版になることが期待できる `cert-manager` を使って DNS-01 チャレンジで証明書を管理するにはどういう手順になるかを確認したのがこの記事です。

GKE 以外でも使えますが、私が GKE を使いたいので GKE で説明します。

## 環境情報

Google Cloud SDK: 182.0.0(インストール済)
Kubernetes: 1.8.4(kubectl インストール済)
Helm: 2.7.2
cert-manager: 0.2.2


## 準備

### GKE クラスタの作成

Kubernetes クラスタを作りましょう。この記事では GKE を使って進めていきます。
クラスタ情報は何度か使うのでシェル変数にしておきます。


```
$ GCLOUD_PROJECT=apstndb-sandbox
$ GKE_CLUSTER=cert-manager-example
$ GKE_ZONE=us-central1-a
$ gcloud config set project ${GCLOUD_PROJECT}
$ gcloud container clusters create --zone=${GKE_ZONE} --machine-type=g1-small --disk-size=30 --num-nodes=1 ${GKE_CLUSTER} --cluster-version=1.8.4-gke.0
$ gcloud container clusters create --zone=${GKE_ZONE} --machine-type=g1-small --disk-size=30 --num-nodes=1 ${GKE_CLUSTER} --cluster-version=1.8.4-gke.0
Creating cluster cert-manager-example...done.
Created [https://container.googleapis.com/v1/projects/apstndb-sandbox/zones/us-central1-a/clusters/cert-manager-example].
kubeconfig entry generated for cert-manager-example.
NAME                  LOCATION       MASTER_VERSION  MASTER_IP     MACHINE_TYPE  NODE_VERSION  NUM_NODES  STATUS
cert-manager-example  us-central1-a  1.8.4-gke.0     35.194.38.97  g1-small      1.8.4-gke.0   1          RUNNING
```

kubectl コマンドを使えるようにしましょう。

```
$ gcloud container clusters get-credentials ${GKE_CLUSTER} --zone=${GKE_ZONE}
Fetching cluster endpoint and auth data.
kubeconfig entry generated for cert-manager-example.

$ kubectl get nodes
NAME                                                  STATUS    ROLES     AGE       VERSION
gke-cert-manager-example-default-pool-45780ba0-vtmt   Ready     <none>    10m       v1.8.4-gke.0
```

### Helm のインストール



[cert-manager の標準のデプロイ方法](https://github.com/jetstack/cert-manager/blob/master/docs/user-guides/helm.md)は Kubernetes パッケージマネージャの [Helm](https://github.com/kubernetes/helm) を使うことになっています。
[Installing Helm](https://docs.helm.sh/using_helm/#installing-helm) を参考に Helm クライアントをインストールしましょう。macOS の場合は次のようになります。


```
$ brew install kubernetes-helm
Updating Homebrew...
==> Downloading https://homebrew.bintray.com/bottles/kubernetes-helm-2.7.2.sierra.bottle.tar.gz
######################################################################## 100.0%
==> Pouring kubernetes-helm-2.7.2.sierra.bottle.tar.gz
==> Caveats
Bash completion has been installed to:
  /usr/local/etc/bash_completion.d
  ==> Summary
  🍺  /usr/local/Cellar/kubernetes-helm/2.7.2: 50 files, 93.6MB
```


Kubernetes 1.8 で RBAC はステーブルになり、 GKE ではデフォルトで RBAC を使う設定になっています。Helm を RBAC 環境で使えるようにするには Kubernetes 内でも準備が必要です。ここでは単純化のために `cluster-admin` クラスタロールにバインドされたサービスアカウントとして `tiller` を作ります。
より細かく設定したい場合は Helm のドキュメントの [Role-based Access Control](https://docs.helm.sh/using_helm/#role-based-access-control) を参照してください。

```
$ kubectl create serviceaccount tiller --namespace kube-system
serviceaccount "tiller" created
$ kubectl create clusterrolebinding tiller --clusterrole=cluster-admin --serviceaccount=kube-system:tiller
clusterrolebinding "tiller" created
```

上で作ったサービスアカウント `tiller` を使う形で Helm のクラスタ内サーバの Tiller をインストールします。

```
$ helm init --upgrade --service-account tiller
Creating /Users/apstndb/.helm
Creating /Users/apstndb/.helm/repository
Creating /Users/apstndb/.helm/repository/cache
Creating /Users/apstndb/.helm/repository/local
Creating /Users/apstndb/.helm/plugins
Creating /Users/apstndb/.helm/starters
Creating /Users/apstndb/.helm/cache/archive
Creating /Users/apstndb/.helm/repository/repositories.yaml
Adding stable repo with URL: https://kubernetes-charts.storage.googleapis.com
Adding local repo with URL: http://127.0.0.1:8879/charts
$HELM_HOME has been configured at /Users/apstndb/.helm.

Tiller (the Helm server-side component) has been installed into your Kubernetes Cluster.
Happy Helming!
```

### cert-manager のインストール

Helm を使って cert-manager をインストールします。今回はリリース済みの 0.2.2 の配布物の中の chart からインストールします。

```
$ curl -sL https://github.com/jetstack/cert-manager/archive/v0.2.2.tar.gz | tar xv
$ helm install --name cert-manager --namespace kube-system cert-manager-0.2.2/contrib/charts/cert-manager
NAME:   cert-manager
LAST DEPLOYED: Sat Dec  9 19:09:38 2017
NAMESPACE: kube-system
STATUS: DEPLOYED

RESOURCES:
==> v1/ServiceAccount
NAME                       SECRETS  AGE
cert-manager-cert-manager  1        1s

==> v1beta1/CustomResourceDefinition
NAME                               AGE
certificates.certmanager.k8s.io    1s
clusterissuers.certmanager.k8s.io  1s
issuers.certmanager.k8s.io         1s

==> v1beta1/ClusterRole
cert-manager-cert-manager  1s

==> v1beta1/ClusterRoleBinding
NAME                       AGE
cert-manager-cert-manager  1s

==> v1beta1/Deployment
NAME                       DESIRED  CURRENT  UP-TO-DATE  AVAILABLE  AGE
cert-manager-cert-manager  1        1        1           0          1s

==> v1/Pod(related)
NAME                                        READY  STATUS             RESTARTS  AGE
cert-manager-cert-manager-575544b5c4-kdx4j  0/2    ContainerCreating  0         1s
```

これで cert-manager のインストールができました。

## cert-manager を使う

### Issuer と Certificate について

cert-manager は CustomResourceDefinition(以下 CRD) を使って証明書を生成する CA の設定を [Issuer](https://github.com/jetstack/cert-manager/tree/master/docs/api-types/issuer), 実際の証明書を [Certificate](https://github.com/jetstack/cert-manager/tree/master/docs/api-types/certificate) としてそれぞれ管理します。
(この記事では使いませんが Namespace に属さない Issuer である ClusterIssuer というものもあります。ユースケースによっては Issuer の代わりに使うことになるでしょう。)

Issuer が Certificate とは独立した CRD になっていることで、デプロイされた cert-manager や個々の証明書とは直交して、証明書申請者のメールアドレスや Let's Encrypt のステージング、本番のエンドポイントの区別などを管理できます。これが kube-lego や kube-cert-manager に対して差別化している点です。

Issuer では [`ACME`](https://github.com/jetstack/cert-manager/blob/master/docs/api-types/issuer/spec.md#acme-configuration タイプ)の設定では [ACME プロトコル](https://github.com/ietf-wg-acme/acme/)に対応した CA から証明書を取得することができます。ACME は Let's Encrypt が実装しているプロトコルであり、他に有力な実装がない現時点ではほぼ Let's Encrypt サポートと同義です。


現時点で Issuer にはオレオレ認証局として動作する [CA](https://github.com/jetstack/cert-manager/blob/master/docs/user-guides/ca-based-issuer.md) もありますが、この記事では特に説明しません。


cert-manager は ACME の HTTP-01 と DNS-01 の2種類のチャレンジに対応しています。

- [HTTP-01](https://github.com/jetstack/cert-manager/blob/master/docs/user-guides/acme-http-validation.md) チャレンジは kube-lego のように Ingress を使って動的に ACME プロバイダがアクセスするエンドポイントに返答します。
- [DNS-01](https://github.com/jetstack/cert-manager/blob/master/docs/user-guides/acme-dns-validation.md) チャレンジでは DNS の TXT レコードを動的に生成します。 現在 Cloud DNS, Cloudflare, Route53 に対応しています。

この記事では DNS-01 を使うため DNS の TXT レコードを操作できる必要があります。
今回は DNS には Cloud DNS を使うため、 cert-manager が Cloud DNS を使えるように GCP のサービスアカウント設定します。(GCE デフォルトサービスアカウントで Cloud DNS スコープを有効にしても構いません)

```
$ gcloud iam service-accounts create cert-manager --display-name "cert-manager"
Created service account [cert-manager].

$ gcloud projects add-iam-policy-binding ${GCLOUD_PROJECT} --member serviceAccount:cert-manager@${GCLOUD_PROJECT}.iam.gserviceaccount.com --role roles/dns.admin
(省略)
- members:
  - serviceAccount:cert-manager@apstndb-sandbox.iam.gserviceaccount.com
  role: roles/dns.admin
(省略)

$ gcloud iam service-accounts keys create cert-manager-key.json --iam-account cert-manager@${GCLOUD_PROJECT}.iam.gserviceaccount.com
```

生成した `cert-manager` サービスアカウントの JSON キーファイルを `clouddns-service-account` という名前の Secret として登録します。

```
$ kubectl create secret generic clouddns-service-account --from-file=cert-manager-key.json=cert-manager-key.json
secret "clouddns-service-account" created
```

上のサービスアカウントを使って Let's Encrypt の本番環境で証明書を取得する Issuer を生成します。

```
$ kubectl apply -f - << EOF
apiVersion: certmanager.k8s.io/v1alpha1
kind: Issuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    # The ACME server URL
    server: https://acme-v01.api.letsencrypt.org/directory
    # Email address used for ACME registration
    email: admin@apstndb.net
    # Name of a secret used to store the ACME account private key
    privateKeySecretRef:
      name: letsencrypt-prod
    # Enable the HTTP-01 challenge provider
    http01: {}
    # ACME dns-01 provider configurations
    dns01:
      # Here we define a list of DNS-01 providers that can solve DNS challenges
      providers:
      - name: prod-dns
        clouddns:
          # A secretKeyRef to a the google cloud json service account
          serviceAccountSecretRef:
            name: clouddns-service-account
            key: cert-manager-key.json
          # The project in which to update the DNS zone
          project: apstndb-sandbox
EOF
issuer "letsencrypt-prod" created
```

上の Issuer を使って `cert-manager-tls` という名前の Secret として `sandbox.apstndb.net` の証明書を管理するための CRD である Certificate を作ります。

```
$ kubectl apply -f - <<EOF
apiVersion: certmanager.k8s.io/v1alpha1
kind: Certificate
metadata:
  name: sandbox-apstndb-net
  namespace: default
spec:
  secretName: cert-manager-tls
  issuerRef:
    name: letsencrypt-prod
  commonName: sandbox.apstndb.net
  dnsNames:
  - www.sandbox.apstndb.net
  acme:
    config:
    - dns01:
        provider: prod-dns
      domains:
      - sandbox.apstndb.net
      - www.sandbox.apstndb.net
EOF
certificate "sandbox-apstndb-net" created
```

作った Certificate を describe すると証明書の取得状況が確認できます。

```
% kubectl describe certificate
Name:         sandbox-apstndb-net
Namespace:    default
Labels:       <none>
Annotations:  kubectl.kubernetes.io/last-applied-configuration={"apiVersion":"certmanager.k8s.io/v1alpha1","kind":"Certificate","metadata":{"annotations":{},"name":"sandbox-apstndb-net","namespace":"default"},"spec...
API Version:  certmanager.k8s.io/v1alpha1
Kind:         Certificate
Metadata:
  Cluster Name:
  Creation Timestamp:             2017-12-09T12:34:09Z
  Deletion Grace Period Seconds:  <nil>
  Deletion Timestamp:             <nil>
  Resource Version:               21646
  Self Link:                      /apis/certmanager.k8s.io/v1alpha1/namespaces/default/certificates/sandbox-apstndb-net
  UID:                            40d55c08-dcdd-11e7-a59b-42010a8000fc
Spec:
  Acme:
    Config:
      Dns 01:
        Provider:  prod-dns
      Domains:
        sandbox.apstndb.net
        www.sandbox.apstndb.net
  Common Name:  sandbox.apstndb.net
  Dns Names:
    www.sandbox.apstndb.net
  Issuer Ref:
    Name:       letsencrypt-prod
  Secret Name:  cert-manager-tls
Events:
  Type     Reason                 Age   From                     Message
  ----     ------                 ----  ----                     -------
  Warning  ErrorCheckCertificate  41s   cert-manager-controller  Error checking existing TLS certificate: secret "cert-manager-tls" not found
  Normal   PrepareCertificate     41s   cert-manager-controller  Preparing certificate with issuer
  Normal   PresentChallenge       41s   cert-manager-controller  Presenting dns-01 challenge for domain www.sandbox.apstndb.net
  Normal   PresentChallenge       41s   cert-manager-controller  Presenting dns-01 challenge for domain sandbox.apstndb.net
  Normal   SelfCheck              39s   cert-manager-controller  Performing self-check for domain www.sandbox.apstndb.net
  Normal   SelfCheck              39s   cert-manager-controller  Performing self-check for domain sandbox.apstndb.net
```

取得できた証明書の中身を確認してみましょう。(`kubectl` の jsonpath がいまいち使いやすくないので `jq` を使っています。)

```
$ kubectl get secret cert-manager-tls -o json | jq -r '.data["tls.crt"]' | base64 -D | openssl x509 -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            03:9f:5a:76:64:a3:f5:a7:79:25:8c:c7:20:71:91:f1:14:27
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C=US, O=Let's Encrypt, CN=Let's Encrypt Authority X3
        Validity
            Not Before: Dec  9 11:35:48 2017 GMT
            Not After : Mar  9 11:35:48 2018 GMT
        Subject: CN=sandbox.apstndb.net
```

### 証明書の使用

実際に証明書を Ingress で使用して HTTPS でアクセスしてみましょう。
アクセス対象の Pod 群としての Deployment と NodePort Service を作成します。

```
$ kubectl run nginx --image nginx --port 80
kubectl get deploymentdeployment "nginx" created
$ kubectl expose deployment nginx --type NodePort
service "nginx" exposed
```

A レコードを設定する対象の静的 IP アドレスを用意してます。今回は GCE Ingress を使うのでグローバルである必要があります。

```
$ gcloud compute addresses create nginx-ip --global
Created [https://www.googleapis.com/compute/v1/projects/apstndb-sandbox/global/addresses/nginx-ip].
```

上で作った静的 IP アドレスに対して DNS で A レコードを設定しておきます。既に Cloud DNS に `sandbox` というゾーンが登録されていて、そのゾーンの中にある `sandbox.apstndb.net` に設定する場合はこんな感じです。

```
$ CLOUDDNS_ZONE=sandbox
$ STATIC_IP_ADDRESS=$(gcloud compute addresses describe nginx-ip --global --format='get(address)')
$ gcloud dns record-sets transaction start --zone=${CLOUDDNS_ZONE}
Transaction started [transaction.yaml].
$ gcloud dns record-sets transaction add --zone=${CLOUDDNS_ZONE} --name sandbox.apstndb.net. --ttl 500 --type A "${STATIC_IP_ADDRESS}"
Record addition appended to transaction at [transaction.yaml].
$ gcloud dns record-sets transaction execute --zone=${CLOUDDNS_ZONE}
Executed transaction [transaction.yaml] for managed-zone [sandbox].
Created [https://www.googleapis.com/dns/v1/projects/apstndb-sandbox/managedZones/sandbox/changes/9].
ID  START_TIME                STATUS
9   2017-12-09T12:50:32.611Z  pending
```

これまでに作成した静的 IP アドレスと証明書を使用する Ingress を作ります。

```
kubectl apply -f - << EOF
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: nginx
  annotations:
    kubernetes.io/ingress.global-static-ip-name: "nginx-ip"
    kubernetes.io/ingress.class: "gce"
spec:
  tls:
  - secretName: cert-manager-tls
    hosts:
      - sandbox.apstndb.net
  backend:
    serviceName: nginx
    servicePort: 80
EOF

$ ingress "nginx" created
```

Ingress に設定した IP アドレスが割り当てられるのを待ちます。

```
% kubectl get ingress --watch
NAME      HOSTS     ADDRESS          PORTS     AGE
nginx     *         35.227.233.236   80, 443   1m
```

`openssl` コマンドで読んでみると、正しく証明書が外から取得できたことがわかります。

```
$ openssl s_client -connect sandbox.apstndb.net:443
CONNECTED(00000003)
depth=1 /C=US/O=Let's Encrypt/CN=Let's Encrypt Authority X3
verify error:num=20:unable to get local issuer certificate
verify return:0
---
Certificate chain
 0 s:/CN=sandbox.apstndb.net
   i:/C=US/O=Let's Encrypt/CN=Let's Encrypt Authority X3
 1 s:/C=US/O=Let's Encrypt/CN=Let's Encrypt Authority X3
   i:/O=Digital Signature Trust Co./CN=DST Root CA X3
---
Server certificate
-----BEGIN CERTIFICATE-----
(省略)
```

ブラウザからも問題なく HTTPS でアクセスできるようになったことが確認できます。GCE HTTPS Load Balancing はデフォルトで HTTP/2 を提供するので HTTP/2 indicatator も青くなっています。

![スクリーンショット 2017-12-09 22.03.16.png](https://qiita-image-store.s3.amazonaws.com/0/1936/cbfc31b6-99f2-7f7e-254f-9c441289327a.png "スクリーンショット 2017-12-09 22.03.16.png")


## 後片付け

課金されるものを削除しておきましょう。GCE Ingress を使った場合は GKE クラスタを削除する前に Ingress を削除して GCE 上も削除されていることを確認しましょう。GCE 静的 IP アドレスも放っておくと課金されるので削除されたことを確認しましょう。

```
$ kubectl delete ingress nginx
$ gcloud container clusters delete cert-manager-example --zone=GKE_ZONE
$ gcloud compute addresses delete nginx-ip --global
```

## まとめ

cert-manager で TLS 証明書を取得するところまでを確認しました。
cert-manager は今後本番利用に耐える実装を目指しており、今後も機能が追加されていくことが期待できます。
この記事では実際に証明書を使うための DNS の A レコードの更新は自前で行ったため Cloud DNS 依存の記述が多くなっていましたが、今後は DNS の A レコードも含めて自律的に管理できるようなものが登場することに期待したいですね。

## 私信

特に Kubernetes が活発な会社で働いているわけではないですが CKA を取りました。来年もヲチしていきます。

![Kobito.kva7wi.png](https://qiita-image-store.s3.amazonaws.com/0/1936/a4aa4223-b0c8-481a-fe5a-089bdbc9b840.png "Kobito.kva7wi.png")