Rails
AWS
docker
kubernetes
eks

Amazon EKSでRails API アプリケーションを動かす

概要

巷で話題のAmazon EKSを使ってRailsアプリを動かしてみました。
チュートリアルに毛が生えたくらいの内容です。

EKS とは

AWSでやってるkubernetesのマネージドサービス。
以下のリージョンで利用可能。(2018.06.18現在)

  • 米国西部 (オレゴン) (us-west-2)
  • 米国東部 (バージニア北部) (us-east-1)

載せるアプリ

  • Rails : 5.2.0
  • Ruby : 2.5.1
  • DB : PostgreSQL

今回、DBにはRDSを使用した。

しかし、ストレージクラスというものを使うとkubernetes上でストレージを用意できるっぽい。
これを使うとDBのような永続化が必要なサービスも動かすことができる。

必要なもの

  • バージョン1.15.32以降のAWSCLI
    • Amazon EKSを触るのに必要
    • brew版は 1.15.32以降 の要件が満たせず、pipで再度インストールした(2018.06.18現在)
  • kubectl
    • Kubernetesクラスタに対してコマンドを実行するためのCLI
    • Homebrewでインストールした
  • heptio-authenticator-aws
    • IAMの情報を使ってKubernetesクラスタへの認証を行うためのツール
    • go get でgithubからインストールした

リソースの作成

ベースにした構成

こちらのcfn.ymlパクった 参考にした。
https://github.com/y13i/aws-eks-example

cfn.ymlを拝借し、カスタマイズして使用した。
その節はお世話になりました。ありがとうございました。

このファイルは、EKSの利用に必要なリソースすべてをひとつのCloudFormationスタックで作成するようにしていて、さらにネットワーク構成をあるあるな形にしたものになっている。

ざっくりこんな感じ。

  • VPC
    • パブリックサブネット*2
    • プライベートサブネット*2
  • プライベートサブネット内にアプリ用のインスタンス設置
  • EKSクラスタの作成

カスタマイズ的な部分

Railsアプリを動かすのに、さらに以下のものが必要だったためyamlに追記した

  • RDS
    • プライベートサブネット内に設置
  • ECRのリポジトリ作成
  • S3の設定

Parameters: の追加分

  DBUsername:
    Type: String
  DBPassword:
    Type: String
    NoEcho: true

