Professional Rails on ECS (rails developer meetup 2017)

このエントリはRails developer meetup 2017で発表した内容をブログとして書き出したものです。 サンプルのスニペットが多いので資料の代わりにエントリとして公開します。 スライド用のmarkdownを元に起こしたものなので、少し読み辛いかもしれませんがご容赦ください。

ECSとは

  • Dockerコンテナを稼動するためのクラスタを管理してくれるサービス
  • 使えるリソースを計測し、自動でコンテナの配置先をコントロールしてくれる
  • kubernetesではない。最近、kubernetesが覇権取った感があって割と辛い
  • 今はEC2が割とバックエンドに透けて見えるのだが、Fargateに超期待
  • ECS or EKS :tired_face:

RailsアプリのDockerize

オススメの構成

  • 実際にデプロイするimageは一つにする
    • 例えばstagingやproduction等のデプロイ環境の違いはイメージでは意識しない
  • 手元で開発する様のDockerfileは分ける

FROM ruby:2.4.2

ENV DOCKER 1

# install os package
RUN <package install>

# install yarn package
WORKDIR /yarn
COPY package.json yarn.lock /yarn/
RUN yarn install --prod --pure-lockfile && yarn cache clean

# install gems

WORKDIR /app
COPY Gemfile Gemfile.lock /app/
RUN bundle install -j3 --retry 6 --without test development --no-cache --deployment

# Rails app directory
WORKDIR /app
COPY . /app
RUN ln -sf /yarn/node_modules /app/node_modules && \
  mkdir -p vendor/assets tmp/pids tmp/sockets tmp/sessions && \
  cp config/unicorn.rb.production config/unicorn.rb

ENTRYPOINT [ \
  "prehook", "ruby -v", "--", \
  "prehook", "ruby /app/docker/setup.rb", "--" ]

CMD ["bundle", "exec", "unicorn_rails", "-c", "config/unicorn.rb"]

ARG git_sha1 # どのコミットなのか中から分かる様にする

RUN echo "${git_sha1}" > revision.log
ENV GIT_SHA1 ${git_sha1}

docker build


assets:precompile

RailsのDocker化における鬼門の一つ

  • S3 or CDNを事前に整備しておくこと
  • ビルド時に解決するがビルド自体とは独立させる
  • docker buildした後で、docker runで実行する

  • ビルドサーバーのボリュームをマウントし、assets:precompileのキャッシュを永続化する
  • キャッシュファイルが残っていれば、高速にコンパイルが終わる
  • manifestをRAILS_ENV毎にrenameしてS3に保存しておく この時、コミットのSHA1を名前に含めておく。(build時にargで付与したもの)
docker run --rm \
  -e RAILS_ENV=<RAILS_ENV> -e RAILS_GROUPS=assets \
  -v build_dir/tmp:/app/tmp app_image_tag \
  rake \
    assets:precompile \
    assets:sync \
    assets:manifest_upload

prehook

ENTRYPOINTで強制的に実行する処理で環境毎の差異を吸収する

  • ERBで設定ファイル生成
  • 秘匿値の準備
  • assets manifestの準備
    • さっきRAILS_ENV毎に名前付けてuploadしてたのをDLしてくる

秘匿値の扱い

  • 設定ファイル自体を暗号化してイメージに突っ込む
    • 環境変数で直接突っ込むとECSのconsoleに露出する
    • 値の種類が多いと環境変数管理する場所が必要になる
  • コンテナ起動時に起動環境の権限で複合化できると良い
    • prehookで複合化処理を行う

yaml_vault

https://github.com/joker1007/yaml_vault

  • Rails5で入った、encrypted secrets.ymlの拡張版
  • AWS-KMS, GCP-KMSに対応している
  • KMSを利用すると秘匿値にアクセスできる権限をIAMで管理できる
  • クラスタに所属しているノードのIAM Roleで複合化
  • 設定をファイルに一元化しつつ安全に管理できる
  • Railsの場合、secrets.ymlをメモリ上で複合化して起動できる
    • ファイルに展開後の値が残らない

開発環境

docker-composeとディレクトリマウントで工夫する


