このブログ記事は、Clojure Web アプリケーションのビルド、テスト、デプロイに関する連載 (全 3 回) の第 3 回です。第 1 回はこちら、第 2 回は and the second こちらをご覧ください。

この記事ではまず、HashiCorp Terraform を使用して、Web アプリケーションの Docker コンテナと PostgreSQL コンテナをホストするための非常に複雑なインフラストラクチャを立ち上げる方法について説明します。その後、CircleCI を使用して、ダウンタイムなしでそのインフラストラクチャをデプロイする方法を解説します。Web アプリケーションの作成作業については前 2 回の記事で説明しています。この作業を省略したい方は、こちら のリポジトリをフォークして part-2 ブランチをチェックアウトし、ソースを取得してください。

Clojure アプリケーションを作成していきますが、本記事を理解するのに必要となる Clojure の知識はごくわずかです。

前提条件

この Web アプリケーションを構築するには、以下のインストールが必要です。

  1. Java JDK 8 以上 - Clojure は Java 仮想マシン上で動作します。もっと言えば、Clojure は Java ライブラリ (JAR) です。この記事ではバージョン 8 を使用していますが、それ以上のバージョンでも問題なく動作します。
  2. Leiningen - lein (“ライン” と発音) と略されることの多い、最もよく使われる Clojure ビルド ツールです。
  3. Git - おなじみの分散型バージョン管理ツールです。
  4. Docker - コンテナを使用することでアプリケーションの作成、デプロイ、実行を簡単に行えるツールです。
  5. Docker Compose - マルチコンテナ Docker アプリケーションを定義、実行するためのツールです。
  6. HashiCorp Terraform - インフラストラクチャを予測可能かつ再現可能な方法で作成、変更するツールです。この記事では、バージョン V0.12.2 を使用してテストを行いました。
  7. SSH がコマンドライン ユーティリティとしてインストールされていること。SSH のインストール方法はオペレーティング システムごとに異なります。未インストールの場合は、インストール方法を検索エンジンで調べるなどしてインストールしてください。

また、以下のアカウントも必要です。

  1. CircleCI アカウント - 継続的インテグレーション & 継続的デリバリーのプラットフォームです。
  2. GitHub アカウント - Git を使用した Web ベースのバージョン管理ホスティング サービスです。
  3. Docker Hub アカウント - Docker ユーザーやパートナーがコンテナ イメージの作成、テスト、保存、配布を行えるクラウドベースのリポジトリです。
  4. AWS アカウント - オンデマンド コンピューティング プラットフォームです。

注: 本記事のインフラストラクチャの構築では、必要な AWS サービスを立ち上げるために多少のコストが発生します。このチュートリアルの所要時間はおよそ 1 時間であり、この間にサービスを稼働させておくと 0.5 ドル~ 1 ドルの料金が発生します。

また、この連載の 第 1 回第 2 回 で説明したように、CircleCI アカウント、Docker Hub アカウント、Github Web アプリケーション アカウントのセットアップも必要です。

CAWS アカウントと認証情報の作成

最初に、AWS アカウントに登録する必要があります。”無料” アカウントを選択してかまいませんが、本記事で使用するリソースには別途料金が発生します。チュートリアル終了後はインフラストラクチャを破棄してかまいません。その方法もこの記事で説明します。

AWS アカウントを作成したら、ルート ユーザーとしてサインインします。登録に使用したメール アドレスがユーザー名になります。必ずルート ユーザーとしてサインインしてください。サインインしたら、[サービス] メニューの [IAM] (Identity Access and Management) を選択します。

画面左側のナビゲーション バーで [ユーザー] を選択し、[ユーザーを追加] をクリックします。作成するユーザーに、プログラムによるアクセスとコンソールへのアクセスの両方を許可します。

ロールの新規作成または既存のポリシーの直接付与により、そのユーザーに 管理者アクセス権 を付与します。

セットアップ ウィザードの他の設定はすべて、デフォルト値のままでかまいません。ただし、[ユーザーを追加] の成功画面を閉じる前に、AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY を書き留めてください。

IAM ユーザーを作成し、アカウント ID (AWS アカウント ID の確認方法はこちら)をメモしたら、アカウント名/番号の横の矢印を選択してドロップダウンで [サインアウト] を選択し、ルート ユーザーからサインアウトします。

次は、新しく作成した IAM ユーザーとしてアカウントにサインインします。サインイン時に [ルートユーザー サインイン] と表示される場合は、[別のアカウントにサインインする] を選択します。先ほどメモした AWS アカウント ID、設定したユーザー名 (この例では “filmappuser”)、マネジメント コンソール アクセス用に設定したパスワードを使用します。

ログインしたら、[サービス] ドロップダウンで [EC2] を選択し、EC2 ダッシュボードの [リソース][キー ペア] をクリックします。

[キー ペアを作成] をクリックして、キー ペアの名前を入力します。 Terraform で film_ratings_key_pair を使用しているので、特に Terraform の編集が必要でなければこの名前を使用してください。自動的にダウンロードされた .pem ファイルを .ssh/ ディレクトリにコピーし、次のコマンドでこのファイルのアクセス許可を設定します。

