Create Nomad Cluster on DigitalOcean

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

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

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

Nomad とは

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

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

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

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

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

Nomad の Architecture

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

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

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 で構築したインフラ部分の図は以下の通り…です。

graph

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

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

nomad_reference_diagram-72c969e0

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
}

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

さて、今回 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}"]

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

その他設定

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

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

動作の様子

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

nomad

consul

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

感想

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

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

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

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

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

その次のステップはブログのコンテンツ (画像や設定ファイル等) をどのようにクラスタ間で同期するのかや、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")
    }
  }
}