あすたぴのブログ

astap(あすたぴ)のブログ

CircleCI2.0 の Workflow を使っているよ

いまいちやる気が出ない時は一度立ち止まってブログを書きます。

Workflow

こちらですね。 ジョブを複数定義し、ジョブごとに依存を定義できる。

今までのCircleCI

いままでのビルドは1フローのみでした。 A-B-C-D というイメージ。 だけど実際はジョブが1つしか定義出来なかった(出来るけど使用するには自分でAPIを呼ぶ必要があった。)ので、A1-A2-A3-A4 みたいな感じだった。

A4はA1に依存してるかもしれないけど、A3には依存していない。とか、 buildとdeployは明確に分けることが出来るんだけど、deployにもいくつかdeploy対象があって、それらは並列でいいとか、あるけど必ず順次実行だった。

具体的にどう使っているか

CIのdeploy対象としては以下の2種類がある。

  • assets(js,css,image)
  • dockerイメージ

dockerイメージも複数種類があったりして、直列に処理をしていると結構な時間がかかる。 以下のような workflow にしている。

workflows:
  version: 2
  build_and_deploy:
    jobs:
      - build
      - app_deploy:
          requires:
            - build
          filters:
            branches:
              only:
                - develop
                - master
      - assets_deploy:
          requires:
            - build
          filters:
            branches:
              only:
                - develop
                - master
      - build_node_app:
          requires:
            - build
          filters:
            branches:
              only:
                - develop
                - master
      - node_app_deploy:
          requires:
            - build_node_app
          filters:
            branches:
              only:
                - develop
                - master

まず build ジョブが走る。実際内容は rspec の実行なんだけど、たぶん buildっていうジョブ名が固定?

それが成功すると、各deployジョブが走る。

app_deploy はRailsのdockerイメージをbuildしてECRにpushしている。

assets_deployは、webpackのbuildを行ってその成果物をS3にアップしている。

build_node_appはexpress用のjsをwebpackでbuildしている。

node_app_deployはbuild_node_appの成果物を受け取って、dockerイメージをbuildしてECRにpushしている。

ここを少し気をつける必要があるのは、各job自体がdockerで動いているということ。 そのdockerイメージは自分で指定するので、自分が行いたいbuild,deployが出来るdockerイメージを用意する必要がある。

app_deployの部分は以前に書いた、multi stage buildなのでdockerではなくmachineビルドになっている。

assets_deployは、webpackのbuildを行うので、nodejs, yarn, そしてS3コマンドを使うため、aws cliが入っているdockerイメージを用意している。

build_node_appは、assets_deployと同じでいい。nodejs, yarnがあるイメージ

node_app_deployは、dockerが入っているdockerが必要になる。そして、ECRにpushするので、aws cliが必要。 あと、build_node_appからartifact経由で成果物を受け取るときにtarコマンドが必要なのでそれも入れておく。

dockerが入っているdockerってなんやねん。って思うかもしれないけど、公式に用意されている。 イカのようなDockerfileからdockerイメージをbuildしてdocker hubとかに置いておく。 何も見られて困るようなものは入ってないので、普通にpublicで良い。

FROM docker:17.05.0-ce

RUN apk update && \
    apk add \
    ca-certificates \
    git \
    openssl \
    zip \
    unzip \
    wget \
    python \
    py-pip \
    py-setuptools \
    groff \
    less \
    tar && \
    pip install awscli

という感じで、色々と準備は必要なものの、CircleCI2.0より前の場合は、 machineビルドで、用意されたVMにその場で色々インストールして、buildしてdeployしていた。 それに比べると、純粋にbuildとdeploy以外は事前に済ませておけるので実行時間は早くなる。

つまり docker は最高。

なぜDockerを使うのかを整理する

※追記 整理とかいいつつ、雑に書きなぐっただけだったので少し文章と見栄えを直しました。

背景