chmod 400 ~/.ssh/film_ratings_key_pair.pem

アプリのデータベース接続の待機

このステップは必須ではありませんが、Elastic Container Service (ECS) リソースのセットアップ時間が短くなるのでお勧めします。Web アプリケーションは、ECS タスク コンテナでの起動時に、ロード バランサーを通じてデータベース タスク コンテナに接続する必要があります。これについては次のセクションで詳しく説明しますが、データベース コンテナへのロード バランサーの登録が完了するまでには数分かかります。このため、Web アプリケーションをすぐに接続しようとすると失敗し、ECS がアプリ コンテナを破棄しなければなりません。その結果、新しいコンテナをスピンアップする時間が余計にかかることになります。

この時間を短縮するために、データベース接続を待機するための スクリプトを借用 して追加します。Web アプリケーション プロジェクトのルート ディレクトリに wait-for-it.sh ファイルを作成し、新しく作成したファイルに このファイル の内容をカット & ペーストします。

その後、film-ratings Web アプリケーション プロジェクトの Dockerfile を以下のように変更します。

FROM openjdk:8u181-alpine3.8

WORKDIR /

RUN apk update && apk add bash

COPY wait-for-it.sh wait-for-it.sh

COPY target/film-ratings.jar film-ratings.jar
EXPOSE 3000

RUN chmod +x wait-for-it.sh

CMD ["sh", "-c", "./wait-for-it.sh --timeout=90 $DB_HOST:5432 -- java -jar film-ratings.jar"]

これで、 wait-for-it.sh スクリプトが実行され、ポート 5432 で DB_HOST に繰り返し接続を試行するようになりました。 90 秒以内に接続できた場合、java -jar film-ratings.jarが実行されます。

また、次に示すように、project.clj ファイルのバージョンを 0.1.1 に更新します。

(defproject film-ratings "0.1.1"
...

これらの変更が完了したら、変更内容を Git に追加およびコミットし、GitHub リポジトリの film-ratings プロジェクトにプッシュします。

$ git add . --all
$ git commit -m "Add wait for it script"
[master 45967e8] Add wait for it script
2 files changed, 185 insertions(+), 1 deletion(-)
create mode 100644 wait-for-it.sh
$ git push

CircleCI ビルドが正常に実行されたかどうかは、CircleCI の ダッシュボードで確認できます。

今度は、変更が CircleCI で最新バージョンとしてパブリッシュされるように、変更に 0.1.1 とタグ付けして プッシュ します。

$ git tag -a 0.1.1 -m "v0.1.1"
$ git push origin 0.1.1
...
 * [new tag]         0.1.1 -> 0.1.1

CircleCI で、build_and_deploy ワークフローによって Docker イメージが Docker Hub にパブリッシュされたことを確認します。

Terraform による AWS インフラストラクチャの構築

ここからは、Terraform を使用して、本番環境さながらのインフラストラクチャを AWS に構築します。Terraform の設定が大量に必要ですので、定義したリソースの説明は一部省略します。興味のある方は、ぜひコードをご確認ください。

まず、GitHub で film-ratings-terraform リポジトリのリポジトリ名の右側にある [Fork (フォーク)] ボタンをクリックして、このリポジトリをフォークし、フォーク バージョンをローカル マシンにクローンします。

$ git clone <the github URL for your forked version of chrishowejones/film-ratings-terraform>

Terraform リポジトリをフォークしてクローンしたので、重要なポイントを見ていきましょう。Terraform ではどのような処理を行っているのでしょうか。

以下の図は、このインフラストラクチャの重要性の高い部分を非常に簡潔に表したものです。

この図から、2 つの ECS タスク (film_ratings_appfilm_ratings_db) のセットアップが行われることがわかります。これらのタスクは、2 つのアプリ インスタンスと 1 つのデータベース インスタンスが含まれる ECS コンテナで実行されます。アプリとデータベース タスクは、それぞれ film_ratings_app_servicefilm_ratings_db_service という似た名前の独自のサービス内で実行されます (図には記載されていません)。

アプリ サービスとアプリ タスクの定義は次のとおりです。

resource "aws_ecs_service" "film_ratings_app_service" {
  name            = "film_ratings_app_service"
  iam_role        = "${aws_iam_role.ecs-service-role.name}"
  cluster         = "${aws_ecs_cluster.film_ratings_ecs_cluster.id}"
  task_definition = "${aws_ecs_task_definition.film_ratings_app.family}:${max("${aws_ecs_task_definition.film_ratings_app.revision}", "${data.aws_ecs_task_definition.film_ratings_app.revision}")}"
  depends_on      = [ "aws_ecs_service.film_ratings_db_service"]
  desired_count   = "${var.desired_capacity}"
  deployment_minimum_healthy_percent = "50"
  deployment_maximum_percent = "100"
  lifecycle {
    ignore_changes = ["task_definition"]
  }

  load_balancer {
    target_group_arn  = "${aws_alb_target_group.film_ratings_app_target_group.arn}"
    container_port    = 3000
    container_name    = "film_ratings_app"
  }
}
film-ratings-app-service.tf


注: 正常な稼働に最低限必要なタスクの割合を 50% に指定しています。この設定により、ローリング デプロイ時にサービスでコンテナ タスクを (1 つだけ残して) 停止し、解放されたリソースを使用して新バージョンのコンテナ タスクを開始することができます。

data "aws_ecs_task_definition" "film_ratings_app" {
  task_definition = "${aws_ecs_task_definition.film_ratings_app.family}"
  depends_on = ["aws_ecs_task_definition.film_ratings_app"]
}

resource "aws_ecs_task_definition" "film_ratings_app" {
  family                = "film_ratings_app"
  container_definitions = <<DEFINITION
[
  {
    "name": "film_ratings_app",
    "image": "${var.film_ratings_app_image}",
    "essential": true,
    "portMappings": [
      {
        "containerPort": 3000,
        "hostPort": 3000
      }
    ],
    "environment": [
      {
        "name": "DB_HOST",
        "value": "${aws_lb.film_ratings_nw_load_balancer.dns_name}"
      },
      {
        "name": "DB_PASSWORD",
        "value": "${var.db_password}"
      }
    ],
    "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "film_ratings_app",
          "awslogs-region": "${var.region}",
          "awslogs-stream-prefix": "ecs"
        }
    },
    "memory": 1024,
    "cpu": 256
  }
]
DEFINITION
}
film-ratings-app-task-definition.tf


