AWS SAM/CircleCI/LocalStackを利用した実践的なCI/CD

はじめに. こんにちは、中山です。 このエントリはServerless Advent Calendar 2017 10日目の記事です。 今回はAWS SAM/CircleCI/LocalStackを利用した 実践的 なCI/CDをご紹介したいと思います。 実践的 とは、もし私が新規でサーバーレスアプリケーションを構築するのであればこういったCI/CD環境を整えるという意味 … 続きを読む

AWS LambdaをTest Runnerとして使ってみる

はじめに

Serverless Advent Calendar 2017 の6日目です。

サーバーレスの技術を使うとWeb APIの開発が非常にお手軽になりました。
ところで質問。

ちゃんとテスト書いてますか??

この記事では、サーバーレスで作ったWeb API (別にサーバーレスで作ってなくてもいいけど) のAPIテストをサーバーレスでやっちゃおう、という小ネタです。 実運用ができるかどうかはやってみないとわかりません。

Web APIのテストって?

デプロイ後のWeb API、皆さんはどのようにテストされていますか?

  • curlなどを使ってShellScrpitでテスト組む?
  • いわゆるxUnit系のテストフレームワークを使ってテストする?
  • 何かしらのSaaSを使ってテスト?
  • Rest Client (Postmanなど) を使って手動でテスト^^;

などでしょうか?

ここで提案です。

サーバーレスでやればいいんじゃね??

AWS Lambdaをテストランナーとして使う

概要

Nodeでやります。

JavaScriptのテストフレームワークは、MochaやJasmine、Jestなどが有名です。最近だとAVAが流行っていますかね。 これらは基本的にはLocal環境でテストしたり、CI/CD環境でテストをまわすのに利用されます。

が、今回は上記のようなテストフレームワークは一切使わず、Lambdaでテストを書いてみます。

テスト対象のAPI

すごーくシンプルな、下記のようなCRUDなAPIを想定します。

API description
POST /users ユーザリソース作成
GET /users/{userId} ユーザリソース取得
PUT /users/{userId} ユーザリソース更新
DELETE /users/{userId} ユーザリソース削除

テストを書く!

テストランナーはLambdaにするとしても、Web APIをたたいたり、Assertionなどは外部パッケージに頼ります。
とりあえず定番の supertestshould を使います。

コードの内容としては以下のようなものになっています。

  1. POST /users で34歳のTaroというユーザを作成し、
  2. GET /users/{userId} でユーザ情報を参照し、
  3. PUT /usert/{userId} でTaro -> Hanakoに改名し、
  4. DELETE /users/{userId} でユーザを削除する
const Supertest = require('supertest');
const should = require('should');

const agent = Supertest.agent('https://your-api-host/v1');

function testShouldPostUserSucceeds() {
  const user = {
    name: 'Taro',
    age: 34
  };

  return new Promise((resolve) => {
    agent.post('/users')
      .set('Accept', 'application/json')
      .send(user)
      .expect((res) => {
        res.status.should.equal(200);
        res.body.should.hasOwnProperty('userId');
        res.body.name.should.equal('Taro');
        res.body.age.should.equal(34);
      }).end((err, res) => {
        if (err) {
          console.log('[Failed] testShouldPostUserSucceeds');
          throw err;
        }
        console.log('[Passed] testShouldPostUserSucceeds');
        resolve(res.body.userId);
      });
  });
}

function testShouldGetUserSucceeds(userId) {
  return new Promise((resolve) => {
    agent.get(`/users/${userId}`)
      .set('Accept', 'application/json')
      .expect((res) => {
        res.status.should.equal(200);
        res.body.userId.should.equal(userId);
        res.body.name.should.equal('Taro');
        res.body.age.should.equal(34);
      }).end((err, res) => {
        if (err) {
          console.log('[Failed] testShouldGetUserSucceeds');
          throw err;
        }
        console.log('[Passed] testShouldGetUserSucceeds');
        resolve(res.body.userId);
      });
  });
}

function testShouldPutUserSucceeds(userId) {
  const user = {
    name: 'Hanako',
    age: 34
  }

  return new Promise((resolve) => {
    agent.put(`/users/${userId}`)
      .set('Accept', 'application/json')
      .send(user)
      .expect((res) => {
        res.status.should.equal(200);
        res.body.userId.should.equal(userId);
        res.body.name.should.equal('Hanako');
        res.body.age.should.equal(34);
      })
      .end((err, res) => {
        if (err) {
          console.log('[Failed] testShouldPutUserSucceeds');
          throw err;
        }
        console.log('[Passed] testShouldPutUserSucceeds');
        resolve(res.body.userId);
      });
  });
}

