ECSを運用で使っていて難しいと思った点

ECSを触っていて今まで難しいと思ったことを雑にまとめておく。

タスクロールとタスク実行ロールの違い

ECSを長く触っているのに、いつも混乱する。

  • タスクロール
    • コンテナ内の権限
    • S3やSESなどの権限をつける
  • タスク実行ロール
    • コンテナ外の権限
    • ECRやParameter Storeの権限をつける

ECSのデプロイ時に静的ファイルが404になる

ECSを触った初期に遭遇した。 詳細は以下のQiitaの記事が分かりやすい。

参照: ECSのデプロイ時に一定確率で静的ファイルが404になる問題を回避する

回避する方法はいくつかある。

  • 静的ファイルをS3に置く
  • CodeDeployの OneAtATime を使う
  • CodeDeployのBlue/GreenとALBのスティッキーセッションを使う

知らないと気づくのは難しい。

ECSサービスを無停止で更新する手順

ECSサービスをTerraformで変更するとき、一部の設定は replace になる。 無停止で更新するために、新しいECSサービスを作成してターゲットグループをうまく切り替える必要がある。

  1. 新しいECSサービスを作成
  2. Host: dummy.exmaple.com のリスナールールに新ECSサービスを紐づける
    • デフォルトリスナーは旧ECSサービス
  3. 新旧のECSサービスを両方ともデプロイするようにデプロイを設定する
  4. リスナールールの Host を正しい値に変更
    • この時点で新ECSサービスにリクエストが流れる
  5. 旧ECSサービスを削除

手順が多くて面倒なので、リソースを作成する前にecs_serviceの全オプションをしっかり読んだ方が良い。

  • EC2 -> Fargate の移行
  • ECSデプロイ -> CodeDeploy の移行
  • Propagate tagsの設定

少なくとも、これらの作業をするときに必要になった。

CannotPullContainerError でECSタスクが無限に再起動する

ECRにDockerイメージをプッシュしないでタスク定義を更新・デプロイすると起きる。

deployment circuit breaker の設定しておくと良い。

参考: Amazon ECS deployment circuit breaker のご紹介

execute-command は最大2セッション

ecs execute-command でECSタスクに接続できるのは 2セッション まで。

複数人がECSタスクに接続する運用がある場合には注意が必要。

Terraformでタスク定義の最新を参照する

デプロイするとタスク定義のリビジョンが変わるので、ecs_task_definitionのコードを参考にコードを書く必要がある。

data "aws_ecs_task_definition" "mongo" {
  task_definition = aws_ecs_task_definition.mongo.family
}

resource "aws_ecs_cluster" "foo" {
  name = "foo"
}

resource "aws_ecs_task_definition" "mongo" {
  family = "mongodb"

  container_definitions = "<略>"
}

resource "aws_ecs_service" "mongo" {
  name          = "mongo"
  cluster       = aws_ecs_cluster.foo.id
  desired_count = 2

  # Track the latest ACTIVE revision
  task_definition = "${aws_ecs_task_definition.mongo.family}:${
    max(aws_ecs_task_definition.mongo.revision, data.aws_ecs_task_definition.mongo.revision)
  }"
}

CodeDeployを使っているとECSサービスのタグが更新できない

AWSコンソールからは普通にタグをつけられるが、Terraformからは更新できずに下記のエラーが起きる。

Unable to update network parameters on services with a CODE_DEPLOY deployment controller. Use AWS CodeDeploy to trigger a new deployment

詳細は調べられていないけど、AWS Providerのバグかも?

CodeDeploy Blue/Greenを使うとTarget Groupは交互に変わる