アプリ インスタンスは、ポート 5432 を介してデータベース インスタンスと通信する必要があります。それには、ネットワーク ロード バランサー (film-ratings-nw-load-balancer) を介してリクエストをルーティングする必要があります。そのため、film_ratings_app タスクのセットアップ時に、コンテナにネットワーク ロード バランサーの DNS 名を渡し、この名前をコンテナ内のアプリケーションが DB_HOST として使用してデータベースと通信できるようにします。

...
resource "aws_lb_target_group" "film_ratings_db_target_group" {
  name                = "film-ratings-db-target-group"
  port                = "5432"
  protocol            = "TCP"
  vpc_id              = "${aws_vpc.film_ratings_vpc.id}"
  target_type         = "ip"

  health_check {
    healthy_threshold   = "3"
    unhealthy_threshold = "3"
    interval            = "10"
    port                = "traffic-port"
    protocol            = "TCP"
  }

  tags {
    Name = "film-ratings-db-target-group"
  }
}

resource "aws_lb_listener" "film_ratings_nw_listener" {
  load_balancer_arn = "${aws_lb.film_ratings_nw_load_balancer.arn}"
  port              = "5432"
  protocol          = "TCP"

  default_action {
    target_group_arn = "${aws_lb_target_group.film_ratings_db_target_group.arn}"
    type             = "forward"
  }
}
network-load-balancer.tf


アプリケーション ロード バランサー (film-ratings-alb-load-balancer) は、ブラウザーの参照先として使用します。ロード バランサーの役割は、HTTP リクエストを film_ratings_app コンテナの 2 つのインスタンスのうちの 1 つにルーティングし、デフォルトのポート 80 をポート 3000 にマッピングすることです。

...
resource "aws_alb_target_group" "film_ratings_app_target_group" {
  name                = "film-ratings-app-target-group"
  port                = 3000
  protocol            = "HTTP"
  vpc_id              = "${aws_vpc.film_ratings_vpc.id}"
  deregistration_delay = "10"

  health_check {
    healthy_threshold   = "2"
    unhealthy_threshold = "6"
    interval            = "30"
    matcher             = "200,301,302"
    path                = "/"
    protocol            = "HTTP"
    timeout             = "5"
  }

  stickiness {
    type  = "lb_cookie"
  }

  tags = {
    Name = "film-ratings-app-target-group"
  }
}

resource "aws_alb_listener" "alb-listener" {
  load_balancer_arn = "${aws_alb.film_ratings_alb_load_balancer.arn}"
  port              = "80"
  protocol          = "HTTP"

  default_action {
    target_group_arn = "${aws_alb_target_group.film_ratings_app_target_group.arn}"
    type             = "forward"
  }
}

resource "aws_autoscaling_attachment" "asg_attachment_film_rating_app" {
  autoscaling_group_name = "film-ratings-autoscaling-group"
  alb_target_group_arn   = "${aws_alb_target_group.film_ratings_app_target_group.arn}"
  depends_on = [ "aws_autoscaling_group.film-ratings-autoscaling-group" ]
}
application-load-balancer.tf


図中のインフラストラクチャのもう 1 つの重要な点は、コンテナを実行する EC2 インスタンスの外部でデータを永続化するために、Elastic File System ボリュームにマッピングされたボリュームを film_ratings_db にマウントしていることです。このようにした理由は、インスタンスのスケールアップやスケールダウンが必要な場合 (またはインスタンスが停止した場合) に、データベース内のデータが失われないようにするためです。