function testShouldDeleteUserSucceeds(userId) {
  return new Promise((resolve) => {
    agent.delete(`/users/${userId}`)
      .set('Accept', 'application/json')
      .expect((res) => {
        res.status.should.equal(200);
        res.body.userId.should.equal(userId);
      }).end((err, res) => {
        if (err) {
          console.log('[Failed] testShouldDeleteUserSucceeds');
          throw err;
        }
        console.log('[Passed] testShouldDeleteUserSucceeds');
        resolve();
      });
  });
}

exports.handler = (event, context) => testShouldPostUserSucceeds()
  .then(userId => testShouldGetUserSucceeds(userId))
  .then(userId => testShouldPutUserSucceeds(userId))
  .then(userId => testShouldDeleteUserSucceeds(userId))
  .then(() => {
    context.succeed('Test Passed');
  }).catch(() => {
    context.fail('Test Failed');
  });
  • Node 6.x でTranspile無しで動くように書いています。
  • ESLintにはめちゃくちゃ怒られる書き方になっているのでご注意を。
  • Supertestなどの説明は省いているので他の記事をご参照ください。

これをLambdaで実行するとどうなるか?

テスト成功時

上記のコードをLambdaで実行し、テストに全部成功すると、例えばAWSのConsoleで確認すると以下の図のようになります。

1-ok-min.png

テスト失敗時

一方で、 testShouldPutUserSucceeds をちょっといじって、Taro -> Hanakoの改名に失敗するような、Failするテストに変更してみると、

  res.status.should.equal(200);
  res.body.userId.should.equal(userId);
//  res.body.name.should.equal('Hanako');
  res.body.name.should.equal('Taro');
  res.body.age.should.equal(34);

下記の図のように、失敗したテストがLambdaの実行ログでわかるのです!!

2-ng-min.png

result.png

所感

これはいったい誰トクなのか?

たぶん下記のようなメリットがあるはず。

  • CloudWatch Eventsなどから定期実行できる
  • 定期的に動かせば、APIの死活監視にも使える
  • Lambdaの実行結果をとりあえずSNSにでも飛ばしておけば、メールだったりWebhookでどっかにポストしたり、通知の柔軟性が高い

ただ、冒頭に書いた通り、本当にこんなものが必要なのかはよくわからない。
もし気が向いたら (需要がありそうなら) フレームワーク化してOSS化を検討したいところ。

続きを読む

GitLabのgitlab-runner autoscalingをaws上でdocker-machineしてみる

概要

GitLab Runner 1.1 with Autoscalingによると、gitlab-runner自身もスケールできるよ、と。

runnerの環境にいろいろ入れるのは嫌だし、runnnerの環境にDockerを入れてそこで動かすにしても、
メモリやCPUも常時そんなに必要なわけじゃないから、runnner自体は安いインスタンスにしたい。
そう思うのは人情です。

そこで、今回はgitlab-runnerはt2.microの小さいインスタンスで動かし、
実際のビルドは、そこからdocker-machineで作成された先のインスタンス内でやろうと考えたのです。

AmazonLinuxで1からgitlab-runnerを入れ、ビルドできるところまでをステップごとに紹介します。
公式ドキュメントをコピペしていると気づかないつまづきポイントつき!

やってみた結果、思ったこと

メリット

  • gitlab-runner自身は、ビルドを行わないので、t2.microレベルで良い。やすい。
  • 複数のリクエストがきても、EC2インスタンスが新規に生成されるので、スケールしやすい。

デメリット

  • EC2インスタンスの起動から行われるため、その分ビルドに時間がかかる

aws内にDockerのレジストリ(ECR)があったり、S3にビルド用の資材が一式入ってます!みたいな人には、aws上にgitlab-runnnerを入れるメリットがありそうです。

EC2インスタンスの作成

まず、gitlab-runnerを動かすインスタンスを作成します。t2.microにしておきます。
作成する際のイメージはAmazonLinux(2017/12/06時点で ami-da9e2cbc)を指定します。

 aws ec2 run-instances 
    --image-id ami-da9e2cbc 
    --count 1 
    --instance-type t2.micro 
    --key-name ${KEY_NAME} 
    --security-group-ids ${SECURITY_GROUP_ID} 
    --subnet-id ${SUBNET_ID}

key-nameやセキュリティグループID、サブネットのIDは作成する環境にあわせて設定しておきます。

docker-machineのインストール

docker-machineを先の手順でたてた環境にインストールします。

公式の手順は、https://docs.docker.com/machine/install-machine/#install-machine-directly にあります。

curl -L https://github.com/docker/machine/releases/download/v0.13.0/docker-machine-`uname -s`-`uname -m` >/tmp/docker-machine &&
chmod +x /tmp/docker-machine &&
sudo cp /tmp/docker-machine /usr/bin/docker-machine

つまづきポイント1

公式の手順では、docker-machineを/usr/local/bin/docker-machineにコピーしますが、
これだとインストールした自分は使えるけども、gitlab-runnerユーザには使えないことになるので、
/usr/bin/docker-machine とします。