リソースを用意するときはTerraformだけど、デプロイは別で行う場合にターゲットグループがズレるケースがある。

  1. Terraformでターゲットグループ(TG-1, TG-2)を用意する
  2. Terraformで TG-1 をリスナールールに紐付ける
  3. CodeDeployでTG-1, TG2のデプロイ設定を行う
  4. CodeDeployでデプロイを行う
    • リスナールールに紐づくリソースが TG-1 => TG-2 に変わる
  5. Terraformで設定を変える
    • コード上は TG-1 がリスナールールに紐づいている
    • apply するとECSサービスの紐づいていない TG-1 に変わり障害になる

terraform plan の出力をちゃんと読めば気づける。

タスク定義みたいにTerraformのコードを工夫すれば回避できるのかな...私は知らないが...

GitHub Actions vs CodePipeline

どちらも触った経験があるけど、どちらも難しくて慣れが必要。

  • GitHub Actions: 日本語の記事が少ない、コード例が少ない
  • CodePipeline: GitHubの連携が面倒、慣れないとIAMとVPCでハマる

比較して考察している記事もあるので、読んでみると良い。

参考: ECS用のCDパイプラインに対する考察

Gemfileに記載してあるgemの説明を一覧で表示する #fjordbootcamp

追記: bundle info でgemの情報を表示できるので、一覧じゃなければコレで十分かも。

$ bundle info rails
  * rails (6.1.3.2)
    Summary: Full-stack web application framework.
    Homepage: https://rubyonrails.org
    Documentation: https://api.rubyonrails.org/v6.1.3.2/
    Source Code: https://github.com/rails/rails/tree/v6.1.3.2
    Changelog: https://github.com/rails/rails/releases/tag/v6.1.3.2
    Bug Tracker: https://github.com/rails/rails/issues
    Mailing List: https://discuss.rubyonrails.org/c/rubyonrails-talk
    Path: /app/vendor/bundle/ruby/3.0.0/gems/rails-6.1.3.2

タイトルに #fjordbootcamp がついていますが、これは #fjordbootcamp の課題とかではありません。

フィヨルドブートキャンプとは

bootcamp.fjord.jp

きっかけ

フィヨルドブートキャンプのGitHubリポジトリに以下のIssueが登録されていた。*1

github.com

これを読んでいて、ふと「gemspecから説明を抜き出して、一覧で表示できると便利そう」と思いついたので、試しに実装してみた。

コード

#!/usr/bin/env ruby
# frozen_string_literal: true

require 'bundler'

lockfile = Bundler.default_lockfile.read
parser = Bundler::LockfileParser.new(lockfile)

spec_per_name = parser.specs.group_by(&:name).transform_values(&:first)

parser.dependencies.keys.each do |name|
  spec = spec_per_name[name]

  if spec
    path = "#{Bundler.specs_path}/#{name}-#{spec.version}.gemspec"
    gemspec = Gem::Specification.load(path)
  end

  puts "#{name}: #{gemspec&.summary}"
end

実行結果