resource "aws_ecs_task_definition" "film_ratings_db" {
  family                = "film_ratings_db"
  volume {
    name = "filmdbvolume"
    host_path = "/mnt/efs/postgres"
  }
  network_mode = "awsvpc"
  container_definitions = <<DEFINITION
[
  {
    "name": "film_ratings_db",
    "image": "postgres:alpine",
    "essential": true,
    "portMappings": [
      {
        "containerPort": 5432
      }
    ],
    "environment": [
      {
        "name": "POSTGRES_DB",
        "value": "filmdb"
      },
      {
        "name": "POSTGRES_USER",
        "value": "filmuser"
      },
      {
        "name": "POSTGRES_PASSWORD",
        "value": "${var.db_password}"
      }
    ],
    "mountPoints": [
        {
          "readOnly": null,
          "containerPath": "/var/lib/postgresql/data",
          "sourceVolume": "filmdbvolume"
        }
    ],
    "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "film_ratings_db",
          "awslogs-region": "${var.region}",
          "awslogs-stream-prefix": "ecs"
        }
    },
    "memory": 512,
    "cpu": 256
  }
]
DEFINITION
}
film-ratings-db-task-definition.tf


次のローンチ設定で、EC2 インスタンスの起動時に、user_data エントリから EFS ボリュームをマウントします。

...
  user_data                   = <<EOF
                                  #!/bin/bash
                                  echo ECS_CLUSTER=${var.ecs_cluster} >> /etc/ecs/ecs.config
                                  mkdir -p /mnt/efs/postgres
                                  cd /mnt
                                  sudo yum install -y amazon-efs-utils
                                  sudo mount -t efs ${aws_efs_mount_target.filmdbefs-mnt.0.dns_name}:/ efs
                                  EOF
launch-configuration.tf


AWS インフラストラクチャの作成

Terraform の実行準備はほとんど完了しましたが、実行の前に、各種変数の値がすべて正しいことを確認する必要があります。terraform.tfvars ファイルの中身を見てみましょう。

# 必要に応じ、設定ファイルに合わせて以下の変数を編集
db_password= "password"
ecs_cluster="film_ratings_cluster"
ecs_key_pair_name="film_ratings_key_pair"
region= "eu-west-1"
film_ratings_app_image= "chrishowejones/film-ratings-app:latest"

# 以下は、必要なければ変更不要
film_ratings_vpc = "film_ratings_vpc"
film_ratings_network_cidr = "210.0.0.0/16"
film_ratings_public_01_cidr = "210.0.0.0/24"
film_ratings_public_02_cidr = "210.0.10.0/24"
max_instance_size = 3
min_instance_size = 1
desired_capacity = 2 
terraform.tfvars


パスワードは必要に応じて変更できますが、今回はデモなので必要ありません。また、環境変数 TF_VAR_db_password を設定してパスワードを上書きすることもできます。ecs_key_pair_name の値は、先ほど作成した .ssh/ ディレクトリの AWS ユーザー用のキー ペアの名前と一致している必要があります。 また、film_ratings_app_image は、このサンプルのままではなく、お使いのイメージに対応する Docker Hub リポジトリ イメージ名に変更してください (適切な名前の Docker Hub リポジトリ イメージをパブリッシュしていることを確認してください。この連載の第 2 回 の作業を完了していれば、パブリッシュ済みのはずです)。

本記事で使用しているものとは別の AWS リージョンを使用したい場合は、region の値を変更する必要があります。data.tf ファイルによって、${data.aws_ami.latest_ecs.id} 変数がお使いのリージョンに適したECS に最適化された AMI イメージ に設定されます。

上記以外の値は、特に変更する必要はありません。

Terraform の実行

terraform.tfvars の値を適切に設定したら、クローンした film-ratings-terraform ディレクトリで Terraform を初期化します。

$ terraform init

Initializing provider plugins...
- Checking for available provider plugins on https://releases.hashicorp.com...
...
Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

次に、terraform plan を実行して、適用時にどのリソースが作成されるかを確認します。その際、AWS アクセス キー ID と AWS シークレット アクセス キーを求められます。これらの入力は、ターミナル セッションで環境変数 TF_VAR_aws_access_key_idTF_VAR_aws_secret_access_key を設定することで省略できます。

$ terraform plan
var.aws_access_key_id
  AWS access key

  Enter a value: ...
...
Refreshing Terraform state in-memory prior to plan...
...
Plan: 32 to add, 0 to change, 0 to destroy.

------------------------------------------------------------------------

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.

To actually build the AWS resources listed in the plan, use the following command (enter `yes` when prompted with `Do you want to perform these actions?`):

$ terraform apply
data.aws_iam_policy_document.ecs-instance-policy: Refreshing state...
data.aws_availability_zones.available: Refreshing state...
data.aws_iam_policy_document.ecs-service-policy: Refreshing state...
...
Plan: 32 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes
...
aws_autoscaling_group.film-ratings-autoscaling-group: Creation complete after 1m10s (ID: film-ratings-autoscaling-group)

Apply complete! Resources: 32 added, 0 changed, 0 destroyed.

Outputs:

app-alb-load-balancer-dns-name = film-ratings-alb-load-balancer-895483441.eu-west-1.elb.amazonaws.com
app-alb-load-balancer-name = film-ratings-alb-load-balancer
ecs-instance-role-name = ecs-instance-role
ecs-service-role-arn = arn:aws:iam::731430262381:role/ecs-service-role
film-ratings-app-target-group-arn = arn:aws:elasticloadbalancing:eu-west-1:731430262381:targetgroup/film-ratings-app-target-group/8a35ef20a2bab372
film-ratings-db-target-group-arn = arn:aws:elasticloadbalancing:eu-west-1:731430262381:targetgroup/film-ratings-db-target-group/5de91812c3fb7c63
film_ratings_public_sg_id = sg-08af1f2ab0bb6ca95
film_ratings_public_sn_01_id = subnet-00b42a3598abf988f
film_ratings_public_sn_02_id = subnet-0bb02c32db76d7b05
film_ratings_vpc_id = vpc-06d431b5e5ad36195
mount-target-dns = fs-151074dd.efs.eu-west-1.amazonaws.com
nw-lb-load-balancer-dns-name = film-ratings-nw-load-balancer-4c3a6e6a0dab3cfb.elb.eu-west-1.amazonaws.com
nw-lb-load-balancer-name = film-ratings-nw-load-balancer
region = eu-west-1

Terraform の実行に最大 5 分、さらにロード バランサーと ECS サービスがお互いを適切に認識するまで 5 分かかります。進捗状況を確認するには、AWS コンソールにアクセスして、[サービス] ドロップダウンから [ECS] を選択し、クラスタ (デフォルトの名前は film_ratings_cluster) を選択して、各サービスにアタッチされたログを調査します。film_ratings_app_service の接続が完了するまで少し時間がかかります。場合によっては、wait-for-it.sh スクリプトを設定していても、最初に開始するタスクでデータベースへの接続に失敗して、別のタスク インスタンスを起動する自動スケーリングが行われるまで待たなければならないことがあります。

ECS サービスとタスクが起動したら、app-alb-load-balancer-dns-name の値を URL に指定して、ブラウザーからアプリケーションに接続できるか試してみてください (この例では上記の出力に film-ratings-alb-load-balancer-895483441.eu-west-1.elb.amazonaws.com と示されていますが、みなさんが実際に行う際の値は異なります)。

初めに HTTP ステータス 502 または 503 が表示されたとしても慌てないでください。ECS の起動後も、アプリケーション ロード バランサーによりすべての動作が正常であると検出されるまでに数分かかることがあるからです。この状況は、ブラウザーに ALB ロード バランサーの DNS 名 (この例では film-ratings-alb-load-balancer-895483441.eu-west-1.elb.amazonaws.com) を入力することで確認できます。

なお、これらのリソースはすべて、terraform destroy コマンドを実行することで破棄できます (すべてのリソースを破棄してよいかどうか確認を求められたら、「yes」と入力します)。

CircleCI による ECS クラスタへのデプロイ

次に、継続的インテグレーション サービスである CircleCI を使用して、Docker インスタンスを自動でパブリッシュします。

前回までの CircleCI 構成では、GitHub に変更がプッシュされるたびにアプリケーションのビルドとテストを行うように設定しています。また、GitHub でプロジェクトにタグが付けられるたびに、Docker コンテナとしてビルド、テスト、パッケージ化、Docker Hub への Docker コンテナのパブリッシュを行うようにも設定しました。

本記事のこのセクションでは、プロジェクトにタグが付けられたときに、パッケージ化された Docker コンテナを ECS クラスタにプッシュする処理を設定ファイルに追加します。ただし、ECS へのデプロイ開始タイミングを制御できるように、手動の承認ステップを追加します。ローリング デプロイを使用するように ECS サービスを設定済みなので、このプロセスでは、タグ付けされた変更後のアプリケーションをダウンタイムなしでデプロイできます。

現在、.circleci/config.yml には、buildbuild-dockerpublish-dockerという 3 つのジョブがあります。今回は、publish-docker の下に deployという名前のジョブを追加します。

  ...
  deploy:
    docker:
      - image: circleci/python:3.6.1
    environment:
      AWS_DEFAULT_OUTPUT: json
      IMAGE_NAME: chrishowejones/film-ratings-app
    steps:
      - checkout
      - restore_cache:
          key: v1-{{ checksum "requirements.txt" }}
      - run:
          name: AWS CLI のインストール
          command: |
            python3 -m venv venv
            . venv/bin/activate
            pip install -r requirements.txt
      - save_cache:
          key: v1-{{ checksum "requirements.txt" }}
          paths:
            - "venv"
      - run:
          name: デプロイ
          command: |
            . venv/bin/activate
            ./deploy.sh
...
.circleci/config.yml


このジョブは、pip (Python パッケージ マネージャー) を使用し、CLI パッケージ要件が含まれる requirements.txt ファイルを読み取って AWS CLI ツールをインストールします。次に、実行ステップ Deploy によって deploy.sh スクリプトが実行されます (このスクリプトはまだ作成していません)。 なお、IMAGE_NAME はこの例のとおりではなく、お使いの Docker Hub リポジトリのイメージ名に変更してください。 次に進む前に、build_and_deploy ワークフローの終わりに必要なジョブをさらに追加します。