もし、/usr/bin/local以下に入れてしまっていたら、ビルド実行時にこんなエラーになります。

ERROR: Preparation failed: exec: “docker-machine”: executable file not found in $PATH

build test failed.png

gitlab-runnerのインストール

公式の入れ方はhttps://docs.gitlab.com/runner/install/linux-repository.htmlにあります。

こんな感じでyumで入れてしまいます。

curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.rpm.sh | sudo bash
sudo yum install gitlab-runner -y

gitlab-runnnerの登録

GitLabにrunnerを登録します。
予めGitLabでトークンの確認と、docker-machineで使うオプション値を確認しておきましょう。

dockerを用いたビルドを想定する場合は、docker-privilegedオプションをつけておくのが良いです。

例はこちら。
docker-machineのオプションは、実際にビルドをするマシンとして過不足ないものを選ぶといいでしょう。

sudo gitlab-runner register --non-interactive 
  --url https://gitlab.com/ 
  --registration-token XXXXXXXXXXXXXXXXXXXXXXX 
  --executor "docker+machine" 
  --name "gitlab-ci-auto-scaling" 
  --docker-image "ubuntu" 
  --docker-privileged 
  --machine-machine-driver "amazonec2" 
  --machine-machine-name "gitlab-ci-%s" 
  --machine-machine-options "amazonec2-access-key=ACCESS_KEY" 
  --machine-machine-options "amazonec2-secret-key=SECRET_KEY" 
  --machine-machine-options "amazonec2-region=ap-northeast-1" 
  --machine-machine-options "amazonec2-root-size=30" 
  --machine-machine-options "amazonec2-instance-type=m4.large" 
  --machine-machine-options "amazonec2-vpc-id=vpc-0123456" 
  --machine-machine-options "amazonec2-subnet-id=subnet-1234567" 
  --tag-list "ec2-auto-scale,docker"

つまづきポイント2

machine-machine-optionsで指定する内容は、“KEY=VALUE”の形で、イコールでつなぐようにします。
“KEY VALUE”のようにしておくと、registerそのものは成功しますが、動作しないことになります。

つまづきポイント3

もし、docker-priviledgedがない状態(false)で、dockerコマンドを実行するようなビルドが走ったときは、こうなります。

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
ERROR: Job failed: exit code 1

a.png

あとはCIするだけ

あとは、gitlab-ci.ymlを用意してビルドするだけ!

image: docker:latest

services:
  - docker:dind

build-test:
  stage: build
  script:
    - echo sekai no yasuda and aoki.
    - docker version
  tags:
    - ec2-auto-scale

tagsには、忘れずに自分で登録したrunnnerのタグにしておきましょう。

インスタンスが作成され、ビルドが走り、そしてインスタンスが削除される。
terminatedがたくさんAWSのコンソールで見えても気にしない!
じゃんじゃんバリバリ、CIがまわせるようになりますね。

では、Happy GitLab Lifeを!!

続きを読む

CloudFormationで、ECSのCI/CD環境を構築した際のハマりどころ 〜CodePipeline,CodeBuild,KMSも添えて〜

Classiアドベントカレンダー4日目です。
本日は、ECSを利用して、AWS上でAWSどっぷりのCI/CD環境を準備したときのお話になります。

今年のre:InventでEKSとFargateがリリースされましたが、東京リージョンに来てなかったり、プレビュー段階だったりで、まだしばらくは参考になる部分はありそうかなと^^;

1.背景

などで、AWS公式でもECS環境下のCloudFormation(以下、CFn)を使ったデプロイ方法が紹介されています。
とはいえ、現実の要件でCFnで実装しようとすると、デフォルト設定だと失敗したり、ドキュメントだけだと、GUIで設定できる部分がCFnでの書き方がわからかったりして、いくつかハマった内容があったので、3種類ぐらいの特徴を抜粋して書いてみようと思います。

2.TL;DR

ECSを使うなら、

  • ALBとECSの動的ポート機能を組み合わせる
  • IAM Role,KMS,SSMパラメータストアを組み合わせる
  • CodePipelineで複数リポジトリからのコード取得を行う

これらの機能を全部CFnでやろうとすると、一部aws-cliなどを使う必要がありますが、
ひとまずDevとOpsでうまく権限を分担したCI/CD環境を構築できるのではないかなと思います。

3.特徴解説

3-1. ALBとECSの動的ポート機能の組み合わせ

qiita_ecs_port.png

EC2へ割り当てるSecurityGroupは、ECSの動的ポート機能を利用するため、インバウンドのTCPポートを開放しておきます。

securitygroup.yml
ALBSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
        VpcId: !Ref VpcId
       GroupName: sample
       GroupDescription: "ALB Serurity Group"
       SecurityGroupIngress:
            -
                CidrIp: 0.0.0.0/0
                IpProtocol: tcp
                FromPort: 443
                ToPort: 443
