このエントリは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に露出する
- 値の種類が多いと環境変数管理する場所が必要になる
- コンテナ起動時に起動環境の権限で複合化できると良い
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
- ちなみにボリュームマウントが死ぬ程遅いので、何らかの工夫が必要
- 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へのデプロイの基本
- TaskDefinitionを更新
- Serviceを更新
- 後はECSに任せる
ecs_deploy
https://github.com/reproio/ecs_deploy
- capistrano plugin
- TaskDefinitionとServiceの更新を行う
- Service更新後デプロイ状況が収束するまで待機する
- 更新したTaskDefinitionのrevisionを他のタスクで参照できる
- TaskDefinitionやServiceの定義はRubyのHashで行う
- 既存の資産が多数ある
- デプロイのコマンドが変化しない
- 設定ファイルの場所や定義も大きく変化しない
個人別ステージング環境
- アプリサーバーだけで良いなら容易に実現可能
- 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
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
コンテナを真面目に運用するためには、結構色々考えることがある
何かしら参考になれば幸いです