...
      - hold:
          requires:
            - publish-docker
          type: approval
          filters:
            branches:
              ignore: /.*/
            tags:
              only: /^\d+\.\d+\.\d+$/
      - deploy:
          requires:
            - hold
          filters:
            branches:
              ignore: /.*/
            tags:
              only: /^\d+\.\d+\.\d+$/
.circleci/config.yml


build_and_deploy ワークフローに 2 つの新しいジョブを追加しました。 1 つ目の hold は、前の publish-docker ステップの完了を必要条件に設定します。type は ‘approval’ です。 ‘approval’ タイプは CircleCI に組み込みのタイプで、ワークフローを停止し、後続のステップに進むためにユーザーに対して [Approve (承認)] をクリックするように求めます。

deploy ジョブは、hold が承認されない限り実行されません。

注: build_and_deploy ワークフロー内の他のステップと同様に、これらのステップは、セマンティック バージョン形式のタグ (例: 0.1.1) がプッシュされたときにのみトリガーされます。

次は、プロジェクトのルート ディレクトリに requirements.txt ファイルを追加して、CircleCI によって AWS CLI ツールがインストールされるようにします。このファイルには以下の内容を入力します。

awscli>=1.16.0

requirements.txt


さらに、deploy ジョブで参照される deploy.sh スクリプトを、プロジェクトのルート ディレクトリに新しく作成します。

#!/usr/bin/env bash

# JQの出力を bash に適した形式に変更
JQ="jq --raw-output --exit-status"

configure_aws_cli(){
        aws --version
        aws configure set default.region eu-west-1 # 別の AWS リージョンを使用する場合はこれを変更
        aws configure set default.output json
}

deploy_cluster() {

    family="film_ratings_app"

    make_task_def
    register_definition
    if [[ $(aws ecs update-service --cluster film_ratings_cluster  --service film_ratings_app_service --task-definition $revision | \
                   $JQ '.service.taskDefinition') != $revision ]]; then
        echo "Error updating service."
        return 1
    fi

    # 古いバージョンが消えるまで待機
    # 必須ではないが、デモには最適
    for attempt in {1..15}; do
        if stale=$(aws ecs describe-services --cluster film_ratings_cluster --services film_ratings_app_service | \
                       $JQ ".services[0].deployments | .[] | select(.taskDefinition != \"$revision\") | .taskDefinition"); then
            echo "Waiting for stale deployments:"
            echo "$stale"
            sleep 45
        else
            echo "Deployed!"
            return 0
        fi
    done
    echo "Service update took too long."
    return 1
}

make_task_def(){
        task_template='[
                {
                    "name": "film_ratings_app",
                    "image": "%s:%s",
                    "essential": true,
                    "portMappings": [
                      {
                          "containerPort": 3000,
                          "hostPort": 3000
                      }
                    ],
                    "environment": [
                      {
                        "name": "DB_HOST",
                        "value": "%s"
                      },
                      {
                        "name": "DB_PASSWORD",
                        "value": "%s"
                      }
                    ],
                    "logConfiguration": {
                      "logDriver": "awslogs",
                      "options": {
                        "awslogs-group": "film_ratings_app",
                        "awslogs-region": "eu-west-1",
                        "awslogs-stream-prefix": "ecs"
                      }
                    },
                    "memory": 1024,
                    "cpu": 256
                }
        ]'

        task_def=$(printf "$task_template" $IMAGE_NAME $CIRCLE_TAG $DB_HOST $DB_PASSWORD)
}

register_definition() {

    if revision=$(aws ecs register-task-definition --container-definitions "$task_def" --family $family | $JQ '.taskDefinition.taskDefinitionArn'); then
        echo "Revision: $revision"
    else
        echo "Failed to register task definition"
        return 1
    fi

}

configure_aws_cli
deploy_cluster
deploy.sh


このスクリプトでは、film_ratings_app タスク用に新しいタスク定義を作成しています。設定ファイルを見るとわかりますが、これは film-ratings-app-task-definition.tf の Terraform 定義のものとほぼ同じ内容です。スクリプトは、クラスタへの新しいタスクのデプロイも行います。

make_task_def 関数の task_def 設定では、$IMAGE_NAME$CIRCLE_TAG$DB_HOST$DB_PASSWORD という名前の変数の値を代入しています。後で、これらの変数を CircleCI プロジェクト環境変数に設定します。

注: お使いの AWS リージョンが eu-west-1 ではない場合は、8 行目と 67 行目のエントリを変更する必要があります。

次に進む前に、この新しいスクリプト ファイルを実行可能にしましょう。

$ chmod +x deploy.sh

次に、これらの変数と必要な AWS 変数を CircleCI 設定ファイルに追加します。

CircleCI のダッシュボード に移動して、film-ratings プロジェクトの設定を選択し、[Build Settings (ビルド設定)] の下の [Environment Variables (環境変数)] を選択して、次の変数を入力します。