EC2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
        VpcId: !Ref VpcId
       GroupName: sample
       GroupDescription: "EC2 Serurity Group"
       SecurityGroupIngress:
            -
                SourceSecurityGroupId: !Ref ALBSecurityGroup
                IpProtocol: tcp
                FromPort: 0
                ToPort: 65535

ECSの動的ポートを有効にするため、PortMappingsの設定でホストのポートを0に設定します。

ecs.yml
ECSTask:
    Type: "AWS::ECS::TaskDefinition"
    Properties:
        Family: sample
        NetworkMode: bridge
        ContainerDefinitions:
            -
                Name: sample
                Image: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${ECRName}:${ImageTag}"
                Cpu: 2
                Memory: 128
                PortMappings:
                    -
                        ContainerPort: 80
                        HostPort: 0
                Essential: true
                Ulimits:
                    -
                        Name: nofile
                        SoftLimit: 65535
                        HardLimit: 65535
                Environment:
                    -
                        Name: TZ
                        Value: Asia/Tokyo
                LogConfiguration:
                    LogDriver: awslogs
                    Options:
                        awslogs-group: sample
                        awslogs-region: !Sub ${AWS::Region}
                        awslogs-stream-prefix: !Ref ImageTag
    Service:
        Type: "AWS::ECS::Service"
        Properties:
            ServiceName: sample
            Cluster: !Ref ECSCluster
            DesiredCount: 1
            TaskDefinition: !Ref ECSTask
            Role: !Ref ECSServiceRole
            PlacementStrategies:
                -
                    Type: spread
                    Field: instanceId
            LoadBalancers:
                -
                    ContainerName: sample
                    ContainerPort: 80
                    TargetGroupArn: !Ref ALBTargetGroup

注意点

複数のEC2でECSを運用するのであれば、PlacementStrategiesの設定を行っておかないと、random配置ECSのタスクが一つのホストだけに偏ってしまったりすることがあります。

3-2. DevとOpsで別gitリポジトリを運用しつつ、CodePipelineのデプロイフェーズでCFnのChangeSetを使う

qiita_codepipeline.png

デプロイにCFnを利用することで、デプロイの実行記録の管理やCFnで記載された部分のインフラ部分のテストを行いつつ、デプロイをすることが可能になります。
また、Sourceフェーズで、CFnの内容やEC2のASGやAMI設定の管理を行うOps管轄リポジトリと、Dockerコンテナ化するアプリロジックが含まれているDev管轄リポジトリを分割することで、
運用フェーズに入ったときにDevとOpsで独立して、デプロイを行うことができます。

codepipeline.yml
CodePipeline:
    Type: "AWS::CodePipeline::Pipeline"
    Properties:
        Name: sample
        ArtifactStore:
            Type: S3
            Location: sample
        RoleArn: !Ref BuildRole
        Stages:
            -
                Name: Source
                Actions:
                    -
                        Name: AppSource
                        RunOrder: 1
                        ActionTypeId:
                            Category: Source
                            Owner: ThirdParty
                            Version: 1
                            Provider: GitHub
                        Configuration:
                            Owner: !Ref GithubOwner
                            Repo: !Ref GithubAppRepo
                            Branch: !Ref GithubAppBranch
                            OAuthToken: !Ref GithubToken
                        OutputArtifacts:
                            - Name: AppSource
                    -
                        Name: InfraSource
                        RunOrder: 1
                        ActionTypeId:
                            Category: Source
                            Owner: ThirdParty
                            Version: 1
                            Provider: GitHub
                        Configuration:
                            Owner: !Ref GithubOwner
                            Repo: !Ref GithubInfraRepo
                            Branch: !Ref GithubInfraBranch
                            OAuthToken: !Ref GithubToken
                        OutputArtifacts:
                            - Name: InfraSource
            -
                Name: Build
                Actions:
                    -
                        Name: CodeBuild
                        RunOrder: 1
                        InputArtifacts:
                            - Name: AppSource
                        ActionTypeId:
                            Category: Build
                            Owner: AWS
                            Version: 1
                            Provider: CodeBuild
                        Configuration:
                            ProjectName: !Ref CodeBuild
                        OutputArtifacts:
                            - Name: Build
            -
                Name: CreateChangeSet
                Actions:
                    -
                        Name: CreateChangeSet
                        RunOrder: 1
                        InputArtifacts:
                            - Name: InfraSource
                            - Name: Build
                        ActionTypeId:
                            Category: Deploy
                            Owner: AWS
                            Version: 1
                            Provider: CloudFormation
                        Configuration:
                            ChangeSetName: Deploy
                            ActionMode: CHANGE_SET_REPLACE
                            StackName: !Sub ${AWS::StackName}
                            Capabilities: CAPABILITY_NAMED_IAM
                            TemplatePath: !Sub "Source::sample.yml"
                            ChangeSetName: !Ref CFnChangeSetName
                            RoleArn: !Ref BuildRole
                            ParameterOverrides: !Sub |
                                {
                                    "ImageTag": { "Fn::GetParam" : [ "Build", "build.json", "tag" ] },
                                    "AppName": "${AppName}",
                                    "OwnerName": "${OwnerName}",
                                    "RoleName": "${RoleName}",
                                    "StageName": "${StageName}",
                                    "VpcId": "${VpcId}"
                                }
            -
                Name: Deploy
                Actions:
                    -
                        Name: Deploy
                        ActionTypeId:
                            Category: Deploy
                            Owner: AWS
                            Version: 1
                            Provider: CloudFormation
                        Configuration:
                            ActionMode: CHANGE_SET_EXECUTE
                            ChangeSetName: !Ref CFnChangeSetName
                            RoleArn: !Ref BuildRole
                            StackName: !Sub ${AWS::StackName}