Resources: の追加分

  VPCEndpointForS3:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      RouteTableIds:
      - Ref: PublicRouteTable
      - Ref: PrivateRouteTable0
      - Ref: PrivateRouteTable1
      VpcId:
        Ref: Vpc
      ServiceName:
        Fn::Join:
        - "."
        - - com
          - amazonaws
          - Ref: AWS::Region
          - s3
  DBSubnetGroup:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription:
        Ref: AWS::StackName
      SubnetIds:
      - Ref: PrivateSubnet0
      - Ref: PrivateSubnet1
      Tags:
      - Key: Name
        Value:
          Ref: AWS::StackName
  DBParameterGroup:
    Type: AWS::RDS::DBParameterGroup
    Properties:
      Family: postgres9.6
      Description:
        Ref: AWS::StackName
      Parameters:
        client_encoding: UTF8
        timezone: Asia/Tokyo
  DBInstance:
    Type: "AWS::RDS::DBInstance"
    Properties:
      AllocatedStorage: 20
      DBInstanceClass: db.t2.micro
      DBName:
        Ref: AWS::StackName
      DBParameterGroupName:
        Ref: DBParameterGroup
      DBSubnetGroupName:
        Ref: DBSubnetGroup
      Engine: postgres
      EngineVersion: "9.6.6"
      MasterUsername:
        Ref: DBUsername
      MasterUserPassword:
        Ref: DBPassword
      MultiAZ: true
      PubliclyAccessible: false
      StorageType: gp2
      Tags:
      - Key: Name
        Value:
          Ref: AWS::StackName
      VPCSecurityGroups:
      - Fn::GetAtt:
        - DBSecurityGroup
        - GroupId
  DBSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: for rds
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 5432
          ToPort: 5432
          SourceSecurityGroupId:
            Fn::GetAtt:
            - NodeSecurityGroup
            - GroupId
      VpcId:
        Ref: Vpc
      Tags:
      - Key: Name
        Value:
          Ref: AWS::StackName
  ContainerRepository:
    Type: AWS::ECR::Repository
  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      RetentionInDays: 365
      LogGroupName:
        Ref: AWS::StackName
  StorageBucket:
    Type: AWS::S3::Bucket
  StorageUser:
    Type: AWS::IAM::User
    Properties:
      ManagedPolicyArns:
      - Ref: StorageUserPolicy
  StorageAccessKey:
    Type: AWS::IAM::AccessKey
    Properties:
      UserName:
        Ref: StorageUser
  StorageUserPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
        - Effect: Allow
          Action: s3:*
          Resource:
          - Fn::GetAtt:
            - StorageBucket
            - Arn
          - Fn::Join:
            - ""
            - - Fn::GetAtt:
                - StorageBucket
                - Arn
              - /*
              - 

Outputs: の追加分


  ContainerRepository:
    Value:
      Ref: ContainerRepository
  DBHostName:
    Value:
      Fn::GetAtt:
        - DBInstance
        - Endpoint.Address
  DBPort:
    Value:
      Fn::GetAtt:
        - DBInstance
        - Endpoint.Port

準備ができたのでデプロイする

ディレクトリ構造はこんな感じ。

api/
 ├ app/
 ├ config/
 │ .
 │ .
 │ .
 ├ infra/
 │ └ cfn.yml
 └ Dockerfile

apiディレクトリにてコマンド実行
※完了まで15〜20分くらい

$ aws cloudformation deploy --template-file infra/cfn.yml --capabilities CAPABILITY_IAM --stack-name development --parameter-overrides DBUsername=app DBPassword=hogehoge

デプロイが完了したら、マネジメントコンソールから
CloudFormation > スタックを選択 > [出力]タブを選択 し、出力を確認する。

ss1.png

EKSクラスタにアクセスするため、認証まわりの設定

.kube/ 作成

$ mkdir -p .kube/

kubeconfigファイル 作成

$ touch ./.kube/k8s-config.yml
.kube/k8s-config.yml
apiVersion: v1
clusters:
- cluster:
    server: <CFnで出力された EKSClusterEndpoint>
    certificate-authority-data: <CFnで出力された EKSClusterCertificateAuthorityData>
  name: kubernetes
contexts:
- context:
    cluster: kubernetes
    user: aws
  name: aws
current-context: aws
kind: Config
preferences: {}
users:
- name: aws
  user:
    exec:
      apiVersion: client.authentication.k8s.io/v1alpha1
      command: heptio-authenticator-aws
      args:
        - "token"
        - "-i"
        - <CFnで出力された EKSClusterName>

環境変数KUBECONFIGの設定

kubectl コマンド実行時にk8s-config.ymlを参照するようパスを追加する。

direnv なり .bash_profile なりに追記。

export KUBECONFIG=<k8s-config.ymlへのパス>

クラスタにアクセスしてみる

$ kubectl get all
NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
service/kubernetes   ClusterIP   xxx.xx.x.x   <none>        443/TCP   8m

command: heptio-authenticator-aws 関連でエラーが出る場合は、 k8s-config.yml からheptio-authenticator-awsまでの相対パスに書き換えてください

例) ../../../go/bin/heptio-authenticator-aws

ワーカノードの設定

kubernetesの設定ファイル置き場を用意

$ mkdir ./kubernetes/

設定ファイルの作成

$ touch kubernetes/aws-auth-configmap.yml
kubernetes/aws-auth-configmap.yml
apiVersion: v1
kind: ConfigMap
metadata:
  name: aws-auth
  namespace: kube-system
data:
  mapRoles: |
    - rolearn: <CFnで出力された NodeInstanceRole>
      username: system:node:{{EC2PrivateDNSName}}
      groups:
        - system:bootstrappers
        - system:nodes

クラスタにnodeを参加させる

$ kubectl apply -f kubernetes/aws-auth-configmap.yml
configmap "aws-auth" created

node一覧を取得し、確認してみる

$ kubectl get nodes
NAME                          STATUS    ROLES     AGE       VERSION
ip-XX-X-XX-XXX.ec2.internal   Ready     <none>    1m        v1.10.3
ip-XX-X-XX-XX.ec2.internal    Ready     <none>    51s       v1.10.3

STATUSがReadyになっていなかったらReadyになるまで温かく見守る

$ kubectl get nodes --watch

環境変数設定

direnv でも .bash_profile でも好きなのにどうぞ。

export AWS_ACCOUNT_ID=xxxxx
export AWS_DEFAULT_REGION=us-east-1
export AWS_REGION=us-east-1
export AWS_ACCESS_KEY_ID=xxxxx
export AWS_SECRET_ACCESS_KEY=xxxxxx
export CONTAINER_REPOSITORY=<CFnで出力された ContainerRepository>
export CONTAINER_IMAGE_TAG=<適当なタグ>

ECRへログイン、イメージのbuild、タグ付け、プッシュ

$ $(aws ecr get-login --no-include-email)
$ docker build -t "$CONTAINER_REPOSITORY:$CONTAINER_IMAGE_TAG" .
$ docker tag "$CONTAINER_REPOSITORY:$CONTAINER_IMAGE_TAG" "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$CONTAINER_REPOSITORY:$CONTAINER_IMAGE_TAG"
$ docker push "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$CONTAINER_REPOSITORY:$CONTAINER_IMAGE_TAG"

kubernetesのマニフェスト作成

secretの作成

Podで環境変数を使うために用意してる。

$ touch kubernetes/eks-secret.yml
kubernetes/eks-secret.yml
apiVersion: v1
kind: Secret
metadata:
  name: eks-secret
type: Opaque
data:
  HOGE: aG9nZQ==

dataKEY : VALUE のペアで、VALUEはbase64エンコードした値。

以下のようにコマンドで生成したものをコピペしてる。

$ echo -n "hoge" | base64
aG9nZQ==

この場合、Podでは値が hoge である環境変数 HOGE が使える状態になる。

作成したマニフェストをクラスタに適用する。

$ kubectl create -f kubernetes/eks-secret.yml

deploymentの作成

Pods と ReplicaSets の設定がまとめられたもの。

$ touch kubernetes/eks-deployment.yml

ECRのイメージ情報を取得しておく

$ echo "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$CONTAINER_REPOSITORY:$CONTAINER_IMAGE_TAG"
xxxxx.dkr.ecr.us-east-1.amazonaws.com/xxxxx:xxxxx

取得したイメージ情報を image: につっこむ

URLからタグまでフルで指定する必要がある。(Using AWS EC2 Container Registry

kubernetes/eks-deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: eks-deployment
spec:
  replicas: 3
  selector:
    matchLabels:
      app: rails-api
  template:
    metadata:
      labels:
        app: rails-api
    spec:
      containers:
      - name: rails-api
        image: xxxxx.dkr.ecr.us-east-1.amazonaws.com/xxxxx:xxxxx
        ports:
        - containerPort: 3000
        envFrom:
        - secretRef:
            name: eks-secret
        args:
          - rails
          - server
          - -p
          - '3000'
          - -b
          - '0.0.0.0'

これは以下のような意味を持つ。

  • xxxxx.dkr.ecr.us-east-1.amazonaws.com/xxxxx:xxxxx イメージをもとにしたコンテナが入ったPodを作る。
  • そのPodにラベル app=rails-api を付与する。
  • ラベル app=rails-api がついたPodが3つになるようレプリケーションする。
  • Podは外部通信用に3000ポートを開ける。
  • コンテナ作成時に rails server -p '3000' -b '0.0.0.0' を実行する。

作成したマニフェストをクラスタに適用する。

$ kubectl create -f kubernetes/eks-deployment.yml

serviceの作成

Podを置いてアプリが動くのは良いとして、そのアプリにどうやってアクセスするの? を解決するのがservice。

ユーザーからのリクエストを処理するのにこいつが必要。

$ touch kubernetes/eks-service.yml
kubernetes/eks-service.yml
kind: Service
apiVersion: v1
metadata:
  name: eks-service
spec:
  type: LoadBalancer
  selector:
    app: rails-api
  ports:
  - protocol: TCP
    port: 80
    targetPort: 3000

これは以下のような意味をもつ。

  • 80ポートへアクセスがあったとき、ラベルが app=rails-api のPodの3000ポートにつなぐ

作成したマニフェストをクラスタに適用する。

$ kubectl create -f kubernetes/eks-service.yml

jobの作成

アプリのように常に動かしたいものではなく、なにかを実行したら消えて欲しいものにはjobを使う。

例えばRailsの場合、 rails db:create とか rails db:migrate みたいなのは一度だけ実行したいものだと思う。

そのためにjobを用意した。

$ touch kubernetes/eks-create-job.yml
kubernetes/eks-create-job.yml
apiVersion: batch/v1
kind: Job
metadata:
  name: eks-create-job
spec:
  backoffLimit: 3
  parallelism: 1
  completions: 1
  template:
    spec:
      containers:
      - name: rails-create
        image: xxxxx.dkr.ecr.us-east-1.amazonaws.com/xxxxx:xxxxx
        ports:
        - containerPort: 3000
        envFrom:
        - secretRef:
            name: eks-secret
        args:
          - rails
          - db:create
      restartPolicy: Never

Podの部分はdeploymentと同じで、arg(=実行するコマンド)が違う。

意味としては

  • xxxxx.dkr.ecr.us-east-1.amazonaws.com/xxxxx:xxxxx イメージをもとにしたコンテナが入ったPodを作る。
  • Podは外部通信用に3000ポートを開ける。
  • コンテナ作成時に rails db:create を実行する。
  • 実行が失敗したら3回までリトライする。
  • Podは最大1つ実行する。
  • 1つのPodの処理が成功したらjobの成功とする。
  • コンテナの再起動は行わない。

作成したマニフェストをクラスタに適用する。

$ kubectl create -f kubernetes/eks-create-job.yml

migrateのほうも同様です。

$ touch kubernetes/eks-migrate-job.yml
kubernetes/eks-migrate-job.yml
apiVersion: batch/v1
kind: Job
metadata:
  name: eks-migrate-job
spec:
  backoffLimit: 3
  parallelism: 1
  completions: 1
  template:
    spec:
      containers:
      - name: rails-migrate
        image: xxxxx.dkr.ecr.us-east-1.amazonaws.com/xxxxx:xxxxx
        ports:
        - containerPort: 3000
        envFrom:
        - secretRef:
            name: eks-secret
        args:
          - rails
          - db:migrate
      restartPolicy: Never

作成したマニフェストをクラスタに適用する。

$ kubectl create -f kubernetes/eks-migrate-job.yml

アプリにアクセスする

serviceを作成すると、EKSによって自動でロードバランサが作成される。

そこにアクセスしたいので、確認するために以下のコマンドを実行する。

$ kubectl describe services

その中から以下の項目を見つける。

LoadBalancer Ingress: xxxxxxxxxx.us-east-1.elb.amazonaws.com

xxxxxxxxxx.us-east-1.elb.amazonaws.com がエンドポイントとなる。

ブラウザやPostmanなんかでアクセス可能になる。

参考URL

Deploy a Kubernetes Application with Amazon Elastic Container Service for Kubernetes