いま作っているシステムではECSのクラスタ上に、Dockerコンテナを配置してアプリケーションをデプロイしている。 なぜ一般的なEC2にアプリケーションを配置する手法を使わずに、ECSでDockerを使用するのか。 自分の考えを整理する。

前提知識

ECSとは

AWSのサービスの1つでEC2ContainerServiceの略。 Dockerコンテナを運用をいい感じにやってくれるサービスです。

TL;DR;

  • アプリケーションの運用はいろいろ大変
  • 運用を楽にするために色々な技術がある
  • Dockerを使うとより楽になる

EC2にアプリケーションを配置する手法

通常EC2を使い際に、AWSが用意しているAMIをそのまま使用することは多くない。 理由としては幾つかあるが、雑に思いついたのはイカ

  • デフォルトでインストールされている言語以外、または別バージョンを使用したい
  • アプリケーションで使用するライブラリをインストールする必要がある
  • インスタンスSSHする際に使う鍵を起動時の設定とは別に用意したい

1つずつ詳しく解説していく。

デフォルトでインストールされている言語以外、または別バージョンを使用したい

いまは知らないが、一昔前のAmazonLinuxであればRubyのversionは2.0だった。 たぶん当時の最新は2.1とか2.2だったと思う。

バージョンがいくつ違うとかはまぁいいとして、重要なのは常に最新に追随するわけではないということ。 Rubyはversionupのたびに高速化している。 いままで最新バージョンを使用していて、既存のアプリケーションが動かなくなるというようなバグに遭遇したことはなく、基本的にはversionは上げ得である。 であれば、最新のメソッドも追加されているしRubyのversionは新しいほうがいい。

アプリケーションで使用するライブラリをインストールする必要がある

すぐに思いつくのはImageMagicとかだろうか。 画像処理を行うアプリケーションであればほぼ入れることになるであろうライブラリである。

インスタンスSSHする際に使う鍵を起動時の設定とは別に用意したい

EC2ではインスタンスSSHするためにkey pairと呼ばれる鍵を作成し、公開鍵をインスタンスに配置してくれる。 ユーザーは公開鍵を自身のマシンにセットし ec2-user とかAMIごとに指定のユーザーとしてSSHをする。

チームで開発する際に、SSHする可能性のあるメンバーには鍵を渡す必要がある。また、このデフォルトのユーザーはroot権限(sudo)を持っているため全員に配布するのはリスクがある。

そこで、インスタンスにデフォルトとは違うユーザーを作成し、そこにチームメンバーの公開鍵を配置する。 そうすることで各メンバーは自分のユーザーを持ち、自分の秘密鍵SSHが可能になる。

デフォルトのCloudwatchのメトリクスとは別にメトリクスを収集したい

Prometheusとか、そういうエージェントを入れることもあるよね。

どのようにEC2インスタンスを作るのか。(設定するのか)

簡単な方法だと、ユーザーデータと呼ばれるインスタンス起動時に実行されるスクリプトがある。ここに各種インストールのコマンドを記述すると起動時に色々インストールしてくれる。 しかし、これだと起動時に言語のビルドなども含まれると異常に起動が遅くなる。(正確に言うとすでに起動はしているので、起動後に自分の使いたいものが揃うのが遅くなる)

その為、一般的にはインスタンスを起動後、SSHして自分好みにセットアップし、AMI(インスタンスのイメージ)に保存する。 保存したAMIから起動することでセットアップされた状態でインスタンスを起動する。この手法を使うことで同じ環境のインスタンスを複数起動することが可能になる。

更に言うとそれらをいちいち手でセットアップするかというとそんな面倒なことはしたくない。その為、Chef、Ansibleなどの構成管理ツールと呼ばれるものを使用し、セットアップを行う。

それも面倒なのでPackerでAMIが自動生成されるようにしたりする。(実はPakcerはあんまりちゃんと使ったことがない)

EC2インスタンスで配置したアプリケーションの運用