version: "2"
services:
  datastore:
    image: busybox
    volumes:
      - mysql-data:/var/lib/mysql
      - vendor_bundle:/app/vendor/bundle
      - bundle:/app/.bundle

  app:
    build:
      context: .
      dockerfile: Dockerfile-dev
    environment:
      MYSQL_USERNAME: root
      MYSQL_PASSWORD: password
      MYSQL_HOST: mysql
    depends_on:
      - mysql
    volumes:
      - .:/app
    volumes_from:
      - datastore
    tmpfs:
      /app/tmp/pids


  mysql:
    image: mysql
    environment:
      MYSQL_ROOT_PASSWORD: password
    ports:
      - '3306:3306'
    volumes_from:
      - datastore

Macの場合

  • ちなみにボリュームマウントが死ぬ程遅いので、何らかの工夫が必要
  • dinghyかdocker-syncで頑張る
    • どっちも辛い
  • Mac捨てるのがオススメ

俺の開発スタイル

  • 開発用Dockerfileでzshや各種コマンドを入れておく
  • docker-compose run --service-ports app zsh
  • シェルスクリプトで自分の.zshrcやpeco等をコピーしdocker exec zsh
  • ファイルの編集だけはホストマシンで行い、後は基本的にコンテナ内で操作する

set -e

container_name=$1

cp_to_container()
{
  if ! docker exec ${container_name} test -e $2; then
    docker cp -L $1 ${container_name}:$2
  fi
}

cp_to_container ~/.zshrc /root/.zshrc
if ! docker exec ${container_name} test -e /usr/bin/peco; then
  docker exec ${container_name} sh -c "curl -L -o /root/peco.tar.gz https://github.com/peco/peco/releases/download/v0.4.5/peco_linux_amd64.tar.gz && tar xf /root/peco.tar.gz -C /root && cp /root/peco_linux_amd64/peco /usr/bin/peco"
fi

docker exec -it ${container_name} sh -c "export TERM=${TERM}; exec zsh"

デプロイの前に

ECSの概念について


TaskDefinition

  • 1つ以上のコンテナ起動定義のセット
    • イメージ、CPUのメモリ使用量、ポート、ボリューム等
    • 物理的に同じノードで動作する
  • docker-composeの設定一式みたいなもの
  • kubernetesでいうPod

Task

  • TaskDefinitionから起動されたコンテナ群
  • 同一のTaskDefinitionから複数起動される

Service

  • Taskをどのクラスタでいくつ起動するかを定義する
  • ECSが自動でその数になるまで、コンテナを立てたり殺したりする
  • コンテナの起動定義はTaskDefinitionを参照する
  • コンテナが起動したノードをALBと自動で紐付ける
  • kubernetesにも似た概念がある

ECSへのデプロイの基本

  1. TaskDefinitionを更新
  2. Serviceを更新
  3. 後はECSに任せる

ecs_deploy

https://github.com/reproio/ecs_deploy

  • capistrano plugin
  • TaskDefinitionとServiceの更新を行う
  • Service更新後デプロイ状況が収束するまで待機する
  • 更新したTaskDefinitionのrevisionを他のタスクで参照できる
  • TaskDefinitionやServiceの定義はRubyのHashで行う
    • Hash化できれば何でも良いので、YAMLでもJSONでも

Why use Capistrano

  • 既存の資産が多数ある
    • slack通知のフックとか
  • デプロイのコマンドが変化しない
  • 設定ファイルの場所や定義も大きく変化しない

個人別ステージング環境

  • アプリサーバーだけで良いなら容易に実現可能
  • RDB等のデータストアを個別に持つなら色々難しい
  • 弊社はアプリサーバーだけ個別にデプロイ可能
  • データストアを弄る場合はフルセットの環境を使い、そこを占有する

インフラの準備

terraform等で以下のものを準備する

  • ALBを一つ用意する
  • 個人別のサブドメインをRoute53に定義
  • ALBのTarget Groupを個人別に定義
  • ALBのホストベースルーティングを定義

その後capistranoにmemberという環境を定義し、各メンバーが自分の名前でtarget_ groupやTaskDefinitionの名前を使ってデプロイ出来る様に諸々を変数化する


