Create Nomad Cluster on DigitalOcean

Nomad 上にこのブログ (Ghost) を移そうと考えています。

そこで、まずは Nomad クラスターを DigitalOcean 上に作成してみました。

この記事の一番最後にクラスター作成に使用している Terraform のファイルを貼っておきます。

Nomad とは

Nomad とは Docker コンテナや Qemu 仮想マシン、 Java アプリケーション等のデプロイ、それらによる処理の実行の管理を自動的に行うツールです。

つまり、ワークロードオーケストレーターです。

ラーメン二郎で言えばロット・マエストロやロット・マイスターが該当するかもしれません。

※ロット・マエストロ/マイスターはラーメンが一定のペースで効率よく置かれるように、ラーメンのデプロイ (カウンターへの配置) を管理し、リソースの調整 (少食っぽい人に大を食わせない、食べるのが遅い人を急かす・手伝う) や他のマエストロ/マイスターとの協業 (広い店舗等で互いの領域の管理、連絡、調整) = クラスターの形成等を行う。

ジロリアンコピペ - ラーメン二郎 wiki - アットウィキ

Nomad の Architecture

サーバーとクライアントがいます。実際はもっと複雑でしたがとりあえず以下だけ覚えておきます。

  • Nomad サーバーが Nomad クライアントとジョブを管理しており、Nomad クライアントへタスクを割り当てます。
  • Nomad クライアントがタスクを実行します。

Nomad を動かしてみる上で最低限必要な用語。

  • ジョブ: ユーザーが希望するあるべき状態を定義します (Web サーバを動かしたい、メモリは 128 MB で、等)。1つ以上のタスクグループから構成されます。
  • タスクグループ: 一緒に実行するべき必要のあるタスクの集まりです。例えば Web サーバーではログ収集を行うプロセスも一緒に動かしたい、等。タスクグループは同じノードで実行される必要があり、これ以上分割することはできません。
  • タスク: Nomad での作業の最小単位。タスクはドライバーによって実行されます。
  • ドライバー: タスクを実行するための手段です。Docker, Qemu, Java やバイナリ等。

Nomad サーバーは1つでも動かせますが、冗長化のため3つか5つが推奨されています。それ以上の数になるとパフォーマンスが悪くなるので推奨されません。3つでは単一ノードの障害に耐えることができます。5つでは2ノードの障害に耐えることができます。

サーバーではリーダーが選出され、リーダー以外はフォロワーとなります。リーダーは全てのクエリとトランザクションの処理を担当し、ログエントリ (MySQL で言うとバイナリログ的なもの?) をフォロワーへ複製します。

大まかなアーキテクチャーや用語については以下のドキュメントが、

リーダーが云々等の分散システムのアルゴリズム部分は以下のドキュメントやページが参考になりました (が、難しい…)。

また、以下のスライドがわかりやすく助かりました。

HashiCorpのNomadを使ったコンテナのスケジューリング手法 from Masahito Zembutsu

Consul と Nomad

Nomad はワークロードオーケストレーターです。Consul は Nomad とは別のツールで、サービスディスカバリーやヘルスチェック等、マイクロサービス間の通信を制御するような役割を行うことのできるツールです。

つまり、Nomad でデプロイし動作している大量のアプリケーションを制御するのに使えます。

Nomad と Consul は簡単に連携が可能で、例えば Consul クラスターが存在する環境で Nomad を起動すると、Nomad 側でクラスターの設定を行わずとも勝手に Consul と通信して Nomad クラスターを形成してくれます!!!

ちなみに両方とも HashiCorp 製のツールです。

今回の構成

理想はサーバー用のノード3台、クライアント用のノード3台でしたが流石に大して動かすものも無いのにお金がかかりすぎるので、今回はサーバーにクライアントを兼任させ、合計3台のノードで構築します。

それぞれのノードで Consul と Nomad をインストールし、Consul と連携させクラスタリングします。

ノードの作成、ノードのファイアウォール、Nomad と Consul のプロビジョニングは Terraform を利用します。Terraform もまた HashiCorp のツールで、サーバの構築自体を担当します。

Terraform で構築したインフラ部分の図は以下の通り…です。

※terraform graph で吐かせたがあまり見やすくはないw

Nomad のドキュメントにある、以下の構成が近い感じです。この構成でサーバーとクライアントを合体させた感じです。

Nomad Deployment Guide - Nomad by HashiCorp

Nomad の設定

サーバー兼クライアントの設定です。