アプリケーションを運用をしていく上で、いろいろな機能追加が入ると思う。 またRubyは毎年12月25日に最新バージョンがリリースされる。(バグフィックス等のバージョンアップは随時行われている)

Rubyのバージョンだけなら、アップデートしない。という選択もあるだろう。しかし、先述したようなImageMagickのようなライブラリが追加で必要になった場合はインスタンスにインストールをする必要がある。 運用しているサーバーが1台、2台ならSSHして手でセットアップ、または構成管理ツールでセットアップするという選択肢もあるだろう。(動いているサーバーにそんなことおれはやりたくないけど)

あんまりないけど再起動が必要なアップデートもある(近年ありがちなSSL,SSH脆弱性アップデートは再起動が必要だった気がする)そうなると、1台運用ならサーバーが停止することになり、複数台運用ならローリングアップデートが必要になる。

自分が昔関わっていたサイトでは毎月必ずインフラの定期メンテがあったりしたこともある。そこに併せて色々やる。

Dockerの良さ

ここまでの流れで運用の大変さとそれを回避するためにいろいろな技術があることがわかったと思う。

DockerはDockerイメージを作成しそのイメージからコンテナを生成しサーバーに配置する。 Dockerイメージは、イカのようなDockerfileから生成する。 イカから生成されるイメージはRuby2.4.1がインストールされたalpine linuxのイメージになる。 このイメージをベースに、自分のアプリケーションに必要な設定を追加していくことになる。

FROM ruby:2.4.1-apline

ADD . /app

すでにわかったかもしれないが、Dockerfileを更新し、イメージを再作成、再作成したイメージのコンテナをサーバーに配置する。これがDockerを利用した場合のアプリケーション(サーバー)の更新になる。

ここまでに記述したようなインフラの更新に必要なのは、 Dockerfileの更新、イメージのPush、新しいイメージからコンテナを配置になる。

またDockerは1つのコンテナに複数アプリを起動するようなことはせず、1コンテナ1プロセスが基本です。 EC2インスタンスで行うような1サーバーのリソースを有効に使うために複数アプリを起動したりするために1つのサーバーにいくつもの言語を入れたり、いくつものアプリケーションを起動するようなことはありません。1つ1つのDockerfileは1つのことをするための設定だけになり設定の管理としてもシンプルになりやすいと思っています。 それに加え、ECSクラスタ内のインスタンスにコンテナを配置する際にはMultiAZやリソースを考慮して配置するため、インスタンスのリソースを自動的に有効に使えるようになります。

Dockerを使いやすい環境

ただ、インフラアップデートが楽なだけでDockerを選択するわけではない。 ECSやGKEなどDockerのクラスタ運用を任せられるサービスがあるのが理由としては大きい。 さすがにクラスタの管理まですべて自前で、となると簡単に手が出せるものではなくなってくる。

正直な話し

Dockerになるとすごく楽になる。みたいな雰囲気で書きましたが、ECSではDockerコンテナを配置するインスタンスはEC2インスタンスになります。 面倒な部分が残っている可能性もあります。

今まで書いてきたようなEC2インスタンスと同じ使い方ではありませんが、もしSSHシたい場合は同じように設定が必要です。セキュリティアップデートも必要です。 それくらいなら、最新のAMIに変える。アップデートコマンドを叩く。等なので新しいAMIから起動してユーザーデータで設定で事足りるのでなんとかという考えです。 また、ECSではコンテナ配置時に配置させたくないインスタンスを設定できるため、比較的簡単にローリングアップデートを行うことができます。

A,Bインスタンスがあった場合に、Cインスタンスを起動。Aインスタンスにコンテナを配置しないようにする。 Aインスタンスを破棄。Dインスタンスを起動。 というようにすることで、安全にインスタンスを切り替えることができます。 Dockerコンテナも同じようなやり方で新しいコンテナに入れ替える。

Dockerの関するありがちな批判

デプロイごとにDockerイメージをbuildするの遅くない?