terraformの例

module "acm" {
  source = "../acm"
}

resource "aws_alb" "developers" {
  name = "developers"
  internal = false
  subnets = ["your-subnet-id"]
  security_groups = ["your-security-group-id"]
  idle_timeout = 120
}

resource "aws_alb_target_group" "per_developer" {
  count = "${length(var.users)}"

  name = "${element(var.users, count.index)}"
  port = 8080
  protocol = "HTTP"
  vpc_id = "your-vpc-id"
  deregistration_delay = 0

  health_check {
    path                = "/health_check"
    interval            = 45
    matcher             = 200
    unhealthy_threshold = 8
  }
}

resource "aws_alb_listener" "developers" {
  load_balancer_arn = "${aws_alb.developers.arn}"
  port = 443
  protocol = "HTTPS"
  ssl_policy = "ELBSecurityPolicy-2016-08"
  certificate_arn = "your-certificate-arn"

  default_action {
    target_group_arn = "${element(aws_alb_target_group.per_developer.*.arn, 0)}"
    type = "forward"
  }
}

resource "aws_alb_listener_rule" "per_developer" {
  count = "${length(var.users)}"

  listener_arn = "${aws_alb_listener.developers.arn}"
  priority     = "${count.index + 1}"

  action {
    target_group_arn = "${element(aws_alb_target_group.per_developer.*.arn, count.index)}"
    type = "forward"
  }

  condition {
    field  = "host-header"
    values = ["${element(var.users, count.index)}-*.example.com"]
  }
}

resource "aws_route53_record" "per_developer_app" {
  count = "${length(var.users)}"

  zone_id = "${var.zone_id}"
  name = "${element(var.users, count.index)}-app.${var.domain_name}"
  type = "A"
  alias {
    name = "${aws_alb.developers.dns_name}"
    zone_id = "${aws_alb.developers.zone_id}"
    evaluate_target_health = true
  }
}

デプロイ定義の例

set :rails_env, :staging
set :branch, ENV["GIT_SHA1"] || (`git rev-parse HEAD`).strip
set :slackistrano, false

raise "require DEVELOPER env" unless ENV["DEVELOPER"]

target_group_arn = Aws::ElasticLoadBalancingV2::Client.new.describe_target_groups(names: [ENV["DEVELOPER"]]).target_groups[0].target_group_arn

set :ecs_services, [
  {
    cluster: "staging-per-member",
    name: "#{ENV["DEVELOPER"]}-#{fetch(:rails_env)}",
    task_definition_name: "#{ENV["DEVELOPER"]}-staging",
    desired_count: 1,
    deployment_configuration: {maximum_percent: 100, minimum_healthy_percent: 0},
    load_balancers: [
      target_group_arn: target_group_arn,
      container_port: 8080,
      container_name: "nginx",
    ],
  },
]

Autoscale (近い内に不要になる話)

Fargate Tokyoリージョンはよ!

  • 現時点でECS ServiceのスケールとEC2のスケールは独立している
  • Service増やしてもEC2のノードを増やさないとコンテナを立てるところがない
  • 増やすのは簡単だが減らす時の対象をコントロールできない

というわけでデフォルトで良い方法がない。


ecs_deploy/ecs_auto_scaler

https://github.com/reproio/ecs_deploy

  • CloudWatchをポーリングして自分でオートスケールする :cry:
  • Serviceの数を制御し、EC2の数はServiceの数に合わせて自動で収束させる
  • スケールインの際は、コンテナが動作していないノードを検出して落とす
  • コンテナが止まるまではEC2のノードは落とさない

polling_interval: 60

auto_scaling_groups:
  - name: ecs-cluster-nodes
    region: ap-northeast-1
    buffer: 1 # タスク数に対する余剰のインスタンス数

