1
/
5

DELISH KITCHENをECS移行した話(前編)

挨拶

エブリーの内原です。DELISH KITCHENのサーバサイドを担当していて、APIサーバの開発と運用、プッシュ通知まわりなどの業務を行っています。

簡単に自分のバックグラウンドを紹介しますと、古くは某パソコン通信サービスの開発、その後ヤフーで社内プラットフォームの開発、今は無き頓智ドットセカイカメラというサービスの開発、などをしていました。
その後縁あってエブリーに入社しDELISH KITCHENの開発を担当することになりました。

今日はそういった業務の中から、インフラに関連する内容をお話しようと思います。Amazon Elastic Container Service(ECS)を用いた運用です。みなさんの参考になれば幸いです。

最近 AWS Fargateが発表されたのでそれも絡めたお話ができればよかったのですが、この対応をしたのは数ヶ月前のことなのでご容赦を。

以前の構成について

自分がエブリーに入社した時のインフラ環境まわりは概ね以下のようになっていました。

インフラ

  • AWS EC2
  • AWS RDS
  • AWS ElastiCache

ソフトウェア

  • delish kitchen api server(api-server)
    • DELISH KITCHENのAPIサーバ本体(Golang)
  • fluentd
    • APIログ転送用
    • 同一ホスト内にあるファイルベースでログをスキャンし転送
  • datadog agent(dd-agent)

プロビジョニングツール

  • Ansible

CI環境

  • CircleCI
  • ブランチベースのテストでのみ利用(後述)

デプロイ方法

  • デプロイサーバにsshログインし、ansible-playbookコマンドで適用

以前の構成における問題点

この環境には以下のような問題があると認識していました。

Ansibleの設定ファイルが複雑になる

1つのインスタンスに複数のrole(api-server、fluentd、dd-agent、その他)を与えることになる関係で、設定ファイルを一見した程度ではなにをやっているかを把握することが難しい状況でした。

Ansibleによる環境変更の確認に時間がかかる

なにか環境に修正を行う場合、

  1. Ansible設定ファイルを修正
  2. ansible-playbookで適用
  3. 確認

という流れになりますが、やりたいことは単に1つの外部パッケージを追加することだけなのに、適用すると全ての項目についてチェック+反映しようとするので、try&errorをするにも時間がかかります。

デプロイ手段が手動

上記の通り、デプロイにはsshログインしてコマンドラインを実行する必要があり、そのコマンドラインを間違うと誤ったデプロイを実施してしまう可能性がありました。それを防ぐために二重チェックするような体制にはなっていましたが、面倒だし、なにより自動化できていないのが問題でした。

なお、CircleCIは利用していましたが、自動テスト実行によるチェックのみ行っており、デプロイはしていない状況でした。

改善案

上記の問題点を受けて、以下のような条件を満たす構成を新たに構築することになりました。

  • 各環境の設定内容が一見して分かること
  • 環境変更の確認が容易であること
  • デプロイが自動化されていること

新環境の概要

結局、新しい構成は以下のようにしました。
この構成によって、上記の要件を満たす環境にすることができました。

Dockerコンテナ化

api-server, fluentd, dd-agentの各ソフトウェアはDockerコンテナとして動作させることにしました。
コンテナ化しておくと、個々のコンテナの構成を最小限にすることができ、またプロダクション環境とローカル環境とを原則同一化させることが可能なので、今回の問題解決に都合がよかったためです。

ECSの利用

Dockerコンテナ化しただけでは、そのコンテナを実際に稼働&運用させるにはいろいろと困難が伴うため、いわゆるオーケストレーションツールの利用を行うことにしました。
世の中には様々なオーケストレーションツールがありますが、今回はECSを導入することにしました。

なお、以前Kubernetesで運用した経験もあったので今回もそうしようかと思ったのですが、ECSのほうが比較的、構成がシンプルで覚えることが少ない傾向があるため、環境移行時のトラブルを最小化できるかなという期待で選びました。

新構成図

新環境の内容

上記構成を実現するための実際の定義は以下のようになっています。

api-server Dockerfile

FROM golang:1.9.2-alpine