datacenter = "sgp1"
data_dir = "/opt/nomad"
bind_addr = "{{ GetInterfaceIP \"eth1\" }}"

server {
  enabled = true
  bootstrap_expect = 3
}

client {
  enabled = true
}
  • bind_addr は Nomad エージェント同士がネットワーク通信を行うために使用する IP アドレスを設定します。"{{ GetInterfaceIP \"eth1\" }}" となっている部分は、go-sockaddr/template format 形式です。これを使うことで、自動的に指定したインターフェースに割り当てられた IP アドレスを設定してくれます。Terraform 等の構成管理ツールでは実際に割り当てられる IP アドレスが起動するまでわからない場合が多いので (また固定 IP アドレスに依存しないのが理想なので) このように状態を持たず設定を記述できるのはドチャクソ便利です。
  • server {} の部分は Nomad サーバーの設定を書くブロックです。enabled = true でサーバーモードを有効に、bootstrap_expect = 3 でクラスターを形成するのに最低限必要なサーバーの数を指定します。指定した台数の Nomad サーバーが起動することで初めてリーダーの選出が行われ、クラスターとして機能し始めます。
  • client {} の部分は Nomad クライアントの設定を行うブロックです。enabled = true でクライアントモードを有効にしています。

その他の設定については以下のドキュメントにまとまっています。

さて、今回 Consul との連携をする予定なのに、Consul に関する設定が1行もありません。また、Nomad クライアントは Nomad サーバーに属するものなのに、その設定もありません。

これは Consul クラスターが存在する場合それを検知し、よしなにやってくれるためです。超便利。

Consul の設定

こちらもサーバー兼クライアントの設定になっています。

※一部 Terraform のテンプレート用の記述が混じっています。

datacenter       = "sgp1"
server           = true
bootstrap_expect = 3
data_dir         = "/opt/consul"
log_level        = "INFO"
ui               = true
advertise_addr   = "{{ GetInterfaceIP \"eth1\" }}"
bind_addr        = "{{ GetInterfaceIP \"eth1\" }}"
client_addr      = "{{ GetInterfaceIP \"eth1\" }} 127.0.0.1"
retry_join       = ["provider=digitalocean region=sgp1 tag_name=nomad-server-node api_token=${do_token}"]
  • server を true にすることでサーバーモードを有効にしています。
  • bootstrap_expect の設定の意味は Nomad と同じです。
  • advertise_addr はクラスタ内のノードに告知するアドレスを指定する設定です。が、指定しない場合 bind_addr のアドレスが使用されます。ので、今回この設定はいらなそう…。今気づいた…。
  • bind_addr の設定は Nomad のものと同じくクラスタ内の通信に利用する IP アドレスを指定します。 利用可能なプライベート IP アドレスが複数ある場合、Consul は起動時にエラーで終了します。 ので、固定 IP アドレスを使用する予定でなければこちらも go-sockaddr/template format で特定のインターフェースを指定してあげる必要があります。
  • client_addr の設定は HTTP や DNS サーバー (Consul は DNS サーバー機能もある) を含むクライアントインターフェースに利用する IP アドレスを指定します。複数指定できます。
  • retry_join は、クラスターを形成するために参加する Consul サーバーを設定します。さて、ここで問題があります。自分自身に動的に割り当てられる IP アドレスは go-sockaddr/template format を利用することで解決できましたが、 自分以外のノードの IP アドレスはどのように設定すれば良いのでしょうか 。上記設定を見ると ["provider=digitalocean region=sgp1 tag_name=nomad-server-node api_token=${do_token}"] となっていますが、これは Cloud Auto-join 機能を利用した記述となっています。この機能は クラウドのメタデータから IP アドレスを引っ張ってくることができます 。例えば今回は DigitalOcean のメタデータを引っ張ってきています。sgp1 リージョンの、nomad-server-node というタグのついているノードの IP アドレスを取得しています。よってクラウドへアクセスするための API トークンが必要となり、api_token=${do_token} の部分で指定しています。トークンは環境変数から取得し、terraform apply 時に設定されるようにしています。クラスターを構成するにあたりどのように他のサーバーの IP アドレスを取得するか悩みどころだと思いますが、この機能は神ですね…。

その他の設定については以下のドキュメントにまとまっています。

その他設定

Nomad と Consul を動かすにあたり、単なるバイナリの実行では管理がし辛いので Systemd に管理してもらいます。

Systemd の設定ファイルはそれぞれ下記ドキュメントに例が載っており、これを少し変更して利用しています。