注意点

  • CodePipelineのキックは、PRがマージされたタイミングなので、(一応、CodePipelineにはTestフェーズもあるが)マージ前のテストなどはCircleCIとかに任せた方がよいかも
  • ParameterOverridesで上書きするパラメータは、CFnのParametersに設定している項目に応じて設定する
  • Sourceフェーズで持ってこれるリポジトリは2つまで。コンテナビルドに持ってくるのがもっとある場合、CodeBuild内でこちらの記事のように、githubから引っ張ってきて、ビルドするなどの対応が必要になりそう

3-3. CodeBuildでDockerイメージを作る際、KMSとSSMパラメータストアを利用する

qiita_codebuild.png

このあたりはAWSの恩恵をフルに受けている部分かなと。
RDSのパスワードや秘密鍵など、gitリポジトリ内で管理したくない情報は、SSMパラメータストアを使って、Dockerイメージを作成するときに環境変数を埋め込みます。

codebuild.yml
CodeBuild:
    Type: AWS::CodeBuild::Project
    Properties:
        Name: sample
        Source:
            Type: CODEPIPELINE
        ServiceRole: !Ref BuildRole
        Artifacts:
            Type: CODEPIPELINE
        Environment:
            Type: LINUX_CONTAINER
            ComputeType: BUILD_GENERAL1_SMALL
            Image: "aws/codebuild/docker:1.12.1"
            EnvironmentVariables:
                -
                    Name: AWS_DEFAULT_REGION
                    Value: !Sub ${AWS::Region}
                -
                    Name: AWS_ACCOUNT_ID
                    Value: !Sub ${AWS::AccountId}
                -
                    Name: IMAGE_REPO_NAME
                    Value: !Ref ECRRepoName

docker buildするときに、--build-argに秘匿情報として環境変数を引き渡し、できあがったイメージをECRにpushする。

buildspec.yml
version: 0.2

phases:
    pre_build:
        commands:
            - $(aws ecr get-login --region $AWS_DEFAULT_REGION)
            - IMAGE_TAG="${CODEBUILD_RESOLVED_SOURCE_VERSION}"
            - DB_PASSWORD=$(aws ssm get-parameters --names rds_pass --with-decryption --query "Parameters[0].Value" --output text)
    build:
        commands:
            - docker build --build-arg DB_PASSWORD="${DB_PASSWORD}" -t "${IMAGE_REPO_NAME}:${IMAGE_TAG}" .
            - docker tag "${IMAGE_REPO_NAME}:${IMAGE_TAG}" "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_REPO_NAME}:${IMAGE_TAG}"
    post_build:
        commands:
            - docker push "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_REPO_NAME}:${IMAGE_TAG}"
            - printf '{"tag":"%s"}' "${IMAGE_TAG}" > build.json
artifacts:
    files:
        - build.json
    discard-paths: yes
(snip)
ARG DB_PASSWORD
ENV DB_PASSWORD=${DB_PASSWORD}
(snip)

実運用する際は、IAM Roleを使う権限も意識して、KMSのKeyを利用するIAM UserやIAM Roleを設定する。

kms.yml
KMSKey:
    Type: "AWS::KMS::Key"
    Properties:
        Description: sample-key
        KeyPolicy:
            Version: "2012-10-17"
            Id: "key-default-1"
            Statement:
                -
                    Sid: "Allow use of the key"
                    Effect: "Allow"
                    Principal:
                        AWS: !GetAtt BuildRole.Arn
                    Action:
                        - "kms:DescribeKey"
                        - "kms:Decrypt"
                    Resource: "*"

注意点

  • SSMパラメータにおける、SecureString型の値登録
    3-3.でSSMパラメータストアで暗号化する際、SecureString型はCFnに対応していない。
    そのため、aws-cliで設定することにした。TerraformはSecureString型に対応しているので、CFn側でも対応して欲しいところ…