注: ここで、既に DOCKERHUB_PASSDOCKERHUB_USERNAME は設定済みのはずです。

ユーザーの作成時に取得した AWS アクセス キー ID と AWS シークレット アクセス キー (Terraform 実行時に設定したのと同じ値) を使用してください。 AWS アカウント ID は、こちらで確認できます。 DB_PASSWORDterraform.tfvars ファイルで使用した値を設定します。

DB_HOST の値は、TCP リクエストを film_ratings_db タスク インスタンスにルーティングするネットワーク ロード バランサーの DNS 名にする必要があります。DNS 名を確認するには、 terraform apply コマンドの出力を見るか、AWS マネジメント コンソールにログインして [サービス][EC2][ロード バランサー]の順に移動し、film-ratings-nw-load-balancer エントリを選択して DNS 名を見ます ([基本的な設定] セクション)

nw-lb-load-balancer-dns-name = film-ratings-nw-load-balancer-4c3a6e6a0dab3cfb.elb.eu-west-1.amazonaws.com
nw-lb-load-balancer-name = film-ratings-nw-load-balancer
region = eu-west-1
terraform apply コマンドの出力


この例では、CircleCI の DB_HOST 変数は film-ratings-nw-load-balancer-4c3a6e6a0dab3cfb.elb.eu-west-1.amazonaws.com に設定します。

注: AWS リソースを破棄済みの場合、film-ratings-terraform ディレクトリで terraform apply コマンドを実行して、AWS リソースを再セットアップし、ネットワーク ロード バランサーの DNS を取得する必要があります。

環境変数を設定したら、CircleCI 設定ファイルと新しいデプロイ スクリプト ファイルの変更内容をコミットして、GitHub にプッシュします。

$ git add . --all
$ git commit -m "Added config to deploy to ECS"
$ git push origin master

アプリケーションの変更とデプロイ

それでは、アプリケーションに機能を追加してタグ付けし、変更を ECS クラスタにデプロイすることで、この CircleCI 設定ファイルが動作することを確認しましょう。

今回アプリケーションに追加する機能は、検索機能です。

まず、検索フォームを表示し検索フォームのポストを処理するために、ルートとハンドラー キーのマッピングを resources/film_ratings/config.edn ファイルに追加します。

