Run Let's Encrypt client with Nomad job

Run Let's Encrypt client with Nomad job

SSL 証明書の取得の自動化を可能とするプロトコルとして ACME (Automated Certificate Management Environment) がありこれの実装として Let’s Encrypt というサービスがあります。

Let’s Encrypt のクライアントとしては certbotlego があります。どちらも以下のように Docker イメージが公開されており、つまり Nomad のジョブとして簡単に動かすことが可能です。

certbot と lego の大きな違いは lego は Go で書かれておりバイナリ一つという手軽さがあるという点です。Docker を使うなら関係ないですが…。

今回 Nomad のバッチジョブとして lego を動かしてみたのでメモします。

モチベーション

現在サーバー 3 台からなる Nomad クラスターを運用しています。サーバーは DigitalOcean で立てておりクラスターの前段にはマネージドロードバランサーを置いています。ロードバランサーには SSL termination を設定しておりつまりロードバランサーに証明書の管理を任せています。設定している証明書のドメインは blog.lorentzca.me です。

ここに問題があり DigitalOcean のロードバランサーはまだ発展途上で、1 つのロードバランサーに対し 1 つの証明書 (= 1 つのドメイン) しか設定できません。つまり、blog.lorentzca.me 以外のサービスを動かしたい場合ドメイン毎に 1 つロードバランサーを追加していかなければいけないというかなりコスパが悪い子なのです…。

ので、ロードバランサーにはロードバランシングだけ頑張ってもらうことにして、バックエンドのサーバー側で証明書の管理を行い HTTPS 通信を受けることにしようと考えています。ここで悩むのが Nomad 上でどのように証明書を取得、管理するかで、マイクロサービスわからんという問題があります。

そこでまずは考える取っ掛かりとして Nomad のジョブとして証明書を取得するところまでやってみよう 💪 というのが本記事の目的です。

まずは lego の使い方の確認

lego を使った新規証明書の取得方法を確認します。

lego のドキュメントは以下。今回 Docker コンテナとして動かそうとしているので CLI のドキュメントを参照すれば良さそうです。

チャレンジの方法 (あなたがそのドメインの所有者であることを証明する方法) はいくつかありますが、今回は DNS-01 チャレンジを使用します。もっともよく使われるチャレンジは HTTP-01 チャレンジで (って書いてあった)、それぞれの仕組みは以下です。

  • DNS-01 チャレンジ: ドメイン名の TXT レコードを設定し、これを通じて確認を行う。
  • HTTP-01 チャレンジ: ドメイン名で HTTP アクセスできる特定の場所にファイルを置き、これを通じて確認を行う。

DNS-01 チャレンジは 80 番ポートでアクセス可能なウェブサイトが無くても行えるというメリットがあります。また、ワイルドカード証明書も発行可能です。HTTP-01 チャレンジは DNS レコードをいじれる環境じゃない場合や DNS レコードをいじるのは敷居が高い、という場合に便利なチャレンジです。ただしワイルドカード証明書は発行できません。

チャレンジに関する説明は以下のページにとてもわかりやすく書いてあります。ありがてえ。

さて、DNS-01 チャレンジでは DNS レコードの追加が必要となりますが、これも lego が自動で行ってくれます。対応する DNS サーバーは主要なものは全て抑えているのではないでしょうか。対象の DNS サーバーのアクセスキーを環境変数で渡してあげれば自動でレコードを追加してくれます。

チャレンジの他に重要な要素として Let’s Encrypt のエンドポイント (ACME サーバー) があります。エンドポイントには本番用と検証用があり、検証として何度も試す場合には検証用のエンドポイントを使用する必要があります。本番用のエンドポイントで何度も証明書の発行をリクエストすると、BAN されてしまいます!!

とはいえ一応検証用のエンドポイントにも制限はあります。が、かなり大きいので普通は意識しなくても問題ないと思います。

ちなみに本番用のエンドポイントの制限は以下。

あとはドメインの指定やメールアドレスの指定を行なっていくだけです。例として lorentzca.me の証明書を取得する docker run コマンドは以下になります。

sudo docker run -it --rm -e "DO_AUTH_TOKEN=$DO_AUTH_TOKEN" \
xenolf/lego:v2.2.0 \
--server=https://acme-staging-v02.api.letsencrypt.org/directory \
--email="<メールアドレス>" \
--domains="lorentzca.me" \
--dns="digitalocean" \
--accept-tos run

成功すると (コンテナ内の) /.lego ディレクトリ以下に秘密鍵や証明書等が保存されます。

/.lego/
├── accounts
│   └── acme-staging-v02.api.letsencrypt.org
│       └── <メールアドレス>
│           ├── account.json
│           └── keys
│               └── <メールアドレス>.key
└── certificates
    ├── lorentzca.me.crt
    ├── lorentzca.me.issuer.crt
    ├── lorentzca.me.json
    └── lorentzca.me.key

Nomad のジョブとして lego を実行する

Nomad のジョブには種類があり、ウェブサーバーのように常時稼働させたいものに適したサービスジョブ、短期的な実行に適したバッチジョブがあります。他にシステムジョブがありますが今回は触れません。

証明書の取得 (更新) は例えば 1 日に 1 回動かせば良いのでバッチジョブが適しています。バッチジョブは cron 形式で実行頻度を設定できます。

今回は検証なので定期実行の設定はしません。nomad run 時にだけ実行されるようにします。Let's Encrypt の検証用のエンドポイントを指定することを忘れないように注意します。コンテナ内では /.lego 以下に取得した証明書が保存されるので、ホスト側の /.lego をマウントしコンテナが破棄された後も参照できるようにしておきます。動作するジョブの定義は以下です。