services:
  - name: app-production
    cluster: ecs-cluster
    region: ap-northeast-1
    auto_scaling_group_name: ecs-cluster-nodes
    step: 1
    idle_time: 240
    max_task_count: [10, 25]

    scheduled_min_task_count:
      - {from: "1:45", to: "4:30", count: 8}
    cooldown_time_for_reach_max: 600
    min_task_count: 0
    upscale_triggers:
      - alarm_name: "ECS [app-production] CPUUtilization"
        state: ALARM
    downscale_triggers:
      - alarm_name: "ECS [app-production] CPUUtilization (low)"
        state: OK

ecs_auto_scaler自体もコンテナに

  • ecs_auto_scalerはシンプルなforegroundプロセス
  • 簡単なDockerfileでコンテナ化可能
  • こいつ自身もECSにデプロイする

まあ、Fargateで不要になると思う


コマンド実行とログ収集

ECSにおいて特定のノードにログインするというのは負けである rails runnerやrakeをSSHで実行とかやるべきではない そのサーバーの運用管理をしなければならなくなる


wrapbox

https://github.com/reproio/wrapbox

  • ECS用のコマンドRunner
    • 半端に汎用性を持たせようとしたんでコードが微妙に……
  • TaskDefinitionを生成、登録し、即起動する
  • 終了までステータスをポーリングし待機する
  • タスク起動権限はIAMでクラスタ単位で管理できる
  • 慣れるとSSHとかデプロイが不要でむしろ楽

default:
  region: ap-northeast-1
  container_definition:
    image: "<ecr_url>/app:<%= ENV["GIT_SHA1"]&.chomp || ENV["RAILS_ENV"] %>"
    cpu: 704
    memory: 1408
    working_directory: /app
    entry_point: ["prehook", "ruby /app/docker/setup.rb", "--"]
    environment:
      - {name: "RAILS_ENV", value: "<%= ENV["RAILS_ENV"] %>"}

wrapboxで実行したコマンドログの取得

  • papertrailにログを転送し、別スレッドでポーリングしてコンソールに流すことができる
  • 原理的に他のログ集約サービスでも実現可能だが、現在papertrailしか実装はない

default:
  # 省略
  log_fetcher:
    type: papertrail # Use PAPERTRAIL_API_TOKEN env for Authentication
    group: "<%= ENV["RAILS_ENV"] %>"
    query: wrapbox-default
  log_configuration:
    log_driver: syslog
    options:
      syslog-address: "tcp+tls://<papertrail-entrypoint>"
      tag: wrapbox-default

db:migrate

capistranoのhookを利用しwrapboxで実行する

def execute_with_wrapbox(executions)
  executions.each do |execution|
    runner = Wrapbox::Runner::Ecs.new({
      cluster: execution[:cluster],
      region: execution[:region],
      task_definition: execution[:task_definition]
    })
    parameter = {
      environments: execution[:environment],
      task_role_arn: execution[:task_role_arn],
      timeout: execution[:timeout],
    }.compact
    runner.run_cmd(execution[:command], **parameter)
  end
end

desc "execution command on ECS with wrapbox gem (before deployment)"
task :before_deploy do
  execute_with_wrapbox(Array(fetch(:ecs_executions_before_deploy)))
end

set :ecs_executions_before_deploy, -> do
  # ecs_deployの結果からTaskDefを取得
  rake = fetch(:ecs_registered_tasks)["ap-northeast-1"]["rake"]
  raise "Not registered new task" unless rake

  [
    {
      cluster: "app",
      region: "ap-northeast-1",
      task_definition: {
        task_definition_name: "#{rake.family}:#{rake.revision}",
        main_container_name: "rake"
      },
      command: ["db:ridgepole:apply"],
      timeout: 600,
    }
  ]
end

db:migrate -> ridgepole

  • migrateのupとdownがめんどい
  • 特に開発者用ステージング
  • ridgepoleならデプロイ時に実行するだけで、ほぼ収束する
  • エラーが起きたらslackにログを出して、手動で直す
  • productionはdiffがあればリリースを停止させる
  • diffを見てリリース担当者が手動でDDLを発行する

テストとCI

以下を参照。 https://speakerdeck.com/joker1007/dockershi-dai-falsefen-san-rspechuan-jing-falsezuo-rifang


コンテナを真面目に運用するためには、結構色々考えることがある

何かしら参考になれば幸いです