# goコマンドでビルドできる環境を用意
RUN apk add --update --no-cache \
        ca-certificates \
        git \
 && update-ca-certificates \
 && apk add --no-cache --virtual .build-deps \
        curl \
 && curl -s -L https://github.com/Masterminds/glide/releases/download/v0.12.3/glide-v0.12.3-linux-amd64.tar.gz | tar -C /tmp -zxvf - \
 && cp /tmp/linux-amd64/glide /usr/local/bin/glide \
 && apk del .build-deps \
 && rm -rf /var/cache/apk/* \
           $GOPATH/src/* \
           /tmp/* \
 && mkdir -p $GOPATH/src/github.com/everytv

# プロジェクト配下のファイルをコピー
COPY ./ $GOPATH/src/github.com/everytv/delish-server

# golang用外部パッケージのインストールとビルドを行い、
# その後イメージを小さくするため外部パッケージ用ソースコードなどを削除
RUN cd $GOPATH/src/github.com/everytv/delish-server \
  && glide install \
  && go build \
  && cp api-server /usr/local/bin \
  && mkdir -p /var/log/delish \
  && rm -rf $GOPATH/src/github.com/everytv/delish-server

ADD docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

EXPOSE 1323
CMD ["/usr/local/bin/docker-entrypoint.sh"]
WORKDIR /

api-server task definition

元々の構成は、各インスタンス内にfluentdを常駐させ、ファイルベースでログを転送していました。
Dockerコンテナ上でファイルをやり取りするのは、不可能ではありませんがボリューム共有などの設定が必要となり面倒です。
よって今回はTCP/IPによるforwardingを採用しました。

ログをfluentdコンテナによって転送するためには、fluentdの待機ポートにログを送信する必要があります。
幸い、Dockerにはlogging driverという機構が存在し、標準でfluentd driverをサポートしています。
またECSも(いくつかの手順が必要ですが)fluentd driverに対応していますので、これを用いることにします。

環境変数 DK_LOG_STDOUT を参照してログを標準出力に書き出すようにしているので、結果的にdockerがログをfluentd driver経由でfluentdに転送します。

またリソース制限については、CPU制限はなし、メモリはソフトリミットのみ定義します。

ソフトリミットにしておけば、ECSのリソース管理としては該当数値が計上されますが、実際にはインスタンスで利用可能なメモリ容量まで利用できます。

よってインスタンスのメモリを食いつぶしてしまったりする可能性もなくはないのですが、インスタンス自体のリソース使用量は別途監視機構で把握するので、それを見て必要そうになったらハードリミットを導入するくらいのスタンスです。

{
  "containerDefinitions": [
    {
      "logConfiguration": {
        "logDriver": "fluentd",
        "options": {
          "fluentd-address": "fluentd.delishkitchen.tv:24224",
          "fluentd-max-retries": "3",
          "tag": "docker.delish"
        }
      },
      "portMappings": [
        {
          "hostPort": 1323,
          "protocol": "tcp",
          "containerPort": 1323
        }
      ],
      "environment": [
        {
          "name": "GO_ENV",
          "value": "production"
        },
        {
          "name": "DK_LOG_STDOUT",
          "value": "1"
        }
      ],
      "memory": null,
      "memoryReservation": 512,
      "image": "1234567890.dkr.ecr.ap-northeast-1.amazonaws.com/delish-server:899",
      "name": "api-server"
    }
  ],
  "family": "delish-server",
  "networkMode": "bridge"
}

api-server service

本来は、Load BalancerとしてApplication Load Balancer(ALB)を使いたい考えでした。Classic Load Balancer(CLB)に比べ、ポート番号の動的割当が可能だったり、パス部分によるルーティングが可能だったりと利点が多いためです。

ただ、諸々の事情からALBを用いることができず、CLBを用いている状態です。
このため、CLBに割り当てるtask definitionはポートが固定されている必要があり、結果として1つのインスタンス上には最大1つしかapi-serverコンテナを起動することができない制約が発生しました。

よって、blue/green deploymentを実現するにはインスタンスが最低2つ必要ということになります。

fluentd Dockerfile

バージョンはv0.12をベースにいくつかプラグインを追加します。
fluentdは設定ファイル中に環境変数が利用できるので、各環境用の設定ファイルは同一にすることができます。

FROM fluent/fluentd:v0.12

COPY fluent.conf /fluentd/etc/fluent.conf

RUN fluent-gem install --no-doc --no-ri \
    fluent-plugin-s3 \
    fluent-plugin-td \
    fluent-plugin-forest \
    fluent-plugin-rewrite-tag-filter \
    fluent-plugin-multi-format-parser

fluent.conf

# 24224ポートで待ち受け
<source>
  @type forward
  port 24224
  bind 0.0.0.0
</source>

<filter docker.delish>
  @type parser
  format json
  key_name log
  reserve_data false
  time_format %Y-%m-%dT%H:%M:%SZ
</filter>

# "log_type":"EVENT" のデータはイベントログとして扱う
<match docker.delish>
  @type rewrite_tag_filter
  <rule>
    key log_type
    pattern EVENT
    tag delish.event
  </rule>
</match>

# delish.event.<イベント名称>を新たなtagにする
<match delish.event>
  type rewrite_tag_filter
  <rule>
    key event_type
    pattern ^([\w_]+)$
    tag ${tag}.$1
  </rule>
</match>

# イベントログをS3とtreasure dataに転送
<match delish.event.**>
  type forest
  subtype copy
  <template>
    <store>
      type s3
      format json
      include_time_key true
      time_format %Y-%m-%d %H:%M:%S
      aws_key_id "#{ENV['AWS_ACCESS_KEY']}"
      aws_sec_key "#{ENV['AWS_SECRET_ACCESS_KEY']}"
      s3_bucket "#{ENV['AWS_S3_BUCKET']}"
      s3_region ap-northeast-1
      s3_object_key_format %{path}%{time_slice}_%{index}_%{hostname}.%{file_extension}
      path logs/delish-event/${tag_parts[2]}/
      buffer_path /fluentd/log/buf-delish-event-${tag_parts[2]}
      time_slice_format %Y/%m/%d/%H
      flush_interval 600s
      buffer_chunk_limit 128m
    </store>
    <store>
      type tdlog
      database "#{ENV['TD_DATABASE']}"
      table event_${tag_parts[2]}
      apikey "#{ENV['TD_API_KEY']}"
      buffer_path /fluentd/log/td-buf-delish-${tag_parts[2]}
      flush_interval 10s
    </store>
  </template>
</match>

fluentd task definition

設定ファイルに与える環境変数を定義しています。

またログ転送時にはバッファファイルを用いますが、ファイルがコンテナ上にしか存在しないと、コンテナが停止した場合にログデータも失われることになるため、インスタンス上のディレクトリをボリューム共有しておきます。
こうすることで、インスタンスが再起動した場合に未処理のログを転送再開することが可能となります。

{
  "containerDefinitions": [
    {
      "portMappings": [
        {
          "hostPort": 24224,
          "protocol": "tcp",
          "containerPort": 24224
        }
      ],
      "environment": [
        {
          "name": "AWS_ACCESS_KEY",
          "value": "..."
        },
        {
          "name": "AWS_S3_BUCKET",
          "value": "..."
        },
        {
          "name": "AWS_SECRET_ACCESS_KEY",
          "value": "..."
        },
        {
          "name": "TD_API_KEY",
          "value": "..."
        },
        {
          "name": "TD_DATABASE",
          "value": "..."
        }
      ],
      "mountPoints": [
        {
          "readOnly": null,
          "containerPath": "/fluentd/log",
          "sourceVolume": "fluentd_log"
        }
      ],
      "memory": null,
      "memoryReservation": 512,
      "image": "1234567890.dkr.ecr.ap-northeast-1.amazonaws.com/delish-fluentd:13"
    }
  ],
  "volumes": [
    {
      "name": "fluentd_log",
      "host": {
        "sourcePath": "/var/log/delish-fluentd"
      }
    }
  ]
}

fluentd service

fluentdへのバランシングは、Load BalancerとしてNetwork Load Balancer(NLB)を用いることにしました。
ただ結論から言うと、ECSからNLBにうまく連携させることができず、一部制限事項のある構成になっています。
詳細については、後述の制限事項をご参照ください。

ECSクラスタ UserData script差分

クラスタを新規作成するとCloud Formationの設定が自動生成されますが、ここに以下の設定を追加しています。

デフォルトではECSからfuentd log driverを用いることができないため、設定を変更します。

dd-agentはインスタンスごとに1つ起動すればよいため、インスタンスの起動時にtaskも起動するようにします。

--- ecs-templ-origin.txt    2017-09-28 10:58:53.000000000 +0900
+++ ecs-templ2.txt    2017-11-17 17:04:19.000000000 +0900
@@ -277,7 +277,17 @@
            echo ECS_BACKEND_HOST=${EcsEndpoint} >> /etc/ecs/ecs.config
         - Fn::Base64: !Sub |
            #!/bin/bash
+           cluster=${EcsClusterName}
+           task_def="dd-agent-task"
            echo ECS_CLUSTER=${EcsClusterName} >> /etc/ecs/ecs.config
+           echo 'ECS_AVAILABLE_LOGGING_DRIVERS=["json-file","syslog","fluentd"]' >> /etc/ecs/ecs.config
+           start ecs
+           yum install -y aws-cli jq
+           instance_arn=`curl -s http://localhost:51678/v1/metadata | jq -r '. | .ContainerInstanceArn' | awk -F/ '{print $NF}'`
+           az=`curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone`
+           region=ap-northeast-1
+           echo "cluster=$cluster az=$az region=$region aws ecs start-task --cluster \
+           $cluster --task-definition $task_def --container-instances $instance_arn --region $region" >> /etc/rc.local
   EcsInstanceAsg:
     Type: AWS::AutoScaling::AutoScalingGroup
     Condition: CreateWithASG

CircleCI設定

以下のような設定を行い、developまたはmasterブランチへのマージ時に、対応する環境へのデプロイを実施します。

version: 2
jobs:
  build:
    executorType: docker
    steps:
      - checkout
      - setup_remote_docker
      - run:
          name: run test
          command: |
            make test
      - run:
          name: build docker image and deploy to ecs
          command: |
            if [ "$CIRCLE_BRANCH" = "develop" -o "$CIRCLE_BRANCH" = "master" ]; then
              curl "https://bootstrap.pypa.io/get-pip.py" -o "get-pip.py"
              sudo python get-pip.py
              sudo pip install awscli
              curl -sL https://github.com/silinternational/ecs-deploy/archive/3.2.tar.gz | tar zxvf -
              if [ "$CIRCLE_BRANCH" = "master" ]; then
                env=production
              else
                env=development
              fi

              # deploy on ecs
              USER=circleci ops/deploy-ecs.sh -e $env -t $CIRCLE_BUILD_NUM -k $AWS_ACCESS_KEY_ID -s $AWS_SECRET_ACCESS_KEY -c ecs-deploy-3.2/ecs-deploy -u $CIRCLE_BUILD_URL -C
            fi

デプロイスクリプト

プロダクション環境や社内検証環境へのデプロイを行うスクリプトです。
ECSへのデプロイにはecs-deployコマンドを利用しています。

このスクリプトでは以下の内容を行います。

  • Dockerイメージのビルド
  • DockerイメージのECRへのアップロード
  • 該当環境用のECSサービスを今回作成したイメージに置き換えてデプロイ
#!/bin/sh

set -e

args=`getopt e:t:c:k:s:u:C $*`

if [ $? != 0 ]; then
  echo "usage `basename $0` [options]"
  echo "options:"
  echo "  -e <enviroment-name>"
  echo "     enviroment name like above:"
  echo "     - \$USER (default)"
  echo "     - development"
  echo "     - production"
  echo "  -t tag"
  echo "     image tag name."
  echo "     - YYYYMMDDhhmmss (default)"
  exit 1
fi
set -- $args

tag=`date +%Y%m%d%H%M%S`
for opt do
  case "$opt" in
    -e)
      env=$2; shift; shift;;
    -t)
      tag=$2; shift; shift;;
    --)
      shift; break;;
  esac
done

. ops/setup-vars

case "$env" in
  production)
    image_prefix=
    update_latest="1"
    ;;
  development)
    image_prefix="develop-"
    update_latest="1"
    ;;
  *)
    image_prefix="${env}-"
    update_latest="0"
esac

image_name=1234567890.dkr.ecr.ap-northeast-1.amazonaws.com/delish-server

docker build -t delish-server .

`aws ecr get-login --region ap-northeast-1 --no-include-email`

docker tag delish-server $image_name:${image_prefix}$tag
docker push $image_name:${image_prefix}$tag
if [ "$update_latest" = "1" ]; then
  docker tag delish-server $image_name:${image_prefix}latest
  docker push $image_name:${image_prefix}latest
fi

ecs-deploy -r ap-northeast-1 -k $AWS_ACCESS_KEY_ID -s $AWS_SECRET_ACCESS_KEY -c $ecs_cluster -n $service_name -i $image_name:${image_prefix}$tag

移行作業について

さて、上記のように新環境を構築することができましたが、旧環境から移行させなければ意味がありません。
移行の方法についてはいくつか考えられましたが、一番トラブルの少なそうな方式を採用しました。

すなわち、全体のトラフィックを割合で分散し、徐々に旧環境から新環境に移行させるというものです。

負荷試験などは別途行っていたものの、実際のユーザによるトラフィックを全て問題なく処理できるという実績はまだない状況でしたので、いきなり100%のトラフィックを食わせるのは若干怖いです。

このため、最初は全体の数%だけアクセスを流入させ、徐々に割合を増やしていき、最終的に全トラフィックが問題なく処理できることが分かったタイミングで旧環境は停止する、という対応を採ることにしました。

移行準備

まず、トラフィック分散方法については、古くからあるDNS Round Robinを採用することにしました。
枯れた手法ですし、特別新たに構築しなければならないコンポーネントもありません。

DELISH KITCHENではDNSにAWS Route53を利用しているため、この仕様に合わせて作業を行います。
当初はELBに対するAliasという設定になっており、このままではDNS RRできないため、設定変更するところから始めました。

具体的には以下の手順で実施しました。

  1. api.delishkitchen.tv CNAME <旧環境ELB> 60
    に変更します。Routing PolicyをWeighted, Weight値は90としておきます
  2. api.delishkitchen.tv CNAME <新環境ELB> 60
    を新たに作成します。Routing PolicyをWeighted, Weight 10としておきます。これにより、全体のトラフィックのうち10%が新環境に流入してきます
  3. 新環境の負荷状態を確認します。インスタンスのCPU負荷、メモリ負荷、ELBの5xxエラーレート、エラーログの有無などを確認し、仮にこの状態で処理量が10倍になっても問題がないかを判定します
  4. 問題がないようならばWeightを調整し、徐々に新環境へのトラフィックを増加させ、逆に旧環境のトラフィックを減少させます
  5. 最終的にDNSエントリのWeightが新環境:旧環境の割合を100:0にします
  6. 旧環境用のCNAMEエントリを削除します
  7. 新環境用のCNAMEエントリをELBに対するAliasに変更します
  8. 旧環境のインスタンスを停止します
    • この際、監視等でインスタンス死活をチェックしている場合、事前にチェックを無効化しておかないとアラートが送出されるので対応しておきます

これでようやく移行が完了しました。

結局、当初の問題は解決したか?

以下の通り、当初問題とされていた項目をそれぞれ解消することができました。

Ansibleの設定ファイルが複雑になる問題

環境構築における設定は、以下のファイルで表現されるようになり、個々のコンポーネントは最低限の情報しか持っていないため、把握は容易になりました。

  • 各コンテナのDockerfile
  • ECSのtask definition, service定義

Ansibleによる環境変更の確認に時間がかかる問題

各処理はDockerコンテナとして稼働しているわけですから、ローカルで検証する場合もDocker経由で起動すれば原理的に同一の環境になるはずです。

よって、環境に修正を行う場合も、

  1. Dockerfile修正
  2. Dockerビルド実行
  3. Dockerイメージからコンテナ起動

というフローを実施することになります。

正直、Dockerfileでやっていることが多ければ多いほどビルドにそれなりの時間がかかることになり、爆速というわけにはいきませんでした。
ただDockerビルドの場合、中間イメージを予め作成しておくことで高速化を図ることができます。

デプロイ手段が手動問題

デプロイ用スクリプトが用意されたことで、CircleCIの設定ファイルからデプロイできるようになりました。

制限事項

fluentd用のNetwork Load Balancer設定

fluentd用のLoad BalancerとしてNLBを使用していますが、本来ならばECSのサービスと連動させたい考えでした。
ただ実際にはなぜか正しく動作せず、NLBとの通信がタイムアウトしてしまう状態でした。

具体的には以下のような設定を行いました。

  • Network Load Balancer
    • Internal Load Balancerとして設定
    • リスナーポートは24244
  • Target Group
    • プロトコル TCP
    • ポート番号 24224
    • ターゲット種別 instance
    • ヘルスチェックプロトコル TCP
    • ヘルスチェックポート トラフィックポート

さらに、Internal Load Balancerはセキュリティグループを持たないため、各インスタンスに対してNLBからアクセス可能とする場合、VPCのCIDRで許可する必要があるとのことなので、fluentdクラスタのセキュリティグループに対し、以下のインバウンド設定を行いました。

  • 送信元
    • VPC CIDR
    • fluentdにログを転送するクライアントも同一VPC内であるため、上記ドキュメント内のクライアント IP アドレスとVPC CIDRを兼ねている
  • ポート範囲
    • 24224
    • インスタンスリスナーとヘルスチェックポートは同一なので兼ねている

結局どうなるか?というと、以下のような挙動になります。

  1. fluentdコンテナが起動するとターゲットグループにインスタンスが自動登録される
  2. インスタンス内のコンテナに対しヘルスチェックが通りhealthyになる

これでクライアント側からNLBに疎通できるかと思いきや、実際にやってみるとタイムアウトしてしまいました。

調べたのですが結局原因が分からなかったため、ターゲットグループの種別をinstanceでなくipとし、fluentdクラスタのインスタンスIPアドレスを直接登録することで疎通できるようになりました。

ただしこの方法だと、ECSクラスタはNLBの存在を把握していないので、クラスタ内インスタンスを増減した場合には別途、ターゲットグループにインスタンスを登録/解除する必要があります。

株式会社エブリーでは一緒に働く仲間を募集しています
17 いいね!
17 いいね!

同じタグの記事

今週のランキング

内原 章さんにいいねを伝えよう
内原 章さんや会社があなたに興味を持つかも