EC2を設定するのと同じように毎回、Ubuntuイメージとかをベースに使って、自分でRubyを入れて・・ みたいな事をしてたらクソ遅いです。 それをしないために、自分で作ったDockerイメージを保存するリポジトリがあります。 公式だとDockerHub、AWSにECRがあります。 アプリケーションに必要な、言語、ライブラリまでインストールしたイメージをリポジトリにPushしておき、 Deploy時はそのイメージをFROMに記述し、実行するアプリケーションのコードを追加、Rubyならbundle installする程度でしょう。bundle installも、Gemfileに更新がなければイメージに最初から含めておけばいいです。 一番早いのは、アプリケーションのコードを乗せるだけ。という使い方です。

terraformで aws のいい感じの構成を作る (subnet編)

対象読者

terraformが何かを知っていて、 terraformを使おうと考えている人。

terraform version 0.9.1

subnet とは何なのか。

VPCのネットワーク内で更に細かくネットワークを分割したもの。 http://docs.aws.amazon.com/ja_jp/AmazonVPC/latest/UserGuide/VPC_Subnets.html http://docs.aws.amazon.com/ja_jp/AmazonVPC/latest/UserGuide/VPC_Scenario2.html ここらへんの話しになります。

今回は、イカ3つのsubnet構成を作ります。

  • public
  • nat-private
  • private

public サブネット

public サブネットには、public ip アドレスを付与し、VPCの外からも見えるネットワークを作ります。 ELBを配置します。 また、nat-privateへの踏み台サーバーを建てたりします。

nat-private サブネット

外部ネットワークからアクセスは行えず、外のネットワークへ出る際に、nat を通るネットワーク おもに、自分で作ったアプリケーション(Railsアプリとか)を配置します。

private サブネット

外部ネットワークからアクセスは行えず、外のネットワークに出る必要のないネットワーク Database等のdatastoreを配置します。

multi-az

awsにはAvailabilityZoneと呼ばれる区分けがあります。 これは、物理的なインスタンスの位置の違いを示しており、片方のAZ(データセンター)が(天災とか)で壊れても、もう片方のAZにも配置しておくことでサービスが死なないようにするという手法に使います。

subnetはそれぞれAZごとに作ります。 東京リージョンでは a, c とAZがあるので2つ作ることになります。

subenet 作成のコード

resource "aws_subnet" "public1a" {
  vpc_id = "${aws_vpc.vpc.id}"

  cidr_block        = "${cidrsubnet(aws_vpc.vpc.cidr_block, 8, 0)}"
  availability_zone = "ap-northeast-1a"

  tags {
    Name    = "${var.service}-${var.envname}-public1a"
  }
}

resource "aws_subnet" "public1c" {
  vpc_id = "${aws_vpc.vpc.id}"

  cidr_block        = "${cidrsubnet(aws_vpc.vpc.cidr_block, 8, 1)}"
  availability_zone = "ap-northeast-1c"

  tags {
    Name    = "${var.service}-${var.envname}-public1c"
  }
}

resource "aws_subnet" "nat-private1a" {
  vpc_id = "${aws_vpc.vpc.id}"

  cidr_block        = "${cidrsubnet(aws_vpc.vpc.cidr_block, 8, 4)}"
  availability_zone = "ap-northeast-1a"

  tags {
    Name    = "${var.service}-${var.envname}-nat-private1a"
  }
}

resource "aws_subnet" "nat-private1c" {
  vpc_id = "${aws_vpc.vpc.id}"

  cidr_block        = "${cidrsubnet(aws_vpc.vpc.cidr_block, 8, 5)}"
  availability_zone = "ap-northeast-1c"

  tags {
    Name    = "${var.service}-${var.envname}-nat-private1c"
  }
}

resource "aws_subnet" "private1a" {
  vpc_id = "${aws_vpc.vpc.id}"

  cidr_block        = "${cidrsubnet(aws_vpc.vpc.cidr_block, 8, 2)}"
  availability_zone = "ap-northeast-1a"

  tags {
    Name    = "${var.service}-${var.envname}-private1a"
  }
}

