8
2

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-04-21

はじめに

こんにちは!今回でラストです!

今回はAWSのマネージドKubernetesサービスであるElastic Kubernetes Service(EKS)にデプロイしてみたいと思います。
今まで作ってきたRailsアプリコンテナをEKSで動かし、DBは同じくAWSのマネージドRDBサービスのRelational Database Service(RDS)を使います。
インフラ構築にはInfrastructure as CodeツールのTerraformを使ってみます。

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

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

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

前回のソースコード

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

Kubernetes

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

コンテナオーケストレーションツールとしては、Kubernetesの他にもDocker SwarmMesosphereなどがありましたが、2020年現在、事実上Kubernetesがデファクトスタンダードとなっています。(参考:国内でDockerコンテナを本番利用している企業は9.2%、コンテナオーケストレーションツールはKubernetesがデファクト - ITmedia NEWS

マネージドKubernetes

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

そこで大手クラウドベンダーはマネージドKubernetesサービスとして、利用者は主にノードコンポーネントの一部(インスタンス数とかボリュームとか)だけに関心を持っていればKubernetesを使えるサービスを展開しています。
AWSであればElastic Kubernetes Service(EKS)、GCPであればGoogle Kubernetes Engine(GKE)、Microsoft AzureであればAzure Kubernetes Service(AKS)です。

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

システム構成

network_diagram.png
Diagramsで描いてみました。(参考:Diagrams on Dockerでシステム構成図を書いてみた - Qiita

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

このシステム構成を構築し、EKSでPodをデプロイしていくために、Infrastructure as CodeツールのTerraform、AWSリソースをコマンドラインで操作するためのAWS CLI、Kubernetesを操作するためのkubectlを使います。
まずは、これらのツールを使うためのコンテナを作成して、その中でシステム構成を実現していきます。

システム構築、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オプションでその後に指定した文字列にマッチしない文字列を実行結果として返しています。
ま、そんなこんなでカレントディレクトリから.gitREADME.mddocker-compose.ymlrailsdeployにマッチしないディレクトリやファイルをrailsディレクトリに移動しました。

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

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コマンドが使える
  4. dockerコマンドが使える(DockerイメージのビルドとECRへのpushのため)

Dockerコンテナ上でdockerコマンドを使うため、今回はDocker in Docker(dind)のDockerイメージをベースに各コマンドをインストールすることにします。

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

terraformapk addでインストールしました。
awskubectlはそれぞれの公式の手順に従ってインストールしています。

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

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以上じゃないと使えないので、最新の3.7を指定しています。

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

$ touch .env
.env
AWS_ACCESS_KEY_ID=[AWS_ACCESS_KEY_IDを記述する]
AWS_SECRET_ACCESS_KEY=[AWS_SECRET_ACCESS_KEYを記述する]

とりあえずAdministratorAccess権限を持っているIAMがあるといいです。まだ発行していない方は公式ドキュメントを参考に作成してください!

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

$ 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の公式ドキュメント(AWS)を読みました。

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

VPC周りを作る

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

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

$ touch deploy/terraform/variables.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
deploy/terraform/provider.tf
provider "aws" {
  version = "~> 2.0"
}

では、VPC周りの定義ファイルを作成していきます。ここではVPCPublic SubnetPrivate SubnetInternet GatewayNAT Gateway(with Elastic IP)、Route TableRoute Table Associationを定義していきます。

$ touch deploy/terraform/vpc.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
}

今回は「こいつ、動くぞ!」を目的にしているので、詳細は公式ドキュメントと見比べてみてください。
1点、aws_subnet.public_subnetaws_subnet.private_subnetのタグに"kubernetes.io/cluster/${var.eks_name}" = "shared"を入れています。これはEKSの公式ユーザーガイドに記載があるのですが、EKSがターゲットのサブネットをディスカバリーするために必須のタグです。お忘れなきよう!

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

EKSを作る

EKSではマスターコンポーネントが動作するEKSクラスターとノードコンポーネントが動作するノードグループを作る必要があります。
それぞれ、Terraformのドキュメント(EKS ClusterEKS Node Group)に従えばさほど難しくありません。

$ touch deploy/terraform/eks.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.smallaws_eks_node_group.eks_ng.scaling_configではノードの最小数、最大数を定義しています。これに合わせてEKSがスケーリングしてくれるわけです。ちょっとdesired_sizemin_sizeの関係がわかっていないのですが...基本的にdesired_sizeでノードが展開されます。もしリソースが足りなくなったらmax_sizeまでオートスケールしてくれます。

RDSを作る

次はRDSです。

$ touch deploy/terraform/rds.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 GroupDB SubnetDB Instanceを定義しています。

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
問題はないとは思いますが、念のためテストを回しておきましょう。

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
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_ENVproductionの場合は接続情報を環境変数から設定できるようにしてみます。

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

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_MODEprodかどうかによって挙動を分けています(デフォルトはdev)。ARGは変数で、docker buildの時に--build-arg [ARG_NAME]=[ARG_VALUE]で変数を外部から引き渡すこともできるやつです。なのでprodでビルドしたいときだけ--build-arg BUILD_MODE=prodと指定すればproduction用のビルドができるようになります。

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

$ touch rails/.dockerignore
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

これで準備が整いました。イメージをビルドしていきます。
イメージをビルドするに際して、イメージ名を決める必要があります。これは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リソースに保存します。

$ mkdir deploy/k8s/config
$ touch deploy/k8s/config/rails_config.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_FILESassets:precompileしたCSS/JSファイルをpublicディレクトリから提供するための環境変数。何かしら設定されていれば有効になるので、今回はtrueの文字列を指定。(RAILS_SERVE_STATIC_FILESconfig/environments/production.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にて指定した環境変数です。USERNAMEPASSWORDは先ほどterraform/rds.tfで定義したものです。DATABASEはこれからrails db:createで作成するものなので、好きな文字列で問題ありません。
HOSTは先ほどTerraformで作成したRDSのエンドポイント名を設定する必要があります。これもawsコマンドで確認してみましょう。

# aws rds describe-db-instances

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

ConfigMapの宣言は以上です。

Jobリソースを宣言する

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

$ mkdir deploy/k8s/settings
$ touch deploy/k8s/settings/set_db_job.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

ファイルの書き方は公式ドキュメントなどを参考に。
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の作成とマイグレーションの適用がなされることがわかりますね。
kindJobを宣言しているので、このリソースはコマンド実行が終わったら自動的に停止状態になります。

PodをDeploymentする

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

$ touch deploy/k8s/deployment.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アプリイメージをベースにし、envFormrails-configのConfigMapから環境変数を読み取り起動しようとしていることがわかります。
また、spec.replicas3にしているので、Podは3つ起動され、起動され続けるようにKubernetesに管理してもらいます。

Serviceリソースの方では、spec.typeLoadBalancerを宣言しています。これによってKubernetesのノードコンポーネントで外からの通信を許可することができます。
spec.selectorapp: 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という名前がついています。

deploy/terraform/eks.tf
resource "aws_eks_cluster" "eks" {
  name      = var.eks_name
  ...
}
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化を考えると、他にもいろいろとやらねばならぬことはある(例えば)のですが、ひとまずこれでEKS+RDSでRailsアプリを公開することができました!!!!

まとめ

最後の最後は少し難しめなお題、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と答えてあげれば削除が始まります。これまた結構時間がかかりますが、跡形もなく消してくれている様子がみて取れます。

本日のソースコード

Other Hands-on Links

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2