$ aws ssm put-parameter --name rds-pass --value PASSWORD --type SecureString --key-id hogehoge

4. その他の雑多なハマりどころ

4-1. ECSのAMIのデフォルト設定

  • EBSのストレージタイプのデフォルトがHDD
    LaunchConfigurationのBlockDeviceMappingsで、gp2を明示的に指定してあげる。
  • WillReplace用のシグナルを送るcfn-signalが未インストール
    UserDataの中で記載しておく。シグナルを送るタイミングは、どこまでAMIに手を入れるかによって変更する。
LaunchConfig:
    Type: "AWS::AutoScaling::LaunchConfiguration"
    Properties:
        AssociatePublicIpAddress: true
        KeyName: sample
        IamInstanceProfile: sample
        ImageId: ami-e4657283
        SecurityGroups:
            - !Ref SecurityGroup
        InstanceType: t2.micro
        BlockDeviceMappings:
            -
                DeviceName: "/dev/xvda"
                Ebs:
                    VolumeType: gp2
                    VolumeSize: 30
        UserData:
            Fn::Base64: !Sub |
                #!/bin/bash
                echo ECS_CLUSTER=${ECSClusterName} >> /etc/ecs/ecs.config
                sudo yum install -y aws-cfn-bootstrap
                sleep 60
                /opt/aws/bin/cfn-signal -e $? --stack ${AWS::StackName} --resource AutoScalingGroup --region ${AWS::Region}
AutoScalingGroup:
    Type: "AWS::AutoScaling::AutoScalingGroup"
    Properties:
        LaunchConfigurationName: sample
        DesiredCapacity: 2
        MaxSize: 3
        MinSize: 2
        VPCZoneIdentifier:
            - !Ref PublicSubnet1
            - !Ref PublicSubnet2
    CreationPolicy:
        ResourceSignal:
            Count: 1
            Timeout: PT5M
    UpdatePolicy:
        AutoScalingReplacingUpdate:
            WillReplace: true

5.まとめ

もう少しきれいな書き方がありそうだけど、実運用でよくある要件の参考程度になれば幸いです。
EC2のASGまわりの設定は、従来のECSだとこのような形で大分インフラ側を意識しないといけない構成です。今後、re:Inventで発表されたEKSやFargateなどとも比べながら、本環境をアップデートしていければよいなと思います。

続きを読む

Terraformを実運用したら, 色々ハマった話

はじめに

CYBIRDエンジニア Advent Calendar1日目担当の@ntrvです。

CYBIRDでは女性向け恋愛ゲームのサーバ運用・インフラ改善を担当しております。
新卒2年目のGolangでうまいことやりたいと思っているエンジニアです。
最近AWSのRe:inventを連続徹夜で見ていてものすごく眠いです…
ということで, 今年もアドベントカレンダーを開催いたします!

Terraformとは?

  • Terraformとはインフラ構築をコード化することのできるツールです。
  • CYBIRDではAWSを使用した, 割と最近のタイトルで使用しております。

運用して困ったケース

  • ここでは実際に運用しているタイトルでTerraformを使用したタイトルで実際に困った事例を紹介していきたいと思います。
  1. 手動で追加したセキュリティグループをTerraformに後から追加するとき
  2. ELB配下のEC2インスタンス(サービス中)のインスタンスタイプを変更したいとき

運用上困ったケース その1

やりたいこと

  • 取り急ぎセキュリティグループを手動で追加し, ALBにattachした。
  • 手動で追加したセキュリティグループを後からTerraform管理下におきたい。

実施したこと

  • terraform importを行い, 既存のリソースを.tfstateに取り込むことを考えた。

    • terraform import module.fuga.aws_security_group.https_hoge sg-1234abcd
  • その後, terraform planで差分がなくなるまで*.tfに書いていく。

困ったこと

理想

  • 以下のように*.tfに記述できることを望んでいた。

    • 既存のコードと書きっぷりが変わらないようにしたかった。
  • terraform importを行うことでaws_security_group.https_hogeだけが.tfstateに追加されるものだと考えていた。
resource "aws_security_group" "https_hoge" {
  name        = "${var.env}-${var.app_name}-HTTPSFromHoge"
  description = "Allow HTTPS From Hoge"
  vpc_id      = "${var.vpc_id}"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"

    cidr_blocks = [
      "8.8.8.8/32",
      "8.8.8.4/32",
    ]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = -1
    cidr_blocks = ["0.0.0.0/0"]
  }
}

現実

  • 実際には以下のように記述する必要がありました。

    • セキュリティグループを記述する方法として二通り存在するため。

      • aws_security_group + aws_security_group_ruleを使用する方法 <- こちらに合わせて書く必要があった。
      • aws_security_groupにインラインで記述する方法
  • aws_security_group_ruleに関しては自由な名前を付けることが出来なかった。

    • 今回はaws_security_group.https_hogeの”https_hoge”から連想されてか”aws_security_group_rule.https_hoge-1と名付けられている。