... 
:duct.module/ataraxy
 {\[:get "/"\] [:index]
  "/add-film"
  {:get [:film/show-create]
   \[:post {film-form :form-params}\] [:film/create film-form]}
  \[:get "/list-films"\] [:film/list]
  "/find-by-name"
  {:get [:film/show-search]
   \[:post {search-form :form-params}\] [:film/find-by-name search-form]}}

 :film-ratings.handler/index {}
 :film-ratings.handler.film/show-create {}
 :film-ratings.handler.film/create {:db #ig/ref :duct.database/sql}
 :film-ratings.handler.film/list {:db #ig/ref :duct.database/sql}
 :film-ratings.handler.film/show-search {}
 :film-ratings.handler.film/find-by-name {:db #ig/ref :duct.database/sql}
...
resources/film_ratings/config.edn


次に、show-search キーと find-by-name キーのハンドラーを、src/film_ratings/handler/film.clj ファイルの下部に追加します。

...

(defmethod ig/init-key :film-ratings.handler.film/show-search [_ _]
  (fn [_]
    [::response/ok (views.film/search-film-by-name-view)]))

(defmethod ig/init-key :film-ratings.handler.film/find-by-name [_ {:keys [db]}]
  (fn [{[_ search-form] :ataraxy/result :as request}]
    (let [name (get search-form "name")
          films-list (boundary.film/fetch-films-by-name db name)]
      (if (seq films-list)
        [::response/ok (views.film/list-films-view films-list {})]
        [::response/ok (views.film/list-films-view [] {:messages [(format "No films found for %s." name)]})]))))
src/film_ratings/handler/film.clj


次に、src/film_ratings/views/index.clj ファイルで、インデックス ビューに新しいボタンを追加します。

(defn list-options []
  (page
    [:div.container.jumbotron.bg-white.text-center
     [:row
      [:p
       [:a.btn.btn-primary {:href "/add-film"} "Add a Film"]]]
     [:row
      [:p
       [:a.btn.btn-primary {:href "/list-films"} "List Films"]]]
     [:row
      [:p
       [:a.btn.btn-primary {:href "/find-by-name"} "Search Films"]]]]))
src/film_ratings/handler/index.clj


さらに、search-films-by-name-view のビューを、src/film_ratings/views/film.clj ファイルの下部に追加します。

...

(defn search-film-by-name-view
  []
  (page
   [:div.container.jumbotron.bg-light
    [:div.row
     [:h2 "Search for film by name"]]
    [:div
     (form-to [:post "/find-by-name"]
              (anti-forgery-field)
              [:div.form-group.col-12
               (label :name "Name:")
               (text-field {:class "mb-3 form-control" :placeholder "Enter film name"} :name)]
              [:div.form-group.col-12.text-center
               (submit-button {:class "btn btn-primary text-center"} "Search")])]]))
src/film_ratings/views/film.clj


データベース境界プロトコルと関連する実装には、find-by-name ハンドラーで参照される fetch-films-by-name 関数が必要です。以下のように、src/film_ratings/boundary/film.clj ファイルで、適切な関数をプロトコルとプロトコルの拡張を追加します。

...
(defprotocol FilmDatabase
  (list-films [db])
  (fetch-films-by-name [db name])
  (create-film [db film]))

(extend-protocol FilmDatabase
  duct.database.sql.Boundary
  (list-films [{db :spec}]
    (jdbc/query db ["SELECT * FROM film"]))
  (fetch-films-by-name [{db :spec} name]
    (let [search-term (str "%" name "%")]
     (jdbc/query db ["SELECT * FROM film WHERE LOWER(name) like LOWER(?)" search-term])))
  (create-film [{db :spec} film]
    (try
     (let [result (jdbc/insert! db :film film)]
       (if-let [id (val (ffirst result))]
         {:id id}
         {:errors ["Failed to add film."]}))
     (catch SQLException ex
       (log/errorf "Failed to insert film. %s\n" (.getMessage ex))
       {:errors [(format "Film not added due to %s" (.getMessage ex))]}))))
src/film_ratings/boundary/film.clj


また、プロジェクト ファイルでビルドのバージョン番号を 0.2.0 に変更します。

(defproject film-ratings "0.2.0"
...

以下を実行して、ここまでの内容をテストします。

$ lein repl
nREPL server started on port 43477 on host 127.0.0.1 - nrepl://127.0.0.1:43477
REPL-y 0.3.7, nREPL 0.2.12
Clojure 1.9.0
Java HotSpot(TM) 64-Bit Server VM 1.8.0_191-b12
    Docs: (doc function-name-here)
          (find-doc "part-of-name-here")
  Source: (source function-name-here)
 Javadoc: (javadoc java-object-or-class-here)
    Exit: Control+D or (exit) or (quit)
 Results: Stored in vars *1, *2, *3, an exception in *e

user=> (dev)
:loaded
dev=> (go)
:duct.server.http.jetty/starting-server {:port 3000}
:initiated
dev=> 

これで、ブラウザーで http://localhost:3000/を開くと、[Search Films (映画の検索)] がインデックスに表示されます。

テスト (SQLite) データベースにいくつか映画を追加して、[Search Films (映画の検索)] を使用して検索フォームに移動してみましょう。1 つ以上の映画の名前の一部を入力します。

期待する結果が検索で得られるかどうか、[Search (検索)] を押して確認します。

一致する結果が返されないような検索を試してみてもよいでしょう。

れでは、変更を GitHub にコミットします。

$ git add . --all
$ git commit -m "Added search for films"
$ git push origin master

CircleCI ビルドが正常に実行されたかどうかは、CircleCI の ダッシュボードで確認できます。

さらに、新しいバージョン タグ 0.2.0 をこのビルドに付けます。

$ git tag -a 0.2.0 -m "v0.2.0"
$ git push origin 0.2.0         
...
 * [new tag]         0.2.0 -> 0.2.0

これで、CircleCI で build_and_deploy ワークフローがトリガーされます。

数分すると、ワークフローで Docker Hub へのパブリッシュが終了し、保留状態になります。

保留中のジョブをクリックし、[Approve (承認)] をクリックすると、ワークフローが再開して ECS へのデプロイが行われます。デプロイ ステップには数分かかる場合があります。また、AWS Elastic Container Service における両方のインスタンスのローリング デプロイには、最大 5 分かかる場合があります。ECS デプロイの進捗状況は、ECS コンソールで確認できます。5 分ほど経過してからブラウザーでアプリケーション ロード バランサーの DNS 名を入力すると、新しい検索ボタンが表示されます。これで、映画の追加や検索を行えるようになりました。

AWS リソースを使い終えたら、必ず film-ratings-terraform ディレクトリで ** terraform destroyコマンドを実行して、リソースを破棄してください。**

まとめ

お疲れさまでした。一連の連載記事を通じて、PostgreSQL データベースを使用した Clojure Web アプリケーションを作成、テスト、ビルドし、Docker コンテナにパッケージ化して、コンテナをパブリッシュし、AWS Elastic Container Service を立ち上げ、Terraform でクラスタをデプロイしました。

これは、少し単純化してありますが、実際の Web アプリケーションにも応用できるかなり現実的なセットアップです。お使いのアプリケーション ロード バランサーを指す Web ドメインを追加したり、TLS 証明書で通信のセキュリティを確保したり、監視を追加したりしてみてもよいでしょう。ただし、必要なコンポーネントの大部分は既に構築が完了しています。


Chris Howe-Jones 氏は、DevCycleのコンサルタント CTO、ソフトウェア アーキテクト、リーン/アジャイル コーチ、開発者、テクニカル ナビゲーターです。主に Clojure/ClojureScript、Java、Scala を扱い、多国籍組織から小規模なスタートアップまで、さまざまなクライアントを抱えています。

さんの他の投稿を読む Chris Howe-Jones