動作の様子

terraform apply 直後の状態です。うまく Nomad と Consul のクラスターが動作していますね。 🎉

あとは必要なジョブの設定を行なっていくのみ! 💪

感想

以前 Kubernetes にブログ環境を移行すると言ったな、あれは嘘だ。

クラスタリング、少し仕組みを調べようとすると途端にハードルがぶち上がる (OS や ネットワーク等もそうだけど…)。

でもあえてマネジメントサービスではなく自分でクラスター管理してみるの、面白いですね。 😃

Nomad は Consul 以外に Vault の連携もできるようなので試してみたい。

次のステップとしては以下を考えています。

  • Web サーバージョブの作成
  • Ghost blog ジョブの作成
  • Web サーバーへ 80 番ポートアクセスし、Ghost blog が表示されるようにする

その次のステップはブログのコンテンツ (画像や設定ファイル等) をどのようにクラスタ間で同期するのかや、DB ジョブをどのように動かすのが良いのかについて調べていこうと思っています。

そこまでできたらあとはジョブのデプロイ方法や TLS の使用方法について調べて移行を実施しようかと考えています (もっと何かあるかな?)。

今回使用した Terraform のファイル

機密情報が入らないように作成しているので、そのうち GitHub で Public にするかも。

ディレクトリの構成は以下。

.
├── README.md
├── conf
│   ├── consul
│   │   ├── consul.service
│   │   └── consul.tpl.hcl
│   └── nomad
│       ├── nomad.hcl
│       └── nomad.service
├── main.tf
├── remote-state.tf
├── terraform.tfstate
├── terraform.tfstate.backup
└── versions.tf

必要な環境変数の設定は以下。

export DIGITALOCEAN_TOKEN=<DigitalOcean API key>
export TF_VAR_DIGITALOCEAN_TOKEN=<DigitalOcean API key>

あとホームディレクトリに .terraformrc があり、Terraform Cloud のトークンを設定しています。

$ cat ~/.terraformrc
credentials "app.terraform.io" {
  token = "<Terraform Cloud Token>"
}

Terraform のバージョンは 0.12 で 0.11 以下とは互換性が無いので注意。

$ cat versions.tf

terraform {
  required_version = ">= 0.12"
}

remote-state.tf で state を Terraform Cloud 上で管理するように設定しています。

$ cat remote-state.tf
terraform {
  backend "remote" {
    hostname     = "app.terraform.io"
    organization = "lorentzca"

    workspaces {
      name = "prod"
    }
  }
}

main.tf の全容は以下。Nomad や Consul のインストールや設定という本来 Terraform で管理すべきでは無い (例えば設定変えるたびサーバ全部作り直し) 範囲まで設定してしまっていますが、色々目処が立ったところで分解しようと思っています。あと名前の付け直し。

variable "DIGITALOCEAN_TOKEN" {
}

resource "digitalocean_project" "nomad" {
  name        = "Nomad Cluster"
  description = "Lorentzca Nomad cluster."
  purpose     = "Nomad cluster"
  environment = "Production"
  resources   = digitalocean_droplet.nomad-server-nodes[*].urn
}

resource "digitalocean_ssh_key" "nomad-nodes-ssh" {
  name       = "Nomad node's ssh key"
  public_key = file("~/.ssh/id_rsa.pub")
}

data "template_file" "consul-config" {
  template = file("conf/consul/consul.tpl.hcl")

  vars = {
    do_token = var.DIGITALOCEAN_TOKEN
  }
}

resource "digitalocean_firewall" "nomad-nodes" {
  name        = "ssh-nomad-consul-http-https"
  droplet_ids = digitalocean_droplet.nomad-server-nodes[*].id

  inbound_rule {
    protocol         = "icmp"
    source_addresses = ["0.0.0.0/0"]
  }

  inbound_rule {
    protocol           = "tcp"
    port_range         = "1-65535"
    source_droplet_ids = digitalocean_droplet.nomad-server-nodes[*].id
  }

  inbound_rule {
    protocol         = "tcp"
    port_range       = "22"
    source_addresses = ["0.0.0.0/0"]
  }

  inbound_rule {
    protocol         = "tcp"
    port_range       = "80"
    source_addresses = ["0.0.0.0/0"]
  }

  inbound_rule {
    protocol         = "tcp"
    port_range       = "443"
    source_addresses = ["0.0.0.0/0"]
  }

  outbound_rule {
    protocol              = "tcp"
    port_range            = "1-65535"
    destination_addresses = ["0.0.0.0/0"]
  }

  outbound_rule {
    protocol              = "udp"
    port_range            = "1-65535"
    destination_addresses = ["0.0.0.0/0"]
  }

  outbound_rule {
    protocol              = "icmp"
    destination_addresses = ["0.0.0.0/0"]
  }
}