job "lego" {
  datacenters = ["dc1"]

  type = "batch"

  group "lego" {
    count = 1
    task "lego" {
      driver = "docker"
      config {
        image = "xenolf/lego:v2.2.0"
        volumes = [
          "/.lego/:/.lego/"
        ]
        args = [
          "-s", "https://acme-staging-v02.api.letsencrypt.org/directory",
          "-m", "<メールアドレス>",
          "-d", "lorentzca.me",
          "--dns", "digitalocean",
          "-a",
          "run"
        ]
      }
      env {
        "DO_AUTH_TOKEN" = "<DigitalOcean のトークン>"
      }
    }
  }
}

また、バインドマウント (ホストのディレクトリと Docker コンテナ内のディレクトリを接続) するために Docker ドライバーのプラグインオプションとして selinuxlabel を設定する必要があります。ありますが、正直まだ Docker と SELinux の関係についてあんまり理解できてません。

とりあえず以下の設定を Nomad にしてあげれば動きます。

plugin "docker" {
  config {
    volumes {
      selinuxlabel = "z"
    }
  }
}

ここまできたらもう Nomad のジョブとして lego を動かし、証明書を取得できます。

  • Nomad エージェントを起動 (今回検証なので -dev モードで起動)。
nomad agent -dev -config=/etc/nomad.d/nomad.hcl
  • ジョブの確認。
$ nomad job plan lego.nomad
+ Job: "lego"
+ Task Group: "lego" (1 create, 1 ignore)
  + Task: "lego" (forces create)

Scheduler dry-run:
- All tasks successfully allocated.

Job Modify Index: 0
To submit the job with version verification run:

nomad job run -check-index 0 lego.nomad

When running the job with the check-index flag, the job will only be run if the
server side version matches the job modify index returned. If the index has
changed, another user has modified the job and the plan's results are
potentially invalid.
  • ジョブの実行。
$ nomad job run -check-index 0 lego.nomad
==> Monitoring evaluation "5142f008"
    Evaluation triggered by job "lego"
    Allocation "9fcd7771" created: node "41773bbf", group "lego"
    Evaluation status changed: "pending" -> "complete"
==> Evaluation "5142f008" finished with status "complete"
  • ジョブのログを確認。良さそう。
$ nomad alloc logs $(nomad status lego | tail -1 | cut -d' ' -f1)
2019/10/15 16:41:18 No key found for account <メールアドレス>. Generating a P384 key.
2019/10/15 16:41:18 Saved key to /.lego/accounts/acme-staging-v02.api.letsencrypt.org/<メールアドレス>/keys/<メールアドレス>.key
2019/10/15 16:41:19 [INFO] acme: Registering account for <メールアドレス>
!!!! HEADS UP !!!!
                Your account credentials have been saved in your Let's Encrypt
                configuration directory at "/.lego/accounts".
                You should make a secure backup of this folder now. This
                configuration directory will also contain certificates and
                private keys obtained from Let's Encrypt so making regular
                backups of this folder is ideal.2019/10/15 16:41:19 \[INFO\] [lorentzca.me] acme: Obtaining bundled SAN certificate
2019/10/15 16:41:20 \[INFO\] [lorentzca.me] AuthURL: https://acme-staging-v02.api.letsencrypt.org/acme/authz-v3/15009878
2019/10/15 16:41:20 \[INFO\] [lorentzca.me] acme: Could not find solver for: tls-alpn-01
2019/10/15 16:41:20 \[INFO\] [lorentzca.me] acme: Could not find solver for: http-01
2019/10/15 16:41:20 \[INFO\] [lorentzca.me] acme: use dns-01 solver
2019/10/15 16:41:20 \[INFO\] [lorentzca.me] acme: Preparing to solve DNS-01
2019/10/15 16:41:21 \[INFO\] [lorentzca.me] acme: Trying to solve DNS-01
2019/10/15 16:41:21 \[INFO\] [lorentzca.me] acme: Checking DNS record propagation using [10.0.2.3:53]
2019/10/15 16:41:21 [INFO] Wait for propagation [timeout: 1m0s, interval: 5s]
2019/10/15 16:41:27 \[INFO\] [lorentzca.me] The server validated our request
2019/10/15 16:41:27 \[INFO\] [lorentzca.me] acme: Cleaning DNS-01 challenge
2019/10/15 16:41:28 \[INFO\] [lorentzca.me] acme: Validations succeeded; requesting certificates
2019/10/15 16:41:29 \[INFO\] [lorentzca.me] Server responded with a certificate.

ちゃんと証明書がホスト側の指定したディレクトリ内に保存されていますね! 🎉

$ sudo tree /.lego
/.lego
├── accounts
│   └── acme-staging-v02.api.letsencrypt.org
│       └── <メールアドレス>
│           ├── account.json
│           └── keys
│               └── <メールアドレス>.key
└── certificates
    ├── lorentzca.me.crt
    ├── lorentzca.me.issuer.crt
    ├── lorentzca.me.json
    └── lorentzca.me.key

5 directories, 6 files

感想

証明書の取得を Nomad ジョブで行うイメージが固まってきた!あとは以下をうまくクリアできれば課題を解決できそう

  • 取得した証明書をノード間で共有する方法。
    • Vault でうまくできないかな?
  • 証明書の取得と更新は別のコマンドなので、最初だけ取得、証明書が存在すれば更新する、といった分岐が必要。
    • 最悪最初の取得だけ手動で行い更新は Nomad ジョブに任せるとか…。
  • クラスター内の各ウェブサーバーが証明書を取得する方法。
    • Vault からどう取得し適用する??

ぼんやりとしたイメージ的にはこんなだけどうまくいくかは全然わからん。あんまり苦労しそうなら手作業に逃げるかも…。 😇

diagram

当面楽しめそうなおもちゃを手に入れたぞ!感!