resource "aws_subnet" "private1c" {
  vpc_id = "${aws_vpc.vpc.id}"

  cidr_block        = "${cidrsubnet(aws_vpc.vpc.cidr_block, 8, 3)}"
  availability_zone = "ap-northeast-1c"

  tags {
    Name    = "${var.service}-${var.envname}-private1c"
  }
}

cidr_blockは、192.168.0.0/24 192.168.1.0/24 みたいになってます。 1つのsubnetで 256 アドレス使えます。ただ、AWSがいろいろ使うので実際にはこれよりちょっと少ないです。

subnet のネットワーク

subnet内でのネットワークの流れを定義する必要があります。 どうやって外と通信するか。という感じです。

public subnetでは、internet gateway を用意します。 これはVPCとしてのネットワークの出入り口でもあります。

nat-private subnetでは nat_gateway というものを用意します。 これは実態としてはEC2インスタンス(だとおもう)で、public ipアドレスを付与する必要があります。 以前は、AWSが用意したnat用のAMIを自身で起動して用意していましたが、nat_gatewayというマネージドなnatが利用できるようになりました。

イカ、コード

resource "aws_internet_gateway" "gw" {
  vpc_id = "${aws_vpc.vpc.id}"

  tags {
    Name    = "${var.service}_${var.envname}_gateway"
  }
}

resource "aws_eip" "nat_gateway" {
  vpc = true
}

resource "aws_nat_gateway" "nat_gw" {
  allocation_id = "${aws_eip.nat_gateway.id}"
  subnet_id     = "${aws_subnet.public1a.id}"
  depends_on    = ["aws_internet_gateway.gw"]
}

ルートテーブル

実際の、subnetのルーティングを設定します。 0.0.0.0/0 、つまりすべてのネットワークをgatewayに流しています。

resource "aws_route_table" "public" {
  vpc_id = "${aws_vpc.vpc.id}"

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

  tags {
    Name    = "${var.service}_${var.envname}_public"
  }
}

resource "aws_route_table" "nat-private" {
  vpc_id = "${aws_vpc.vpc.id}"

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = "${aws_nat_gateway.nat_gw.id}"
  }

  tags {
    Name    = "${var.service}_${var.envname}_nat-private"
  }
}

ルートテーブルを作っただけではダメで、それをどのsubnetで使うかを設定します。

resource "aws_route_table_association" "public1a" {
  route_table_id = "${aws_route_table.public.id}"
  subnet_id      = "${aws_subnet.public1a.id}"
}

resource "aws_route_table_association" "public1c" {
  route_table_id = "${aws_route_table.public.id}"
  subnet_id      = "${aws_subnet.public1c.id}"
}

resource "aws_route_table_association" "nat-private1a" {
  route_table_id = "${aws_route_table.nat-private.id}"
  subnet_id      = "${aws_subnet.nat-private1a.id}"
}

resource "aws_route_table_association" "nat-private1c" {
  route_table_id = "${aws_route_table.nat-private.id}"
  subnet_id      = "${aws_subnet.nat-private1c.id}"
}

まとめ

正直、かなりハマりました。 というものの、terraformで作らないでAWSコンソールで作ると、最初からいい感じに設定されているんですよね。 自分で、terraformで細かく作って初めて知ったものは多かったです。 nat-privateのネットワークがハマりどころだと思います。

CircleCI2.0でRailsアプリをdocker multi stage buildをする

※2017/05/29 時点くらいの話しです。

現在は改善されているかもしれません。

multi stage buildとは

https://speakerdeck.com/toricls/understanding-dockers-multi-stage-builds こちらが詳しい。

build環境と、実行環境を別イメージとしてbuildしたい。 それを簡単に出来るようになったよ。という話し。