## 現実
resource "aws_security_group" "https_hoge" {
  name        = "${var.env}-${var.app_name}-HTTPSFromHoge"
  description = "Allow HTTPS From HOGE"
  vpc_id      = "${var.vpc_id}"
}

resource "aws_security_group_rule" "https_hoge" {
  type      = "ingress"
  from_port = 443
  to_port   = 443
  protocol  = "tcp"

  security_group_id = "${aws_security_group.hoge.id}"

  cidr_blocks = [
    "8.8.8.8/32",
    "8.8.8.4/32",
  ]
}

resource "aws_security_group_rule" "https_hoge-1" {
  type      = "egress"
  from_port = 0
  to_port   = 0
  protocol  = "-1"

  security_group_id = "${aws_security_group.hoge.id}"

  cidr_blocks = [
    "0.0.0.0/0",
  ]
}

どのようにすればよいか

  • 急ぎの場合以外はTerraform管理下のリソースに直接変更を加えない。

    • terraform importを極力行わないようにする。

運用上困ったケース その2

やりたいこと

ELB配下のEC2インスタンスのインスタンスタイプを”安全に”変更したい場合, 手動によるオペレーションでは以下のように必要があります。

  1. ALBからdetachする
  2. アクセスログを確認し, 完全に切り離されたことを確認し電源を落とす
  3. インスタンスタイプを変更し電源をあげる
  4. ALBにattachする

この手順をTerraform管理下で実施する場合, 以下のような方法が考えられます。

  1. 手動で上記手順を実施し, 後からTerraformを修正する…①
  2. ELBへのattachmentはTerraform管理下に置かないようにし, 他はTerraformにまかせる…②

困ったこと

  • 今まで(Terraform v0.8.5)は①の方法で実施しておりました。

    • しかしTerraform v0.10.4で同じ方法を実施したところ, リソース再作成のplanが表示されました。。

      • その後v0.10.7で検証しましたが, 再現しなかった… 今後詳しく調査する予定です。