$ ./bundler-summaries
bootsnap: Boot large ruby/rails apps faster
byebug: Ruby fast debugger - base + CLI
capybara: Capybara aims to simplify the process of integration testing Rack applications, such as Rails, Sinatra or Merb
jbuilder: Create JSON structures via a Builder-style DSL
listen: Listen to file modifications
puma: Puma is a simple, fast, threaded, and highly concurrent HTTP 1.1 server for Ruby/Rack applications
rack-mini-profiler: Profiles loading speed for rack applications.
rails: Full-stack web application framework.
sass-rails: Sass adapter for the Rails asset pipeline.
selenium-webdriver: The next generation developer focused tool for automated testing of webapps
spring: Rails application preloader
sqlite3: This module allows Ruby programs to interface with the SQLite3 database engine (http://www.sqlite.org)
turbolinks: Turbolinks makes navigating your web application faster
tzinfo-data:
web-console: A debugging tool for your Ruby on Rails applications.
webdrivers: Easy download and use of browser drivers.
webpacker: Use webpack to manage app-like JavaScript modules in Rails

*1:フィヨルドブートキャンプでは学習で使うサービス自体がGitHubオープンソースになっている

SlackのGitHub Appをアップグレードするとdeployコマンドと通知が壊れる

先日、SlackのGitHub Appがアップグレードされました。

github.com

しかし、アップグレードすると /github deploy コマンドとデプロイ通知が 壊れる ため、業務で使っている場合はもう少し様子を見てからアップグレードした方が良さそうです。

READMEの記述

以下はREADMEの引用ですが、意図的に機能を消しているので復活しない可能性もある。

Removed deploy command and notification support: Today, the functionality provided by deploy command is very limited and doesn't address all the scenarios. We are removing deploy command and notifications support as part of this version. We want to relook at the scenarios and build a more holistic experience that customers need.

関連するIssue

github.com

github.com

GitHub(Legacy)を復活させる

https://<workspace>.slack.com/apps/A8GBNUWU8-github-legacy から再インストールすると復活できた。
workspace の部分は各ワークスペースの名前に直してください。

Rubyをインストールせず Docker だけを使ってrails newを実行する

rails new するときにDockerfileを使う必要は特にない。

むしろ開発時に使うDockerfileとは別物になるので、Dockerfileを作らない方が良いです。

コマンド

$ mkdir example_app
$ cd example_app
$ docker run --rm -v $(pwd):/app -w /app ruby:3.0.0 bash -c '\
    curl -fsSL https://deb.nodesource.com/setup_14.x | bash - \
    && apt-get update && apt-get install -y git nodejs \
    && npm install -g yarn \
    && gem i --no-document rails \
    && rails new .'

解説

--rm

一時的な実行でコンテナを残しておく必要がないのでつけてる。

-v $(pwd):/app -w /app

ホストPCのディレクトリをコンテナ内の /app にマウントし、そのディレクトリを作業ディレクトリに指定する。

ruby:3.0.0 bash -c '...'

rubyのイメージのbashを使ってコマンドを実行する。

curl -fsSL https://deb.nodesource.com/setup_14.x | bash - \
&& apt-get update && apt-get install -y git nodejs \
&& npm install -g yarn \

rails newすると自動的にbundle installyarn installが実行されるため、必要になるパッケージの類いを追加する。

gem i --no-document rails \
&& rails new .

railsのgemを追加した後、rails new . で作業ディレクトリ(/app)にファイルを作成する。

開発環境で使うDockerfileについて

開発時に使うDockerfileとdocker-compose.ymlは前回に記事を書いてある。

sinsoku.hatenablog.com

最後に

たぶんRubyを入れて、普通に rails new した方が楽だと思う。

Railsアプリの開発環境向けDockerfile + docker-compose.yml

人に説明するときに記事あると便利なので、開発環境向けのDockerfileとdocker-compose.ymlを書いておく。

Dockerfile

FROM ruby:3.0.0

WORKDIR /app

# Using Node.js v14.x(LTS)
RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash -

# Add packages
RUN apt-get update && apt-get install -y \
      git \
      nodejs \
      vim

# Add yarnpkg for assets:precompile
RUN npm install -g yarn

# Add Chrome
RUN curl -sO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
    && apt install -y ./google-chrome-stable_current_amd64.deb \
    && rm google-chrome-stable_current_amd64.deb

# Add chromedriver
RUN CHROME_DRIVER_VERSION=`curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE` \
    && curl -sO https://chromedriver.storage.googleapis.com/$CHROME_DRIVER_VERSION/chromedriver_linux64.zip \
    && unzip chromedriver_linux64.zip \
    && mv chromedriver /usr/bin/chromedriver
  • git: GitHubを参照してgemを入れるときに必要
  • nodejs + yarn: bin/rails assets:precompile で必要
  • vim: bin/rails credentials:edit で使う
  • Chrome + chromedriver: System Test で必要

docker-compose.yml

version: "3.8"
services:
  db:
    image: postgres:12-alpine
    volumes:
      - postgres:/var/lib/postgresql/data
    environment:
      POSTGRES_HOST_AUTH_METHOD: trust

  redis:
    image: redis:5-alpine
    volumes:
      - redis:/data

  web: &web
    build: .
    image: app:1.0.0
    stdin_open: true
    tty: true
    volumes:
      - .:/app:cached
      - bundle:/app/vendor/bundle
      - node_modules:/app/node_modules
      - rails_cache:/app/tmp/cache
      - packs:/app/public/packs
      - packs_test:/app/public/packs-test
    tmpfs:
      - /tmp
    environment:
      BUNDLE_PATH: "/app/vendor/bundle"
      BOOTSNAP_CACHE_DIR: "/app/vendor/bundle"
      WD_INSTALL_DIR: "/usr/local/bin"
      HISTFILE: "/app/log/.bash_history"
      EDITOR: "vi"
      DATABASE_URL: "postgres://postgres:postgres@db:5432"
      REDIS_URL: "redis://redis:6379/"
      RAILS_MASTER_KEY:
    depends_on:
      - db
      - redis
    command: ["bin/rails", "server", "-b", "0.0.0.0"]
    expose: ["3000"]
    ports: ["3000:3000"]
    user: root
    working_dir: /app

  worker:
    <<: *web
    command: ["bundle", "exec", "sidekiq"]
    expose: []
    ports: []

volumes:
  postgres:
  redis:
  bundle:
  node_modules:
  rails_cache:
  packs:
  packs_test:
image: app:1.0.0

imageに名前とバージョンをつけておくことで、バージョンをインクリメントすると docker-compose run のときに自動的にビルドが走る。

Dockerfileを変えたときにバージョンをインクリメントすることで、他の開発者が古いイメージを使い続けてしまう事を防ぐことができる。

stdin_open: true
tty: true

byebug などでデバッグできるようにするため。

volumes:
  - .:/app:cached
  - bundle:/app/vendor/bundle
  - node_modules:/app/node_modules
  - rails_cache:/app/tmp/cache
  - packs:/app/public/packs
  - packs_test:/app/public/packs-test

Docker for Mac遅い問題の対処。

ホストとの同期を減らすことで若干マシになる。遅いけど。

worker:
  <<: *web
  command: ["bundle", "exec", "sidekiq"]
  expose: []
  ports: []

sidekiqを使う場合、基本的な設定はwebと同じなのでエイリアスを使う。

expose, portsはworkerで不要なので上書きする。

使い方

初回の環境構築

Dockerイメージを作成して、 bin/setup を実行する。

$ docker-compose build
$ docker-compose run --rm web bin/setup

サーバの起動

$ docker-compose run --rm --service-ports web

実行すると http://localhost:3000 でアクセスできる。

Railsコマンド類の実行

$ docker-compose run --rm web bin/rails -T

テストの実行

$ docker-compose run --rm -e RAILS_ENV=test web bin/rails db:test:prepare
$ docker-compose run --rm -e RAILS_ENV=test web bin/rails test

コンテナ内で作業する

毎回コンテナの起動をすると遅いので、基本的にコンテナ内で作業した方が楽。

$ docker-compose run --rm web bash
root@b419978e89ec:/app# bin/rails --version
Rails 6.1.3

全てのvolumeを削除する

開発環境を一新したいときに使う。

$ docker-compose down --volumes

追加の説明

Dockerfileでbundle installは不要なのか?

Dockerイメージのビルドでは不要です。

bin/setup の実行で、マウントしてるvolumeにインストールされます。

なぜdocker-compose up webで起動しないのか?

docker-compose up で起動すると標準入力が使えなくなって、byebugでデバッグできなくなるため。

あと、コンテナを止めたときに稀にtmp/pids/server.pidが残ることがある。*1

コマンドが長い

bibendi/dipを使うか、bashエイリアスを使うと楽です。

開発用のイメージが大きい

alpineやmulti stage buildを使えば軽量化できるかもしれないですが、面倒だったので調べてないです。

開発環境用のDockerイメージが少し重くても、大して困らないため。

*1:原因の詳細は調べられていないですが、 docker-compose run では今まで起きていない。

assets:precompileの結果を1世代だけ残す

Sprocketsは assets:clean[0] しても1時間以内に作成したassetsは消してくれません。

参照: https://github.com/rails/sprockets/blob/v4.0.2/lib/sprockets/manifest.rb#L245

さらに assets:clean の後に webpacker:clean を実行してくれるけど、引数は伝搬してくれない。

参考: https://github.com/rails/webpacker/blob/v6.0.0.pre.2/lib/tasks/webpacker/clean.rake#L19-L21

webpacker:clean に引数を伝搬させる

webpackerが登録してるProcを削除し、 enhance でタスクを登録し直す。

# Rakefile

assets_clean = Rake::Task['assets:clean']
assets_clean.actions.reject! { |act| act.source_location[0].include?('webpacker/clean.rake') }
assets_clean.enhance(['webpacker:clean'])

assets:clean[keep,age] に対応させる

with_loggerやmanifestを使えるように binding を取得して頑張る。

# Rakefile

assets_clean = Rake::Task['assets:clean']
assets_clean.arg_names << :age
pos = assets_clean.actions.index { |act| act.source_location[0].include?('sprockets/rails/task.rb') }
act = assets_clean.actions[pos]
assets_clean.actions[pos] = lambda do |_t, args, **opts|
  keep = Integer(args.keep || act.binding.eval('self.keep'))
  age = Integer(args.age || 3600)

  act.binding.eval(<<~RUBY)
    with_logger do
      manifest.clean(#{keep}, #{age})
    end
  RUBY
end

まとめ

これにより bin/rails assets:precompile assets:clean[0] を実行すると、1世代だけassetsを残せる。

Herokuでカスタムドメイン(ルートドメイン)を使うためにCloudFrontを設定する

Route53で取得したカスタムドメインをHerokuで使うためにCloudFrontの設定をしたのでブログに書いておく。

コード例は foo.herokuapp.com で動くアプリケーションを foo-example.com でアクセスできるようにする設定です。

AWS側の設定

  • CloudFrontで使うacmはvirginiaで作成
  • ttlは0にしてキャッシュしない
    • /assets/packs はキャッシュした方が良いけど、まだ未対応
locals {
  foo_domain    = "foo-example.com"
  foo_origin_id = "foo-heroku"
}

module "acm" {
  source  = "terraform-aws-modules/acm/aws"
  version = "2.12.0"

  providers = {
    aws = aws.virginia
  }

  domain_name = local.foo_domain
  zone_id     = aws_route53_zone.main.zone_id
}

resource "aws_cloudfront_distribution" "foo" {
  enabled         = true
  is_ipv6_enabled = true
  aliases         = [local.foo_domain]

  origin {
    domain_name = "foo.herokuapp.com"
    origin_id   = local.foo_origin_id

    custom_origin_config {
      http_port              = 80
      https_port             = 443
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1", "TLSv1.1", "TLSv1.2"]
    }
  }

  default_cache_behavior {
    target_origin_id       = local.foo_origin_id
    allowed_methods        = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"]
    cached_methods         = ["GET", "HEAD"]
    compress               = true
    default_ttl            = 0
    max_ttl                = 0
    viewer_protocol_policy = "redirect-to-https"

    forwarded_values {
      headers      = ["*"]
      query_string = true

      cookies {
        forward = "all"
      }
    }
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn      = module.acm.this_acm_certificate_arn
    minimum_protocol_version = "TLSv1.2_2019"
    ssl_support_method       = "sni-only"
  }
}

Heroku側の設定

カスタムドメインを登録しておく。

$ heroku domains:add foo-example.com