なぜ別イメージにしたいかと言うと、Goの例がわかりやすいが、 Goはワンバイナリで実行可能な為、 Goをコンパイルするために必要なライブラリ等は、実行環境のイメージには必要がない。 dockerのイメージは小さいほど、(docker pull含めて)実行が早いので軽いほうがいい。

CircleCI2.0 で実行する方法

CircleCI2.0では、build時に自分で好きなDockerのvesionを使えるよ。とうたっている。 https://circleci.com/docs/2.0/building-docker-images/

      - run:
          name: Install Docker client
          command: |
            set -x
            VER="17.03.0-ce"
            curl -L -o /tmp/docker-$VER.tgz https://get.docker.com/builds/Linux/x86_64/docker-$VER.tgz
            tar -xz -C /tmp -f /tmp/docker-$VER.tgz
            mv /tmp/docker/* /usr/bin

ここで 17.03.0-ce を指定しているのですが、multi-stage buildを行うためには、17.05.0-ce が必要になる。 そのため、ここで 17.05.0-ce と指定すると、そのバージョンのdockerをinstalできる。 出来るのだが、そのバージョンは使えない。 なぜかと言うと docker は client 側と docker daemon側で2つのversionがあり、 setup_remote_docker では、docker daemon側のバージョンは 17.03.0-ce で固定されている。 daemon側が低いとそちらに合わせられるため、multi-stage buildは行うことができない。

では、どうするのが良いか。 machine実行で、自分で docker を install する。

  deploy:
    working_directory: ~/app
    machine: true
    steps:
      - checkout
      - run:
          name: update docker
          command: |
            docker version
            sudo service docker stop
            curl -fsSL https://get.docker.com/ | sudo sh
            docker version

上記では、(おそらく)最新のdockerを自分で入れている。

Client:
 Version:      17.05.0-ce
 API version:  1.29
 Go version:   go1.7.5
 Git commit:   89658be
 Built:        Thu May  4 22:06:06 2017
 OS/Arch:      linux/amd64

Server:
 Version:      17.05.0-ce
 API version:  1.29 (minimum version 1.12)
 Go version:   go1.7.5
 Git commit:   89658be
 Built:        Thu May  4 22:06:06 2017
 OS/Arch:      linux/amd64
 Experimental: false

build時に、debugでversionを出すとこんな感じに 17.05.0-ce が入っていることがわかる。

あとは普通に、docker imageをbuildして push したりすればよい。 雑な例を出すとこんな感じ。

      - deploy:
          name: run build & deploy
          command: |
            $(aws ecr get-login --region ap-northeast-1)
            $REPO={{ECRのリポジトリとか}}
            $PUSH_TAG=latest
            docker build -t hoge:latest .
            docker tag hoge:latest $REPO:$PUSH_TAG
            docker push $REPO

最後に

Railsでmulti-stage buildに使っているDockerfileを載せておく。 あんまり詰めれていないので、もっと良く出来るとは思う。 考えとしては、gemをbuildするために必要なものを、最初のイメージで入れて 実行に必要なものだけど後半のイメージにいれている。

FROM ruby:2.4.1-alpine as builder

RUN apk --update add --virtual build-dependencies build-base curl-dev linux-headers
RUN apk --update add mariadb-dev
RUN echo 'gem: --no-document' > /etc/gemrc

WORKDIR /app
ADD . /app
RUN bundle install --jobs=4
RUN apk del build-dependencies


FROM ruby:2.4.1-alpine

ENV LANG ja_JP.UTF-8
COPY --from=builder /usr/local/bundle /usr/local/bundle

RUN apk --update add tzdata imagemagick
RUN apk --update add mariadb-dev && rm /usr/lib/libmysqld*
RUN apk del openssl-dev mariadb-client-libs mariadb-common

ADD . /app
RUN chown -R nobody:nogroup /app
USER nobody

WORKDIR /app
EXPOSE 9292
CMD [ "bundle", "exec", "unicorn", "-c", "config/unicorn.rb" ]