コーディング未経験のPO/PdMのためのRails on Dockerハンズオン、Rails on Dockerハンズオン vol.16 - Deploy to EKS -

# はじめに
こんにちは！今回でラストです！

今回はAWSのマネージド[Kubernetes](https://kubernetes.io/ja/)サービスである[Elastic Kubernetes Service（EKS）](https://aws.amazon.com/jp/eks/)にデプロイしてみたいと思います。
今まで作ってきたRailsアプリコンテナをEKSで動かし、DBは同じくAWSのマネージドRDBサービスの[Relational Database Service（RDS）](https://aws.amazon.com/jp/rds/)を使います。
インフラ構築にはInfrastructure as Codeツールの[Terraform](https://www.terraform.io/)を使ってみます。

あくまで「こいつ、動くぞ！」を目的にしているので、今回のハンズオンだけでこれらの全てを伝えるわけではありませんし、使いこなせるわけではありません。あくまでとっかかりとして捉えてみてください。
気になる方はどんどん調査して使ってみてください！

AWSを使うので、各自AWSアカウントは取得しておいてくださいね。

では、最後のハンズオンを始めます！

# 前回のソースコード
ここまでのソースコードはこちらに格納してます。今回のハンズオンからやりたい場合はこちらからダウンロードしてください。

- https://github.com/at946/rails_on_docker_handson/tree/vol.14

# Kubernetes
[Kubernetes(k8s）](https://kubernetes.io/ja/)はコンテナオーケストレーションツールに位置付けられるOSSです。
コンテナオーケストレーションツールは、Dockerなどのコンテナ技術を使って作られたアプリケーションのデプロイ、スケーリング、サービスディスカバリー、負荷分散などなどを管理したり自動化したりできるもので、Docker単体だけでは難しかったコンテナの本番環境稼働を可能にしてくれます。（Docker単体ではボリュームやネットワークがサーバーと１対１だったり、サーバーとコンテナの関連の管理がおよそ人ではできなかったり困難がありました。）

コンテナオーケストレーションツールとしては、Kubernetesの他にも[Docker Swarm](https://docs.docker.com/swarm/overview/)や[Mesosphere](https://d2iq.com/solutions/mesosphere)などがありましたが、2020年現在、事実上Kubernetesがデファクトスタンダードとなっています。（参考：[国内でDockerコンテナを本番利用している企業は9.2％、コンテナオーケストレーションツールはKubernetesがデファクト - ITmedia NEWS](https://www.itmedia.co.jp/news/articles/1907/22/news087.html)）

# マネージドKubernetes
KubernetesはOSSなので誰でも自分のサーバーで環境構築することができます。
ただ、すごいことをやってくれるので仕組みもけっこう複雑です。「Kubernetesがデファクトか！じゃあ構築するぞ！」みたいなノリではできないんじゃないかと思います。
Kubernetesでは、全体をクラスターと呼び管理しています。クラスターを管理する部分を「マスターコンポーネント」、アプリコンテナが稼働する部分を「ノードコンポーネント」と分けたりします。（参考：[Kubernetesのコンポーネント - Kubernetes](https://kubernetes.io/ja/docs/concepts/overview/components/)）
特に「マスターコンポーネント」はクラスターのコントロールプレーンを担っておりとても重要かつ要素も複雑です。運用したくないです。

そこで大手クラウドベンダーはマネージドKubernetesサービスとして、利用者は主にノードコンポーネントの一部（インスタンス数とかボリュームとか）だけに関心を持っていればKubernetesを使えるサービスを展開しています。
AWSであれば[Elastic Kubernetes Service（EKS）](https://aws.amazon.com/jp/eks/)、GCPであれば[Google Kubernetes Engine（GKE）](https://cloud.google.com/kubernetes-engine?hl=ja)、Microsoft Azureであれば[Azure Kubernetes Service（AKS）](https://azure.microsoft.com/ja-jp/services/kubernetes-service/)です。

今回はEKSを使ってKubernetes上に作ってきたRailsアプリケーションをデプロイしてみましょう。

# システム構成
![network_diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254216/a00daf4b-3067-6543-0692-81181f7707be.png)
[Diagrams](https://diagrams.mingrammer.com/)で描いてみました。（参考：[Diagrams on Dockerでシステム構成図を書いてみた - Qiita](https://qiita.com/at-946/items/75d4d3aad97c1470c2cc)）

VPCの中でEKS（ノードコンポーネント）とRDSはPrivate Subnetに配置します。今回はあまり使う機会なしですが、ノードコンポーネントをPrivate Subnetにおいているのでインターネットと通信するためにPublic SubnetにNAT Gatewayをおきます。
また、Dockerイメージを管理するために[Elastic Container Registry（ECR）](https://aws.amazon.com/jp/ecr/)を使います。

このシステム構成を構築し、EKSでPodをデプロイしていくために、Infrastructure as Codeツールの[Terraform](https://www.terraform.io/)、AWSリソースをコマンドラインで操作するための[AWS CLI](https://aws.amazon.com/jp/cli/)、Kubernetesを操作するための[kubectl](https://kubernetes.io/docs/reference/kubectl/overview/)を使います。
まずは、これらのツールを使うためのコンテナを作成して、その中でシステム構成を実現していきます。

# システム構築、Kubernetes操作のためのコンテナを作る
これから、Railsアプリを公開するまでにやることは大まかに以下の流れです。

1. Terraformでシステム構成図の通り必要なリソースを作成する
2. ECRにRailsアプリのDockerイメージを登録する
3. EKSにECRに登録したRailsアプリをデプロイする

今回はこの一連のデプロイ作業をするための`deploy`コンテナを作って、その中でデプロイ作業を進めていこうと思います。

## ディレクトリ構造を見直す
現状のホームディレクトリはRailsアプリのソースコードが置かれていますが、新たに`rails/`ディレクトリを作成し一階層したで管理するようにします。そして`rails/`ディレクトリと同じ階層に`deploy/`ディレクトリを作成し、そちらにRailsアプリ以外のデプロイに必要なファイルを作っていくことにします。

```
|-- rails_on_docker_handson
  |-- app/
  |-- bin/
  |-- ...
  |-- Gemfile
  |-- Gemfile.lock
  |-- Dockerfile
  |-- docker-compose.yml
  |-- .git/
  |-- .gitignore
  |-- ...
```
↓

```
|-- rails_on_docker_handson
  |-- docker-compose.yml
  |-- .git/
  |-- .gitignore
  |-- rails/
  | |-- app/
  | |-- bin/
  | |-- ...
  | |-- Gemfile
  | |-- Gemfile.lock
  | |-- Dockerfile
  |
  |-- deploy/
    |-- Dockerfile
    |-- k8s/
    |-- terraform/
```
まずはこのディレクトリ・ファイル作成とファイル移動をやっていきましょう。

```
$ mkdir -p rails/ deploy/k8s/ deploy/terraform/
$ touch deploy/Dockerfile
$ mv `ls -a | egrep -v ".git|README.md|docker-compose.yml|rails|deploy"` rails
```
少し`mv`コマンドで複雑なことしてます。「\`\`」のコマンドの実行結果を使って`mv [実行結果] rails`が実行されます。
「\`\`」で囲まれたコマンドは`|`が間に入っていますが、これは左側の結果に対して右側のコマンドを実行する時に使います。`ls -a`はカレントディレクトリのディレクトリ、ファイルを隠しファイル含めて表示するコマンドです。この結果から`egrep`の`-v`オプションでその後に指定した文字列にマッチしない文字列を実行結果として返しています。
ま、そんなこんなでカレントディレクトリから`.git`、`README.md`、`docker-compose.yml`、`rails`、`deploy`にマッチしないディレクトリやファイルを`rails`ディレクトリに移動しました。

ディレクトリ構造を変更したので、`docker-compose.yml`の`build`と`volumes`の位置を更新します。

```diff:docker-compose.yml
  version: "3"

  services:
    db:
      image: postgres:12.1-alpine
      environment:
        - TZ=Asia/Tokyo
      volumes:
-       - ./tmp/db:/var/lib/postgresql/data
+       - ./rails/tmp/db:/var/lib/postgresql/data

    web:
-     build: .
+     build: rails/
      volumes:
-       - .:/app
+       - ./rails:/app
      ports:
        - 3000:3000
      depends_on:
        - db
      environment:
        - RAILS_SYSTEM_TESTING_SCREENSHOT=inline
```
これでファイルの移動は完了です。念のため、イメージをビルドしなおしてコンテナを起動させてみるといいかもしれません。

```
$ docker-compose build --no-cache web
$ docker-compose up -d
```
エラーなくサイトにアクセスできていたらOKです！

```
$ docker-compose down
```

## デプロイ作業用のコンテナを作る
先にのべたデプロイ手順から、デプロイ作業用のコンテナは以下のことができなければなりません。

1. `terraform`コマンドが使える
2. `aws`コマンドが使える
3. `kubectl`コマンドが使える
3. `docker`コマンドが使える（DockerイメージのビルドとECRへのpushのため）

Dockerコンテナ上で`docker`コマンドを使うため、今回は[Docker in Docker（dind）](https://hub.docker.com/_/docker)のDockerイメージをベースに各コマンドをインストールすることにします。

```dockerfile:deploy/Dockerfile
FROM docker:dind

ENV HOME="/workspace"

WORKDIR ${HOME}

RUN apk update && \
    apk upgrade && \
    # Install terraform
    apk add --no-cache -q terraform && \
    # Install aws cli
    apk add --no-cache -q curl unzip python3 groff && \
    curl -sO https://bootstrap.pypa.io/get-pip.py && \
    python3 get-pip.py && \
    pip3 install awscli --upgrade && \
    rm get-pip.py && \
    # Install kubectl
    curl -s https://amazon-eks.s3-us-west-2.amazonaws.com/1.14.6/2019-08-22/bin/linux/amd64/kubectl -o kubectl && \
    chmod +x ./kubectl && \
    mv kubectl /usr/local/bin
```
`terraform`は`apk add`でインストールしました。
[`aws`](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/install-linux.html)と[`kubectl`](https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/install-kubectl.html)はそれぞれの公式の手順に従ってインストールしています。

`docker-compose.yml`も更新します。

```diff:docker-compose.yml
- version: "3"
+ version: "3.7"
 
  services:
    ...
+   deploy:
+     build: deploy/
+     environment:
+       - AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
+       - AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
+       - AWS_DEFAULT_REGION=ap-northeast-1
+       - KUBECONFIG=/workspace/k8s/.kube/config
+     volumes:
+       - ./rails:/workspace/rails
+       - ./deploy/k8s:/workspace/k8s
+       - ./deploy/terraform:/workspace/terraform
+     privileged: true
```
`privileged: true`オプションをつけることでDockerコンテナの中からローカルのDockerデーモンを使って`docker`コマンドを操作できるようになります。
この`privileged`はdocker-composeのversionが3.4以上じゃないと使えないので、[最新](https://docs.docker.com/compose/compose-file/)の3.7を指定しています。

また、`AWS_ACCESS_KEY_ID`と`AWS_SECRET_ACCESS_KEY`はそれぞれ`$AWS_ACCESS_KEY_ID`と`RAWS_SECRET_ACCESS_KEY`から取得するようにしています。`$`は環境変数を表していて同じディレクトリの`.env`ファイルで定義することができます。
`AWS_ACCESS_KEY_ID`や`AWS_SECRET_ACCESS_KEY`はセンシティブな値なので、`docker-compose.yml`とは別で管理して間違ってGitで公開したりしないようにするのがオススメです。

```
$ touch .env
```

```text:.env
AWS_ACCESS_KEY_ID=[AWS_ACCESS_KEY_IDを記述する]
AWS_SECRET_ACCESS_KEY=[AWS_SECRET_ACCESS_KEYを記述する]
```
とりあえず`AdministratorAccess`権限を持っているIAMがあるといいです。まだ発行していない方は[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/id_credentials_access-keys.html)を参考に作成してください！

それではデプロイ操作コンテナをビルドして、コマンドが使えるようになっているかチェックしておきましょう。

```
$ docker-compose build deploy
$ docker-compose up -d deploy
$ docker-compose exec deploy ash
```

```
# docker -v
Docker version 19.03.8, build afacb8b7f0

# terraform version
Terraform v0.12.17

# aws --version
aws-cli/1.18.36 Python/3.8.2 Linux/4.19.76-linuxkit botocore/1.15.36

# kubectl version
Client Version: version.Info{Major:"1", Minor:"14+", GitVersion:"v1.14.7-eks-1861c5", GitCommit:"1861c597586f84f1498a9f2151c78d8a6bf47814", GitTreeState:"clean", BuildDate:"2019-09-24T22:12:08Z", GoVersion:"go1.12.9", Compiler:"gc", Platform:"linux/amd64"}
The connection to the server localhost:8080 was refused - did you specify the right host or port?

# exit
```
ちょっと`kubectl`が怪しい感じですが今の段階ではクラスターと接続できていないのでエラーっぽい感じの表示がなされます。コマンド自体は使えているので問題ないです。

これでデプロイ作業コンテナの準備ができました。

# Terraformでシステム構築する
最初に提示したシステム構成図をTerraformで実現していきます。

これにはいろいろな記事を参考にさせていただきました。

- [Terraformを使ってEKSを作成してみた | Developers.IO](https://dev.classmethod.jp/articles/terraform-eks-create/)
- [SubhakarKotta/aws-eks-rds-terraform: This setup creates AWS EKS cluster using terraform](https://github.com/SubhakarKotta/aws-eks-rds-terraform)
- [eksctl で VPC を作るのをやめて Terraform で作るようにしました - hatappi.blog](https://blog.hatappi.me/entry/2019/02/17/123111)

そして何よりもTerraformの[公式ドキュメント（AWS）](https://www.terraform.io/docs/providers/aws/index.html)を読みました。

ではではTerraformの定義ファイルを作っていきましょう！

## VPC周りを作る
まずはVPCなどの骨格となるネットワーク構成を作っていきます。

最初に、いろいろなところで共通的に使うことになる変数を定義してみます。

```
$ touch deploy/terraform/variables.tf
```

```tf:deploy/terraform/variables.tf
variable "project" {
  default = "handson"
}

variable "num_subnets" {
  default = 3
}

variable "eks_name" {
  default = "handson-eks"
}
```
このように定義しておくことで他のファイルから`var.project`といった感じで変数を呼び出すことができます。

あと、`provider`を定義しておく必要がある。今回はAWS。

```
$ touch deploy/terraform/provider.tf
```

```tf:deploy/terraform/provider.tf
provider "aws" {
  version = "~> 2.0"
}
```

では、VPC周りの定義ファイルを作成していきます。ここでは[VPC](https://www.terraform.io/docs/providers/aws/r/vpc.html)、[Public Subnet](https://www.terraform.io/docs/providers/aws/r/subnet.html)、[Private Subnet](https://www.terraform.io/docs/providers/aws/r/subnet.html)、[Internet Gateway](https://www.terraform.io/docs/providers/aws/r/internet_gateway.html)、[NAT Gateway](https://www.terraform.io/docs/providers/aws/r/nat_gateway.html)（with [Elastic IP](https://www.terraform.io/docs/providers/aws/r/eip.html)）、[Route Table](https://www.terraform.io/docs/providers/aws/r/route_table.html)、 [Route Table Association](https://www.terraform.io/docs/providers/aws/r/route_table_association.html)を定義していきます。

```
$ touch deploy/terraform/vpc.tf
```

```tf:deploy/terraform/vpc.tf
data "aws_availability_zones" "available" {
  state = "available"
}

##############################
# VPC
##############################
resource "aws_vpc" "vpc" {
  cidr_block = "10.0.0.0/16"
  
  tags = {
    Name = "${var.project}-vpc"
  }
}

##############################
# Subnet
##############################
resource "aws_subnet" "public_subnet" {
  count                   = var.num_subnets
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = data.aws_availability_zones.available.names[ count.index % var.num_subnets ]
  cidr_block              = cidrsubnet(aws_vpc.vpc.cidr_block, 8, count.index)
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project}-public-subnet-${count.index+1}"
    "kubernetes.io/cluster/${var.eks_name}" = "shared"
  }
}

resource "aws_subnet" "private_subnet" {
  count                   = var.num_subnets
  vpc_id                  = aws_vpc.vpc.id
  availability_zone       = data.aws_availability_zones.available.names[ count.index % var.num_subnets ]
  cidr_block              = cidrsubnet(aws_vpc.vpc.cidr_block, 8, var.num_subnets + count.index)
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.project}-private-subnet-${count.index+1}"
    "kubernetes.io/cluster/${var.eks_name}" = "shared"
  }
}

##############################
# Internet Gateway
##############################
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.vpc.id

  tags = {
    Name = "${var.project}-igw"
  }
}

##############################
# Elastic IP
##############################
resource "aws_eip" "nat" {
  vpc = true

  tags = {
    Name = "${var.project}-nat"
  }
}

##############################
# NAT Gateway
##############################
resource "aws_nat_gateway" "nat" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public_subnet.0.id

  tags = {
    Name = "${var.project}-nat"
  }
}

##############################
# Route table
##############################
resource "aws_route_table" "public_rtb" {
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }

  tags = {
    Name = "${var.project}-public-rtb"
  }
}

resource "aws_route_table" "private_rtb" {
  vpc_id = aws_vpc.vpc.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_nat_gateway.nat.id
  }

  tags = {
    Name = "${var.project}-private-rtb"
  }
}

##############################
# Route table association
##############################
resource "aws_route_table_association" "rtba_public" {
  count           = var.num_subnets
  subnet_id       = element(aws_subnet.public_subnet.*.id, count.index)
  route_table_id  = aws_route_table.public_rtb.id
}

resource "aws_route_table_association" "rtba_private" {
  count           = var.num_subnets
  subnet_id       = element(aws_subnet.private_subnet.*.id, count.index)
  route_table_id  = aws_route_table.private_rtb.id
}
```
今回は「こいつ、動くぞ！」を目的にしているので、詳細は公式ドキュメントと見比べてみてください。
１点、`aws_subnet.public_subnet`と`aws_subnet.private_subnet`のタグに`"kubernetes.io/cluster/${var.eks_name}" = "shared"`を入れています。これはEKSの[公式ユーザーガイド](https://docs.aws.amazon.com/eks/latest/userguide/network_reqs.html)に記載があるのですが、EKSがターゲットのサブネットをディスカバリーするために必須のタグです。お忘れなきよう！

これで環境の骨格ができましたので、次にEKSを定義してみます。

## EKSを作る
EKSではマスターコンポーネントが動作するEKSクラスターとノードコンポーネントが動作するノードグループを作る必要があります。
それぞれ、Terraformのドキュメント（[EKS Cluster](https://www.terraform.io/docs/providers/aws/r/eks_cluster.html)、[EKS Node Group](https://www.terraform.io/docs/providers/aws/r/eks_node_group.html)）に従えばさほど難しくありません。

```
$ touch deploy/terraform/eks.tf
```

```tf:deploy/terraform/eks.tf
##############################
# IAM Role for EKS Cluster
##############################
resource "aws_iam_role" "eks_iam_role" {
  name = "eks-iam-role"

  assume_role_policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "eks.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
POLICY
}

resource "aws_iam_role_policy_attachment" "eks-AmazonEKSClusterPolicy" {
  policy_arn  = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
  role        = aws_iam_role.eks_iam_role.name
}

resource "aws_iam_role_policy_attachment" "eks-AmazonEKSServicePolicy" {
  policy_arn  = "arn:aws:iam::aws:policy/AmazonEKSServicePolicy"
  role        = aws_iam_role.eks_iam_role.name
}

##############################
# EKS Cluster
##############################
resource "aws_eks_cluster" "eks" {
  name      = var.eks_name
  role_arn  = aws_iam_role.eks_iam_role.arn

  vpc_config {
    subnet_ids  = concat(aws_subnet.public_subnet.*.id, aws_subnet.private_subnet.*.id)
  }

  depends_on = [
    aws_iam_role_policy_attachment.eks-AmazonEKSClusterPolicy,
    aws_iam_role_policy_attachment.eks-AmazonEKSServicePolicy
  ]
}

##############################
# IAM Role for EKS Node Group
##############################
resource "aws_iam_role" "eks_node_group_iam_role" {
  name = "eks-node-group-iam-role"

  assume_role_policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
POLICY
}

resource "aws_iam_role_policy_attachment" "eks_node_group_AmazonEKSWorkerNodePolicy" {
  policy_arn  = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
  role        = aws_iam_role.eks_node_group_iam_role.name
}

resource "aws_iam_role_policy_attachment" "eks_node_group_AmazonEKS_CNI_Policy" {
  policy_arn  = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
  role        = aws_iam_role.eks_node_group_iam_role.name
}

resource "aws_iam_role_policy_attachment" "eks_node_group_AmazonEC2ContainerRegistryReadOnly" {
  policy_arn  = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
  role        = aws_iam_role.eks_node_group_iam_role.name
}

##############################
# EKS Node Group
##############################
resource "aws_eks_node_group" "eks_ng" {
  cluster_name    = aws_eks_cluster.eks.name
  node_group_name = "eks-ng"
  node_role_arn   = aws_iam_role.eks_node_group_iam_role.arn
  subnet_ids      = aws_subnet.private_subnet.*.id
  instance_types  = ["t2.small"]

  scaling_config {
    desired_size  = 3
    max_size      = 4
    min_size      = 2
  }

  depends_on = [
    aws_iam_role_policy_attachment.eks_node_group_AmazonEKSWorkerNodePolicy,
    aws_iam_role_policy_attachment.eks_node_group_AmazonEKS_CNI_Policy,
    aws_iam_role_policy_attachment.eks_node_group_AmazonEC2ContainerRegistryReadOnly
  ]
}
```
EKSではクラスター、ノードグループそれぞれにIAM Roleを付与してあげる必要があるので少し複雑に見えるかもしれませんが、Terraformに沿って書けばさほど難しくありません。
`aws_eks_node_group.eks_ng.instance_types`でノードグループのインスタンスタイプを指定してます。今回はサンプルですし小さめの`t2.small`。`aws_eks_node_group.eks_ng.scaling_config`ではノードの最小数、最大数を定義しています。これに合わせてEKSがスケーリングしてくれるわけです。ちょっと`desired_size`と`min_size`の関係がわかっていないのですが...基本的に`desired_size`でノードが展開されます。もしリソースが足りなくなったら`max_size`までオートスケールしてくれます。

## RDSを作る
次はRDSです。

```
$ touch deploy/terraform/rds.tf
```

```tf:deploy/terraform/rds.tf
##############################
# Security Group for RDS
##############################
resource "aws_security_group" "rds" {
  vpc_id      = aws_vpc.vpc.id

  ingress {
    protocol        = "tcp"
    from_port       = 5432
    to_port         = 5432
    security_groups = [aws_eks_cluster.eks.vpc_config[0].cluster_security_group_id]
  }

  tags = {
    Name = "sg-${var.project}-rds"
  }
}

##############################
# DB Subnet for RDS
##############################
resource "aws_db_subnet_group" "default" {
  name  = "${var.project}-db-subnet-group"
  subnet_ids = aws_subnet.private_subnet.*.id

  tags = {
    Name = "${var.project}-db-subnet-group"
  }
}

##############################
# RDS
##############################
resource "aws_db_instance" "rds" {
  allocated_storage       = 20
  db_subnet_group_name    = aws_db_subnet_group.default.name
  engine                  = "postgres"
  engine_version          = "12.2"
  instance_class          = "db.t2.micro"
  username                = "handson_user"
  password                = "handson2020"
  port                    = 5432
  vpc_security_group_ids  = [aws_security_group.rds.id]
  skip_final_snapshot     = true

  tags = {
    Name = "${var.project}-db"
  }
}
```
[Security Group](https://www.terraform.io/docs/providers/aws/r/security_group.html)、[DB Subnet](https://www.terraform.io/docs/providers/aws/r/db_subnet_group.html)、[DB Instance](https://www.terraform.io/docs/providers/aws/r/db_instance.html)を定義しています。

Security Groupでは`aws_security_group.rds.ingress.security_groups`でEKSに設定されたセキュリティグループをIngressで許可しており、これを`aws_db_instance.rds.vpc_security_groups_id`でRDSに付与しています。こうすることでEKSのノードグループの上のPodからRDSにアクセスできるようになります。

そう言えばここまでPostgreSQLはversion12.1を使っていましたが、RDSでは12.2しか使えないようです...（参考：[PostgreSQL on Amazon RDS - Amazon Relational Database Service](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_PostgreSQL.html#PostgreSQL.Concepts.General.version12)）
問題はないとは思いますが、念のためテストを回しておきましょう。

```diff:docker-compose.yml
  ...
  db:
-   image: postgres:12.1-alpine
+   image: postgres:12.2-alpine
    ...
```

```
$ docker-compose run web rspec

Finished in 2 minutes 11.5 seconds (files took 18.92 seconds to load)
85 examples, 0 failures
```
OKOK。

## ECRを作る
最後にECRも作成しておきます。

```
$ touch deploy/terraform/ecr.tf
```

```tf:deploy/terraform/ecr.tf
##############################
# ECR for Rails app
##############################
resource "aws_ecr_repository" "ecr" {
  name  = "${var.project}_app"
}
```
これはレポジトリの名前をつけてあげてるだけですね。

## TerraformでAWSリソースを構築する
ここまでで定義ファイルの準備が整いましたので、TerraformでAWSリソースを作成・構築していきます。
まず、再びデプロイ作業用のコンテナに入ります。

```
$ docker-compose exec deploy ash
```

```
# cd terraform
# terraform init
# terraform plan
# terraform apply
```
これだけです。`plan`でファイルから設定するべき項目をプランニングし、`apply`で適用するという感じです。
`apply`の時に「Do you want to perform these actions?」と聞かれますが`yes`と答えましょう。

少し時間がかかりますが、`Apply Complete!`となれば環境構築は完了です！

# RailsアプリをECRに登録する
次に、ECRにRailsアプリのDockerイメージを登録しようと思います。
EKSではproduction環境として動かしますし、RDSに接続できるように設定をできるようにしないといけません。

DBの接続情報は`config/database.yml`で設定していましたね。
ということで、そのファイルで`RAILS_ENV`が`production`の場合は接続情報を環境変数から設定できるようにしてみます。

```diff:rails/config/database.yml
  ...
  production:
    <<: *default
-   database: app_production
-   username: app
+   host: <%= ENV['APP_DATABASE_HOST'] %>
+   database: <%= ENV['APP_DATABASE_DATABASE'] %>
+   username: <%= ENV['APP_DATABASE_USERNAME'] %>
    password: <%= ENV['APP_DATABASE_PASSWORD'] %>
```
これでそれぞれの環境変数から接続情報が設定されるようになります。環境変数の指定はKubernetesのConfigMapを使ってやりますので、また後ほど。

また、Dockerfileもdevelopment環境とproduction環境では実行したいことが異なります。
例えば、development環境ではChromeブラウザがテスト用に必要ですが、production環境にはいりません。また、`bundle install`でインストールしたいgemにも差があります。
このような差分を同じDockerfileでできるように、`docker build`コマンドのオプションで`--build-args`を使って変数を送り込むことで動作を変えることができます。
具体的には、Dockerfileを以下のように更新します。（結構大きく変わるのでコピペしてください。）（参考：[DockerFileにif文(条件分岐) - Qiita](https://qiita.com/t_n/items/203cd3593427a35f7ed7)）

```dockerfile:rails/Dockerfile
ARG BUILD_MODE="dev"
FROM ruby:2.6.5-alpine3.11

ARG BUILD_MODE
ARG PROD_MODE="prod"
ARG RUNTIME_PACKAGES="gcc \
                      g++ \
                      less \
                      libc-dev \
                      libxml2-dev \
                      linux-headers \
                      make \
                      nodejs \
                      postgresql \
                      postgresql-dev \
                      tzdata \
                      yarn"
ARG BUILD_PACKAGES="build-base \
                    curl-dev"
ARG CHROME_PACKAGES="chromium \
                     chromium-chromedriver \
                     dbus \
                     mesa-dri-swrast \
                     ttf-freefont \
                     udev \
                     wait4ports \
                     xorg-server \
                     xvfb \
                     zlib-dev"
                     
ENV HOME="/app"
ENV LANG=C.UTF-8
ENV TZ=Asia/Tokyo

WORKDIR $HOME

RUN apk update && \
    apk upgrade && \
    apk add --no-cache ${RUNTIME_PACKAGES} && \
    apk add --virtual build-packs --no-cache ${BUILD_PACKAGES} && \
    if [ "${BUILD_MODE}" != "${PROD_MODE}" ]; then \
        apk add --no-cache ${CHROME_PACKAGES}; \
    fi

COPY Gemfile ${HOME}
COPY Gemfile.lock ${HOME}

RUN if [ "${BUILD_MODE}" = "${PROD_MODE}" ]; then \
        bundle install --without development test -j4; \
    else \
        bundle install --without production -j4; \
    fi && \
    apk del build-packs

COPY . ${HOME}

RUN if [ "${BUILD_MODE}" = "${PROD_MODE}" ]; then \
        bundle exec rails assets:precompile RAILS_ENV=production; \
    else \
        yarn install; \
    fi

EXPOSE 3000
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]
```
`BUILD_MODE`が`prod`かどうかによって挙動を分けています（デフォルトは`dev`）。`ARG`は変数で、`docker build`の時に`--build-arg [ARG_NAME]=[ARG_VALUE]`で変数を外部から引き渡すこともできるやつです。なので`prod`でビルドしたいときだけ`--build-arg BUILD_MODE=prod`と指定すればproduction用のビルドができるようになります。

さらに、productionには不要なファイルもあります。テストシナリオとか今までのログファイルとかは不要です。これをビルドする時に無視するために`dockerigonore`ファイルを作成して不要なファイルを定義しておきます。

```
$ touch rails/.dockerignore
```

```text:rails/.dockerignore
.local
.pki
log
node_modules
spec
tmp
.rspec
yarn-error.log
```
ざっとみた感じ、この辺りが今のところ不要かなー。

またここまででGitからダウンロードしたりした場合は`config/master.key`がない状態だと思います。これだとproductionでビルドできないので生成します。

```
$ rm rails/config/credentials.yml.enc
$ docker-compose run -e EDITOR="mate --wait" web rails credentials:edit
```
こうすることで`master.key`を再生成することができる。今回は活用していませんが、`credentials`について詳しくはいろいろと参照してください。（参考：[Rails5.2から追加された credentials.yml.enc のキホン - Qiita](https://qiita.com/NaokiIshimura/items/2a179f2ab910992c4d39)）

これで準備が整いました。イメージをビルドしていきます。
イメージをビルドするに際して、イメージ名を決める必要があります。これはECRに作成したリポジトリ名と同一でなくてはなりません。まず、`aws`コマンドを使って名前を確認しておきます。

```
# aws ecr describe-repositories
```
JSON形式で情報が表示されますが、このうち`repositoryUri`がDockerイメージに名付けるべき名前です。またタグは`1.0.0`としておきます。

```
# cd /workspace/rails
# docker build -t [repositoryUri]:1.0.0 --build-arg BUILD_MODE=prod .
```
これで`repositoryUri`の名前をつけて、production環境用にDockerイメージをビルドできました。

次にECRにログインします。

```
# aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin [repositoryUri]

Login Succeeded
```
ログインができたらECRにDockerイメージを登録できるようになるので、今作成したDockerイメージをプッシュします。

```
# docker push [repositoryUri]:1.0.0
```
これでイメージの登録も完了です。

あとはEKS上にこのイメージをベースとしたPodをデプロイしていきます！

# EKSにデプロイする
## 環境変数用のConfigMapを生成する
最初にデプロイするPod（コンテナ）に渡す環境変数を[ConfigMapリソース](https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/)に保存します。

```
$ mkdir deploy/k8s/config
$ touch deploy/k8s/config/rails_config.yaml
```
```yaml:deploy/k8s/rails_config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: rails-config
data:
  RAILS_ENV: production
  RAILS_SERVE_STATIC_FILES: "true"
  APP_DATABASE_HOST: [RDSのエンドポイント名]
  APP_DATABASE_DATABASE: app_production
  APP_DATABASE_USERNAME: handson_user
  APP_DATABASE_PASSWORD: handson2020
```
ConfigMapではこんな感じでデータを保存できるんですね。

`RAILS_ENV`はRailsアプリケーションを`production`モードで起動するための環境変数です。

`RAILS_SERVE_STATIC_FILES`は`assets:precompile`したCSS/JSファイルを`public`ディレクトリから提供するための環境変数。何かしら設定されていれば有効になるので、今回は`true`の文字列を指定。（`RAILS_SERVE_STATIC_FILES`は`config/environments/production.rb`に記述があります。）

```rb:rails/config/environments/production.rb
...
# Disable serving static files from the `/public` folder by default since
# Apache or NGINX already handles this.
config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
...
```

`APP_DATABASE_*`は先ほど、`config/database.yml`にて指定した環境変数です。`USERNAME`、`PASSWORD`は先ほど`terraform/rds.tf`で定義したものです。`DATABASE`はこれから`rails db:create`で作成するものなので、好きな文字列で問題ありません。
`HOST`は先ほどTerraformで作成したRDSのエンドポイント名を設定する必要があります。これも`aws`コマンドで確認してみましょう。

```
# aws rds describe-db-instances
```
またJSON形式で情報がアウトプットされたかと思いますが、このうち`Endpoint.Address`がエンドポイント名ですので、これを設定します。

ConfigMapの宣言は以上です。

## Jobリソースを宣言する
Railsアプリケーションを起動させる前にDBの作成とマイグレーションファイルの適用が必要です。
そのために１度だけ起動してコマンド発行されたら落ちるJobリソースを宣言して、Podをデプロイする前に実行しようと思います。

```
$ mkdir deploy/k8s/settings
$ touch deploy/k8s/settings/set_db_job.yaml
```

```yaml:deploy/k8s/settings/set_db_job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: rails-db-setup
spec:
  template:
    metadata:
      name: rails-db-setup
    spec:
      containers:
      - name: rails-db-setup
        image: [RepositoryUri]:1.0.0
        imagePullPolicy: Always
        command: ["ash"]
        args: ["-c", "bundle exec rails db:create && bundle exec rails db:migrate"]
        envFrom:
        - configMapRef:
            name: rails-config
      restartPolicy: Never
  backoffLimit: 1
```
ファイルの書き方は[公式ドキュメント](https://kubernetes.io/docs/concepts/workloads/controllers/jobs-run-to-completion/)などを参考に。
`spec.template.spec.containers.image`に先ほどECRにプッシュしたRailsアプリのDockerイメージを宣言しています。そのイメージを使って`bundle exec rails db:create && bundle exec rails db:migrate`を実行することでDBの作成とマイグレーションの適用を行おうとしています。
また、`envForm`で先ほど宣言したConfigMap（`rails-config`）から環境変数を読み取っています。そこにはRDSのエンドポイントが`APP_DATABASE_HOST`として定義されているので、RDSに対してDBの作成とマイグレーションの適用がなされることがわかりますね。
`kind`に`Job`を宣言しているので、このリソースはコマンド実行が終わったら自動的に停止状態になります。

## PodをDeploymentする
最後に実際に動作し続けるPodリソースの宣言ファイルを作ります。Podの宣言と言いましたが、KubernetesではPodリソースの数を宣言するReplicaSetリソースのデプロイ戦略を宣言するDeploymentリソースのファイルを適用することでPodをデプロイすることが一般的です。
また、外部と通信するためのServiceリソースも一緒に宣言しちゃいましょう。

```
$ touch deploy/k8s/deployment.yaml
```
```yaml:deploy/k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: rails-deployment
  labels:
    app: rails
spec:
  replicas: 3
  selector:
    matchLabels:
      app: rails
  template:
    metadata:
      labels:
        app: rails
    spec:
      containers:
      - name: rails
        image: [RepositoryUri]:1.0.0
        imagePullPolicy: Always
        ports:
        - containerPort: 3000
        envFrom:
        - configMapRef:
            name: rails-config


---
apiVersion: v1
kind: Service
metadata:
  name: rails-service
spec:
  type: LoadBalancer
  selector:
    app: rails
  ports:
    - protocol: TCP
      port: 80
      targetPort: 3000
```
`---`を挟んでDeploymentリソースとServiceリソースが宣言されているのがわかりますね。

Deploymentリソースの方ではJobリソースと似ていますが、`spec.template.spec.containers.image`でECRのRailsアプリイメージをベースにし、`envForm`で`rails-config`のConfigMapから環境変数を読み取り起動しようとしていることがわかります。
また、`spec.replicas`を`3`にしているので、Podは3つ起動され、起動され続けるようにKubernetesに管理してもらいます。

Serviceリソースの方では、`spec.type`に`LoadBalancer`を宣言しています。これによってKubernetesのノードコンポーネントで外からの通信を許可することができます。
`spec.selector`で`app: rails`を定義していますが、これはDeploymentリソースの`label`を指定しているものです。ポートは`80`ポートを受け取り、`3000`ポートに流していることが読み取れ、Deploymentリソースの方で`containerPort`として`3000`を開けていることも読み取れます。

EKSではServiceリソースでLoadBalancerを作成すると、AWSのNLB（Network Load Balancer）が生成されます。これによって、NLBに付与されるドメインを通じてEKSで稼働しているRailsコンテナのPodにアクセスできるようになるわけです。

ここまででファイルの準備は揃いましたので、EKSに適用していきましょう。

## デプロイ
デプロイするためにはまずデプロイするクラスターを指定する必要があります。`aws`コマンドを使ってこれをやってみます。
Terraformのファイルを見返していただきたいのですが、今EKSクラスターには`handson-eks`という名前がついています。

```tf:deploy/terraform/eks.tf
resource "aws_eks_cluster" "eks" {
  name      = var.eks_name
  ...
}
```

```tf:deploy/terraform/variables.tf
variable "eks_name" {
  default = "handson-eks"
}
```
デプロイするクラスターを指定するためにconfigを更新します。

```
# aws eks update-kubeconfig --name handson-eks
```
これで`docker-compose.yml`で定義した`KUBECONFIG`の場所にconfigファイルが生成されているはずです。

また、これでEKSクラスターを特定できるようになったので、例えば`kubectl get nodes`コマンドでノードグループのインスタンスの状態を確認することができるようになっているはずです。

```
# kubectl get nodes
NAME                                                 STATUS   ROLES    AGE     VERSION
ip-xxx-xxx-xxx-xxx.ap-northeast-1.compute.internal   Ready    <none>   HhMMm   v1.15.10-eks-bac369
ip-xxx-xxx-xxx-xxx.ap-northeast-1.compute.internal   Ready    <none>   3h10m   v1.15.10-eks-bac369
ip-xxx-xxx-xxx-xxx.ap-northeast-1.compute.internal   Ready    <none>   3h10m   v1.15.10-eks-bac369
```

では、ConfigMap -> Job -> Deployment/Serviceの順番で適用していきます。

Kubernetesでは`kubectl apply -f [file_name]`でリソースを適用していくことができます。これはとても統一的でめちゃくちゃ便利です。変更があった場合も同様のコマンドでアップデートをかけることができます（一部をのぞき）。

ちなみに似たような感じで`kubectl delete -f [file_name]`でそのファイルで適用していたリソース（もっと言えばそのファイル内でラベル付されてるリソース）を削除することができます。

```
# kubectl apply -f k8s/config/rails_config.yaml
configmap/rails-config created

# kubectl get configmap
NAME           DATA   AGE
rails-config   6      81s
```
ConfigMapが作成されていることがわかります。もっと詳しく中身をみたい場合は`kubectl describe cm rails-config`でみれます。

次はJobです。

```
# kubectl apply -f k8s/settings/set_db_job.yaml
job.batch/rails-db-setup created

# kubectl get jobs -w
NAME             COMPLETIONS   DURATION   AGE
rails-db-setup   0/1           29s        30s
rails-db-setup   1/1           18s        36s
```
`-w`オプションは変化があった時に表示が更新されるモードです。`Ctrl+C`で抜け出せます。
Jobもコンプリートしたことがわかりますね。

最後はDeploymentとServiceです。

```
# kubectl apply -f k8s/deployment.yaml
deployment.apps/rails-deployment created
service/rails-service created

# kubectl get pods -w
NAME                                READY   STATUS      RESTARTS   AGE
rails-db-setup-lb9cs                0/1     Completed   0          4m27s
rails-deployment-56b4695fdf-2jdgl   1/1     Running     0          2m17s
rails-deployment-56b4695fdf-5dxsf   1/1     Running     0          2m17s
rails-deployment-56b4695fdf-npf7b   1/1     Running     0          2m17s
```
先ほどのJobもPodとして動いていたので`Status: Completed`で残っていますね。他の3つがRailsアプリのPodです。Deploymentで宣言した通り3つ起動していますね。

```
# kubectl get service -w
NAME            TYPE           CLUSTER-IP       EXTERNAL-IP                PORT(S)        AGE
kubernetes      ClusterIP      xxx.xxx.xxx.xxx  <none>                     443/TCP        3h42m
rails-service   LoadBalancer   xxx.xxx.xxx.xxx  xxxxxxxxxx.amazonaws.com   80:31230/TCP   3m47s
```
今回生成したのは`rails-service`の方ですね。`EXTERNAL-IP`に書かれているドメインがNLBに付与されているドメインです。これがRailsアプリのPodに流してくれるはず。アクセスしてみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254216/85376af1-e6a5-007d-1219-9c49fb21d718.png)

ドメインが反映されるまで少し時間がかかりますが、ちょっと待てば今まで作ってきたサイトが表示されました。
独自ドメインやHTTPS化を考えると、他にもいろいろとやらねばならぬことはある（[例えば](https://dev.classmethod.jp/articles/eks-aws-alb-ingress-controller/)）のですが、ひとまずこれで<b>EKS+RDSでRailsアプリを公開することができました！！！！</b>

# まとめ
最後の最後は少し難しめなお題、EKSでアプリをデプロイしてみように挑戦してみましたがいかがだったでしょうか？
実際にサービスとして本番環境で運用をしようとすると、いろいろと足りないところは多いのですが、ひとまず「こいつ、動くぞ！」というところには到達できたんじゃないかと思います。

今回でハンズオンは終わりです。Dockerから始まり、Railsアプリ、TDD、そしてHerokuやEKSでのサービス公開。
一通りのサービス開発の流れを体験して、「あれ、意外と調べながらやったりすればできそうだな。」というような感覚を持っていただけたなら幸いです。そうです。開発は選ばれた物にしかできない魔法ではない。学びです。

直接コーディングやデプロイに関わらないロールだとしても、サービス開発に携わっているならばこういったことを知っていることは確実に優位性になるでしょう！
もしさらなる興味が湧いてきたら、個人開発とかにも挑戦してみましょう！

ここまでお付き合いいただきありがとうございました！

# 後片付け
あ、今日はAWSを使っていろいろやりました。放置しとくとちゃりんちゃりんなのでちゃんと後片付けをしておきましょう。

まず確実にDeployment、SVCは落としておきましょう。ずっと公開されっぱなしになっちゃうので。

```
# kubectl delete -f k8s/deployment.yaml
deployment.apps "rails-deployment" deleted
service "rails-service" deleted

# kubectl delete -f k8s/settings/set_db_job.yaml
job.batch "rails-db-setup" deleted

# kubectl delete -f k8s/config/rails_config.yaml
configmap "rails-config" deleted
```

今回の環境を全部無かったことにする場合はTerraformで削除しちゃいましょう。

```
# cd /workspace/terraform
# terraform destroy
Do you really want to destroy all resources?

...

Destroy complete! Resources: 31 destroyed.
```
これまた`yes`と答えてあげれば削除が始まります。これまた結構時間がかかりますが、跡形もなく消してくれている様子がみて取れます。

# 本日のソースコード
- https://github.com/at946/rails_on_docker_handson/tree/vol.16

# Other Hands-on Links
- https://github.com/at946/rails_on_docker_handson/tree/master