はじめに
こんにちは!今回でラストです!
今回は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 SwarmやMesosphereなどがありましたが、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アプリケーションをデプロイしてみましょう。
システム構成
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アプリを公開するまでにやることは大まかに以下の流れです。
- Terraformでシステム構成図の通り必要なリソースを作成する
- ECRにRailsアプリのDockerイメージを登録する
- 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
の位置を更新します。
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
デプロイ作業用のコンテナを作る
先にのべたデプロイ手順から、デプロイ作業用のコンテナは以下のことができなければなりません。
-
terraform
コマンドが使える -
aws
コマンドが使える -
kubectl
コマンドが使える -
docker
コマンドが使える(DockerイメージのビルドとECRへのpushのため)
Dockerコンテナ上でdocker
コマンドを使うため、今回はDocker in Docker(dind)のDockerイメージをベースに各コマンドをインストールすることにします。
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
とkubectl
はそれぞれの公式の手順に従ってインストールしています。
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_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
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を使ってEKSを作成してみた | Developers.IO
- SubhakarKotta/aws-eks-rds-terraform: This setup creates AWS EKS cluster using terraform
- eksctl で VPC を作るのをやめて Terraform で作るようにしました - hatappi.blog
そして何よりもTerraformの公式ドキュメント(AWS)を読みました。
ではではTerraformの定義ファイルを作っていきましょう!
VPC周りを作る
まずはVPCなどの骨格となるネットワーク構成を作っていきます。
最初に、いろいろなところで共通的に使うことになる変数を定義してみます。
$ touch 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
provider "aws" {
version = "~> 2.0"
}
では、VPC周りの定義ファイルを作成していきます。ここではVPC、Public Subnet、Private Subnet、Internet Gateway、NAT Gateway(with Elastic IP)、Route Table、 Route Table Associationを定義していきます。
$ touch 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_subnet
とaws_subnet.private_subnet
のタグに"kubernetes.io/cluster/${var.eks_name}" = "shared"
を入れています。これはEKSの公式ユーザーガイドに記載があるのですが、EKSがターゲットのサブネットをディスカバリーするために必須のタグです。お忘れなきよう!
これで環境の骨格ができましたので、次にEKSを定義してみます。
EKSを作る
EKSではマスターコンポーネントが動作するEKSクラスターとノードコンポーネントが動作するノードグループを作る必要があります。
それぞれ、Terraformのドキュメント(EKS Cluster、EKS Node Group)に従えばさほど難しくありません。
$ touch 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
##############################
# 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、DB Subnet、DB 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)
問題はないとは思いますが、念のためテストを回しておきましょう。
...
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
##############################
# 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
の場合は接続情報を環境変数から設定できるようにしてみます。
...
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)
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
.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
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
に記述があります。)
...
# 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の作成とマイグレーションファイルの適用が必要です。
そのために1度だけ起動してコマンド発行されたら落ちるJobリソースを宣言して、Podをデプロイする前に実行しようと思います。
$ mkdir deploy/k8s/settings
$ touch 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の作成とマイグレーションの適用がなされることがわかりますね。
kind
にJob
を宣言しているので、このリソースはコマンド実行が終わったら自動的に停止状態になります。
PodをDeploymentする
最後に実際に動作し続けるPodリソースの宣言ファイルを作ります。Podの宣言と言いましたが、KubernetesではPodリソースの数を宣言するReplicaSetリソースのデプロイ戦略を宣言するDeploymentリソースのファイルを適用することでPodをデプロイすることが一般的です。
また、外部と通信するためのServiceリソースも一緒に宣言しちゃいましょう。
$ touch 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
という名前がついています。
resource "aws_eks_cluster" "eks" {
name = var.eks_name
...
}
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に流してくれるはず。アクセスしてみましょう。
ドメインが反映されるまで少し時間がかかりますが、ちょっと待てば今まで作ってきたサイトが表示されました。
独自ドメインや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
と答えてあげれば削除が始まります。これまた結構時間がかかりますが、跡形もなく消してくれている様子がみて取れます。