Terraform管理下で実施する方法

  • そもそもTerraformで管理しているリソースに手を加えること自体危険であったので, ②の方針にしようかと考えております。

    • Terraform v0.8.8からはインスタンスタイプ変更時にリソース再作成ではなく, 再起動してくれるようになりました。

      • provider/aws: Allow aws_instances to be resized rather than forcing a new instance (#11998)

②の方法を採用すると実際の手順は以下のようになります。

  1. ALBからdetachする
  2. アクセスログを確認し, 完全に切り離されたことを確認する
  3. あらかじめ変更しておいた*.tfを使用しterraform applyを実行する
  4. インスタンスタイプが変更され再起動されているので, ALBに手動でattachする

得られた教訓

  • terraform importを多用すると, コードがカオスなことになってくる。

    • 緊急時以外はterraform管理下のリソースに手動で変更しない。
    • terraform importで間違えたときに切り戻しできるように, backendのversioningの機能はONにしておく。
  • 運用することを考えて, Terraformを使用する
    • 何でもTerraformに落とし込んでしまうと, 運用時に困るものこともある。
    • ignore_changesを有効利用する。
  • Terraformで管理しているリソースに手動で変更を行うことは基本的にしない。
    • Terraformで保持している状態と実際の状態が乖離していく。
    • 後からTerraform側の修正で対応すると大変なことになる。

その他主張したいこと

  • とにかくterraform planは絶対必須!!

    • TerraformがどのようにAPIを叩くのかを見ることができるため。
    • あくまでterraformの立てた実行計画なので, apply時に失敗することはありますが..
  • 安全にTerraformを実行できる環境づくりは必要!!
    • RDS等消えると困るリソースは lifecycle.prevent_destroyでリソース再作成時にエラーとなるようにする。
    • State Lockingでterraform applyを同時実行されることを防止する。
    • terraform plan時に生成される.tfplanterraform applyに渡す。
    • terraform applyはCircle CIやJenkins上から実行する。
    • tfenvを使用し, Terraformのバージョンをリポジトリごとに固定する。

感想

  • Terraformは難しい…(おざなりな感想)

最後に

CYBIRDエンジニア Advent Calendar 2016 明日は、 @masatoshiitoh の「TCPサーバーを書いてみよう」です!
私のデスクの隣にいる方で, 社内一の超テッキーではないかと噂になっている方です。楽しみですね!

続きを読む

GitLab CIとAWS Spot Instanceでお手頃価格のスケーリングCIを実現する

お断り

この記事は GitLab Meetup Tokyo #4 で発表した内容です.
スライドだけではトーク内容が伝わりにくいので,記事化することにしました.

スライド:
https://speakerdeck.com/tetsukay/reasonable-auto-scaling-ci-with-aws-spot-instance

Reasonable Auto Scaling CI with AWS Spot Instance

現在進行形の怖い話

大量のModules

image.png

以前のCI環境

  • プラットフォーム

    • GitLab CI
  • サーバ
    • AWSオンデマンドインスタンス
    • m4.xlarge(4core 16GB $0.258/h)

こんな環境でDockerベースCIをしていました.

image.png

課題

  • 前述の通り,大量のModulesがあるためCIに時間がかかる
  • しかも並列でCIが走るとどんどん遅くなる
    • 4CIも同時に走ると,タイムアウトでCIがコケる
  • (計算してみたらそうでもなかったけど)ハイコスト
    • $72 = 280 hours * $0.258

image.png

BY THE WAY…

AWSの課金が時間単位から秒単位になりましたね :moneybag:

image.png

やるっきゃない! Auto scaling

image.png

Auto scaling with GitLab CI

GitLab CIは,docker-machineをサポートしています.
CIが実行されるたび,GitLab CIはdocker-machineでAWSスポットインスタンスを立てて,そこでCIを行います.

image.png

こんなかんじ

  • GitLabとスポットインスタンス(CIインスタンス)の間に,GitLab Runnerが仲介

    • この子はオンデマンド(t2.small)でGitLabからのCIリクエストを受け付ける役割
    • この子がdocker-machineでCIインスタンスを立ち上げる

image.png

スポットインスタンスってなあに?

先程からチラチラでているスポットインスタンスについて簡単に説明します.

image.png

余った資源をお安く提供

AWSのデータセンターは,需要の増減を見越してある程度余裕を持たせて稼働しています.当然ですよね.
でも今度は逆に処理能力が余ってしまうわけです.もったいない!
そこで,需要が少なく処理能力が余っている時,余っている処理能力をお安く提供しますよ.っていうのがスポットインスタンスです.
ちなみにスポットインスタンスの価格は変動します.「ここまで出せる!」という価格を決めて入札します.

  • おおよそオンデマンドの 15〜20% くらいの価格で買うことができます.
  • 例えば, c4.4xlarge(16core 30GB RAM)では
    • on-demand: 1.008/h
    • spot: 0.182/h(最安時)

となり,1秒単位で課金されます.

image.png

スポットインスタンスのデメリット1

なら,全部スポットで良いんじゃないかな?

と,おもいますよね?
全部スポットインスタンスで賄えれば,それは大きなコスト削減につながりますが,デメリットもあります.

ひとつ目は,価格変動リスク
AWSリソースの需要が逼迫した場合,スポットインスタンスの価格はオンデマンドの10倍(:exclamation:)近くまで跳ね上がります.
なお,設定した入札価格を超えて知らず知らずのうちに10倍払っていた…ということにはなりませんのでご安心を.

image.png

スポットインスタンスのデメリット2

ふたつ目は,突然強制的にシャットダウンされることがある点です.
スポットインスタンス使用中に価格が入札価格を上回った場合,1〜2分の猶予時間を与えられてから強制的にインスタンスをシャットダウンされます.
また,CIのたびにインスタンスを起動するので,1〜2分ほどインスタンスを起動する時間がビルド時間にプラスして掛かります.

image.png

改善後のCI環境

さて,上記の仕組みを導入した結果…

  • CIの実行インスタンスタイプは

    • m4.xlarge(4core 16GB)から c4.4xlarge(16core 30GB)へ!

      • 入札価格は $0.5 としています
  • CIの実行時間は
    • 25min から 14minへ!
  • 並列数は無制限!
    • といいたいところですが,インスタンス起動可能数に上限があるのを忘れていました.
    • 今回の環境の場合は,20が上限です.が,現時点では十分すぎるくらいです.

image.png

コスト計算

Before

  • Runner Instance(on-demand)

    • 前環境では直接ここでCIをしていた
    • m4.xlarge $0.258
    • 280時間/月稼働
    • 280*0.258 = $72.24

After

  • Runner Instance(on-demand)

    • CIリクエストを捌く係.CIはしない
    • t2.small $0.0152
    • 280時間/月稼働
    • 280*0.0152 = $4.256
  • CI Instance(spot)

    • Runnerからリクエストを受けてCIする係
    • c4.4xlarge $0.1825(最安値)
    • 200 CI /月(おおよそ)
    • 14min * 200 = 2800 min/月 = 46.67 時間/月
    • 46.67 * 0.1825 = $8.52
  • Total

    • 4.256 + 8.52 = $12.776

なにより,並列でCIを起動してもビルド時間が伸びない嬉しさ!!

まとめ

  • CIするインスタンススペックをドカッと上げることができたよ!
  • (事実上)無制限な並列CIができたよ!
  • それなのにコストも 1/3(概算) まで下がってしまったよ!

image.png

続きを読む