resource "digitalocean_droplet" "nomad-server-nodes" {
  count              = 3
  image              = "centos-7-x64"
  name               = "nomad-server-${count.index}"
  region             = "sgp1"
  size               = "s-1vcpu-1gb"
  backups            = false
  private_networking = true
  ssh_keys           = [digitalocean_ssh_key.nomad-nodes-ssh.fingerprint]
  tags               = ["nomad-server-node"]

  # update package and install basic packages
  provisioner "remote-exec" {
    inline = [
      "yum update -y",
      "yum install -y unzip vim bind-utils docker",
      "systemctl enable docker",
      "systemctl start docker",
    ]

    connection {
      type        = "ssh"
      user        = "root"
      host        = self.ipv4_address
      private_key = file("~/.ssh/id_rsa")
    }
  }

  # install consul for create nomad cluster
  provisioner "remote-exec" {
    inline = [
      "curl -s https://releases.hashicorp.com/consul/1.5.0/consul_1.5.0_linux_amd64.zip -o /tmp/consul.zip",
      "unzip /tmp/consul.zip -d /usr/local/bin",
      "consul -autocomplete-install",
      "complete -C /usr/local/bin/consul consul",
    ]

    connection {
      type        = "ssh"
      user        = "root"
      host        = self.ipv4_address
      private_key = file("~/.ssh/id_rsa")
    }
  }

  # setup consul
  provisioner "remote-exec" {
    inline = [
      "mkdir -p /etc/consul.d",
      "mkdir -p /opt/consul",
    ]

    connection {
      type        = "ssh"
      user        = "root"
      host        = self.ipv4_address
      private_key = file("~/.ssh/id_rsa")
    }
  }

  provisioner "file" {
    destination = "/etc/consul.d/consul.hcl"
    content     = data.template_file.consul-config.rendered

    connection {
      type        = "ssh"
      user        = "root"
      host        = self.ipv4_address
      private_key = file("~/.ssh/id_rsa")
    }
  }

  provisioner "file" {
    source      = "conf/consul/consul.service"
    destination = "/etc/systemd/system/consul.service"

    connection {
      type        = "ssh"
      user        = "root"
      host        = self.ipv4_address
      private_key = file("~/.ssh/id_rsa")
    }
  }

  # install nomad
  provisioner "remote-exec" {
    inline = [
      "curl -s https://releases.hashicorp.com/nomad/0.9.1/nomad_0.9.1_linux_amd64.zip -o /tmp/nomad.zip",
      "unzip /tmp/nomad.zip -d /usr/local/bin",
      "nomad -autocomplete-install",
      "complete -C /usr/local/bin/nomad nomad",
    ]

    connection {
      type        = "ssh"
      user        = "root"
      host        = self.ipv4_address
      private_key = file("~/.ssh/id_rsa")
    }
  }

  # setup nomad
  provisioner "remote-exec" {
    inline = [
      "mkdir -p /opt/nomad",
      "mkdir -p /etc/nomad.d",
    ]

    connection {
      type        = "ssh"
      user        = "root"
      host        = self.ipv4_address
      private_key = file("~/.ssh/id_rsa")
    }
  }

  provisioner "file" {
    source      = "conf/nomad/nomad.service"
    destination = "/etc/systemd/system/nomad.service"

    connection {
      type        = "ssh"
      user        = "root"
      host        = self.ipv4_address
      private_key = file("~/.ssh/id_rsa")
    }
  }

  provisioner "file" {
    source      = "conf/nomad/nomad.hcl"
    destination = "/etc/nomad.d/nomad.hcl"

    connection {
      type        = "ssh"
      user        = "root"
      host        = self.ipv4_address
      private_key = file("~/.ssh/id_rsa")
    }
  }

  # start consul and nomad
  provisioner "remote-exec" {
    inline = [
      "systemctl enable consul",
      "systemctl start consul",
      "sleep 10",
      "systemctl enable nomad",
      "systemctl start nomad",
    ]

    connection {
      type        = "ssh"
      user        = "root"
      host        = self.ipv4_address
      private_key = file("~/.ssh/id_rsa")
    }
  }
}