タイミーのブログ - TECH PLAY

TECH PLAY

タイミー

タイミー の技術ブログ

302

こんにちは、 タイミーデリバリー 開発チームの宮城です。 今回は弊社のOpenAPI3ベースの スキーマ 駆動開発の運用方法を紹介します。 TL;DR 技術スタックは OpenAPI3, Swagger UI, Committee, ActiveModelSerializers Committeeを利用してOpenAPI準拠のRequest Specを行う OpenAPI3のrequiredキーワードに注意する 背景 タイミーデリバリーでは、 Rails による API サーバーと、Web管理画面としてVue.jsによるSPA、ユーザー向け iOS アプリとしてSwiftを採用しています。 1つの モノリス な Rails で利用者別にネームスペースを区切り、それぞれエンドポイントを提供しています。 サーバーサイドとクライアントサイドを分離し並行して開発を進めるために スキーマ 駆動開発を導入しました。 スキーマ 駆動開発の詳しい説明やメリットについては既に多くの記事が存在するため、ここでは参考にさせていただいた記事の紹介までとさせていただきます。 RubyKaigi 2019でOpenAPI 3について登壇しました - おおたの物置 スキーマファースト開発のススメ - onk.ninja スキーマ駆動開発のススメ - Studyplus Engineering Blog この記事では、実際に スキーマ 駆動開発を開発フローに導入する際のTipsを記したいと思います。 技術スタックは ActiveModelSerializers, OpenAPI3, Swagger UI, Committee Rails で API サーバーを構築するにあたって、 json の生成には ActiveModelSerializers を採用しました。 スキーマ 定義には OpenAPI3 を採用し、 Swagger UI でドキュメントを閲覧できるようにしています。 この3つに関しては近年では割とよくある一般的な技術スタックかなと思っています。 スキーマ 定義を記述する YAML ファイルは Rails の リポジトリ に配置しています。 . ├── app/ ... アプリケーションのメインのソースコード ├── bin/ ├── config/ ├── db/ ├── etc/ │ └── docs/ │    ├── docker-compose.yml │    ├── README.md │    └── swagger/ │    └── swagger.yml ルート直下のetc ディレクト リにはアプリケーションの実行には直接は関係ないものをまとめており、Dockerイメージのビルド時にdockerignoreしています。 スキーマ 定義以外ではデプロイの設定が入ってたりします。 スキーマ 定義は分割していない スキーマ 定義は swagger.yml に全て記載しており、 YAML の分割などは一旦していません。 別のプロジェクトで細かく分割して ディレクト リを分け、jsで ディレクト リを監視しマージする運用をしていたことがありましたが、マージする仕組みを新規開発者に理解してもらうコストがかなり高く、運用しづらかった経験がありました。 今回は新規プロジェクトで API の数も少ないため、一旦1つの YAML ファイルのみで運用してみています。今の所チームメンバーからの不満は少ないですが、 ios とvueでネームスペースを切っているのでそれくらいは分けてもいいかなと思っています。 Docker ComposeでSwagger UIを立ち上げる Swagger UIを利用したドキュメントの閲覧方法は、どこかに ホスティング したりCircleCIのartifactsを使ったりはせず、Docker Composeで開発者自身のローカルで立ち上げるようにしています。 チームメンバーがDockerでの開発に慣れていたためこの選択をしました。 docker-compose.ymlはこんな感じです。 Swagger UIを立ち上げるdocker-compose.yml version : '3' services : redoc : image : redocly/redoc:latest container_name : redoc volumes : - ./swagger:/usr/share/nginx/html/swagger environment : SPEC_URL : swagger/swagger.yml ports : - "8081:80" doc : image : swaggerapi/swagger-ui:latest container_name : doc volumes : - ./swagger:/usr/share/nginx/html/swagger environment : API_URL : swagger/swagger.yml ports : - "8080:8080" Swagger UIと Redoc が立ち上がります。 RedocもOpenAPIベースのドキュメント表示ツールで、開発者が自由にどちらでも見ていいことにしています。 Committeeを利用してOpenAPI準拠のRequest Specを行う スキーマ 駆動開発では、開発を始める前にまず スキーマ を定義しそれを信頼することが求められます。 しかし起こりうる課題として スキーマ と実装の乖離があります。 スキーマ の信頼性が失われると結局サーバーサイド開発者とクライアントサイド開発者の無駄なコミュニケーションが発生し、 スキーマ は形骸化してしまいます。 Rails の開発においてこの問題を防ぐために、committeeとcommittee- rails というGemを採用しました。 github.com github.com committeeは、OpenAPIで定義した スキーマ を利用してリク エス トとレスポンスの検証を行う ミドルウェア を提供してくれます。 committee- rails はラッパーライブラリで、 rails での導入を簡単にしてくれます。 committee- rails は rails_helper.rb に以下の記述を追記することで導入できます。 # configured for committee-rails config.add_setting :committee_options config.committee_options = { schema_path : Rails .root.join( ' etc ' , ' docs ' , ' swagger ' , ' swagger.yml ' ).to_s, } include Committee :: Rails :: Test :: Methods スキーマ が配置されているパスを設定しています。 committeeを導入する前は、元々OpenAPIの定義は Rails とは別の リポジトリ として用意していました。実装のコミットと スキーマ 定義のコミットは分けた方が見通しがいいだろうと思ってのことです。 しかしcommitteeを導入するとなるとprivateの別 リポジトリ の ソースコード を読みに行くのは非常に面倒になってしまうので、上記のメリットだけであるならば Rails の リポジトリ にOpenAPI定義まで含めた方が楽だろうということで、 Rails の リポジトリ に統合することにしました。運用上困ることは特に起きていません。 committeeを導入することで、 rspec のrequest specで assert_response_schema_confirm が使えるようになります。 例えばUser自身のリソースを返すエンドポイントがあったとします。(認証などは除いています) paths : '/users' : get : tags : - user summary : User自身の情報 description : 名前とメールアドレスの取得 responses : '200' : content : application/json : schema : $ref : '#/components/schemas/UserSchema' components : schemas : UserSchema : type : object required : - user_id - family_name - given_name - email properties : user_id : type : integer example : 1 family_name : type : string example : 田中 maxLength : 255 given_name : type : string example : 太郎 maxLength : 255 email : example : hoge@example.com type : string maxLength : 255 Rails 側で対応するserializerとcontrollerはこんな感じになります。 # app/serializers/user_serializer.rb class UserSerializer < ApplicationSerializer attribute :id , key : :user_id attributes :family_name , :given_name , :email end # app/controllers/users_controllers.rb class UsersController < ApplicationController def show render json : current_user, serializer : UserSerializer end end request specはこんな感じです。 RSpec .describe ' Users ' , type : :request do describe ' #show GET ' do let( :user ) { FactoryBot .create( :user ) } subject { get(user_v1_users_me_path) } before { subject } context ' when success ' do let( :return_http_status ) { :ok } it ' return expected status ' do expect(response).to have_http_status return_http_status end it ' return expected body schema ' do assert_response_schema_confirm end end end end assert_response_schema_confirm を利用することで、OpenAPI定義に準拠しているかをチェックすることができます。 この時点ではテストは通ります。 ここから、例えばUserSchemaに誕生日が追加されたとします。 components : schemas : UserSchema : type : object required : - userId - family_name - given_name - email - birthday properties : user_id : type : integer example : 1 family_name : type : string example : 田中 maxLength : 255 given_name : type : string example : 太郎 maxLength : 255 email : example : hoge@example.com type : string maxLength : 255 birthday : example : 1990-01-01 type : string format : date Serializerにbirthdayは定義していないため、このまま再度Request Specを実行するとテストは通らなくなります。 .F Failures : 1 ) Users #show GET when success return expected body schema Failure / Error : assert_response_schema_confirm Committee :: InvalidResponse : #/components/schemas/UserApi::UserSchema missing required parameters: birthday # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/operation_wrapper.rb:35:in `rescue in validate_response_params' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/operation_wrapper.rb:30:in `validate_response_params' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/response_validator.rb:20:in `call' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3.rb:38:in `response_validate' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/test/methods.rb:27:in `assert_response_schema_confirm' # ./spec/requests/user/v1/users/me_request_spec.rb:32:in `block (4 levels) in <main>' # /usr/local/bundle/gems/bootsnap-1.4.6/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:55:in `load' # /usr/local/bundle/gems/bootsnap-1.4.6/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:55:in `load' # /usr/local/bundle/gems/spring-commands-rspec-1.0.4/lib/spring/commands/rspec.rb:18:in `call' # -e:1:in `<main>' # ------------------ # --- Caused by: --- # OpenAPIParser::NotExistRequiredKey: # #/components/schemas/UserApi::UserSchema missing required parameters: birthday # /usr/local/bundle/gems/openapi_parser-0.11.2/lib/openapi_parser/schema_validator.rb:62:in `validate_data' Finished in 1.33 seconds (files took 0.46133 seconds to load ) 2 examples, 1 failure Failed examples: rspec ./spec/requests/users_request_spec.rb: 31 # Users #show GET when success return expected body schema このように、OpenAPI定義に準拠していなかった場合はテストを落とすことができます。導入も簡単で非常に便利です。 ちなみに assert_response_schema_confirm を全てのエンドポイントで記述するのは面倒なので、実際はshared_exampleにまとめています。 # spec/support/response_helper.rb module ResponseHelper shared_examples ' response status & body compare to swagger ' do it ' return expected status ' do expect(response).to have_http_status return_http_status end it ' return expected body schema ' do assert_response_schema_confirm end end end OpenAPI3のrequiredに注意する CommitteeによってOpenAPI定義に準拠していることを確認していますが、これはOpenAPI定義でrequiredを設定しなければプロパティの有無の検証ができず、テストが全て通ってしまいます。 components : schemas : UserSchema : type : object required : <- ここに必須とするpropertiesを設定しなければ、テストが全て通ってしまう - user_id - family_name - given_name - email properties : # 省略 type: object を指定する場合は、基本的に併せてrequiredを設定するものと考えたほうが良いかと思います。 その他にもcommitteeが検証してくれる項目は多岐にわたりますが、それぞれOpenAPI定義で指定しなければ検証はされません。(当たり前ですが) 配列のminItemsを指定することで、空配列をバリデーションする リク エス トのContent-Type リク エス トのパスの存在可否 レスポンスの ステータスコード の存在可否 Committeeでテストが落ちて欲しいシチュエーションでテストが通ってしまった場合、OpenAPIで定義していないだけということがよくありました。 同様の問題として、OpenAPI定義でよく悩むのはnullableの扱いです。 qiita.com OpenAPI3になってからはpropertiesに nullable: true フラグを設定することができるようになりましたが、Swagger UIでは表示はまだされません。 Committeeの検証においては、requiredを指定しpropertiesで型を定義すればデフォルトで nullable: false となり、 value がnullの場合はテストが落ちるようになります。 ですので基本的にはキーとバリューが確実に存在する場合はrequiredのみを指定しておき、 value のnullを許容したいにしたい場合のみ nullable: true , nullable: false は指定しなくてもよい、みたいな運用をしています。 もう一つ、例えばOpenAPIで定義していないcreated_atやupdated_atをレスポンスとして返していたとしても、Committeeは過分に関してはチェックをしてくれません。 スキーマ 駆動開発を実践しているならばOpenAPIに定義されているpropertiesがあればクライアントサイドの開発は進められるはずなので、過分があったとしても特に問題はないはずではありますが… この運用でよかったこと API ドキュメントの信頼性が高く、保守し続けられることが仕組み化できた スキーマ 駆動開発は何度か経験しているものの、今回のプロジェクトで初めてCommitteeを導入しました。Committeeによって スキーマ に準拠していることを検証できるようになったため、 API ドキュメントの信頼性がより高められました。 Committeeの導入により、バリデーションを意識して YAML を書くようになった requiredやminItemsなどバリデーションを意識して適切なpropertiesを設定するようになり、より詳細なドキュメントが残せるようになりました。 まとめ 今回は スキーマ 駆動開発を実際にどのように運用しているのかを紹介しました。今回では紹介しきれませんでしたが、リソース指向の スキーマ 定義についてのチームの試行錯誤や、snake_caseからcamelCaseへの変換をコントローラー層で行っている例、コントローラー層での汎用的なエラーハンドリングなど、 API 開発のTipsを今後も紹介していこうと思っています。 質問や指摘お待ちしております!
こんにちは、タイミーSREチームの宮城です。 今回は弊社が Redash をFargateで構築/運用している話を紹介します。 背景 タイミーでは、CSやセールスのKPI策定から毎月の事業数値に至るまで、Redashが様々な用途で活用されています。 Fargateで構築する以前はEC2上のdocker-composeで運用されていましたが、以下の課題がありました。 オートスケールできないため、クエリが詰まってCPUが100%になってサービスが停止する。 その度slack上から再起動していた セットアップしたエンジニアが退社しており、インフラ構成図やノウハウの共有、IaCによる管理ができていない。 クエリや ダッシュ ボードなどのデータの定期的なバックアップができていない。 v7系からv8系へのアップデートがしたいが、アップデートによる影響範囲がわからず恐怖感がある。 事業に大きく関わるサービスなのにも関わらず、モニタリングやアラートができていない。 上記をFargateに移行することで解決することができました。 移行後の アーキテクチャ Redashで利用する ミドルウェア に関しては下記 コンポーネント を使い、全てをterraformで管理しています。 - PostgreSQL -> RDS - Redis -> ElastiCache それぞれの構成の紹介 ここからは、それぞれの構成をTerraformの ソースコード やタスク定義の JSON などを交えつつ説明していきます。 RDS/Elasticache ダッシュ ボードなどのデータが定期的なバックアップが行われていない問題は、RDSでsnapshotを取得することで解決しました。 それぞれ一番小さい インスタンス タイプのシングルAZ構成で構築しています。 実際に運用してみて負荷が大きければスペックを上げるつもりでしたが、現状問題なく捌けています。 将来、可用性を高めるためマルチAZにすることも容易であり、こういった柔軟なサーバーリソースの活用も クラウド の利点といえるでしょう。 RDSのTerraform privateサブネットに置いたシンプルな構成です。 applyが完了したらrootユーザーのパスワードを AWS コンソール上から変更し、接続情報をSecretsManagerに保管しています。 resource "aws_db_subnet_group" "redash" { name = "redash" subnet_ids = [ data.aws_subnet.private-subnet-1a.id, data.aws_subnet.private-subnet-1c.id, data.aws_subnet.private-subnet-1d.id, ] } /* Postgresの5432ポートを開くセキュリティグループ */ resource "aws_security_group" "rds-redash" { name = "rds-postgres-redash" description = "for redash" vpc_id = data.aws_vpc.vpc.id } resource "aws_security_group_rule" "rds-redash-ingress" { type = "ingress" from_port = 5432 to_port = 5432 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.rds-redash.id } resource "aws_security_group_rule" "rds-redash-egress" { type = "egress" from_port = 0 to_port = 0 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.rds-redash.id } /* defaultはよくないので追加。必要があればパラメータを増やしていく */ resource "aws_db_parameter_group" "redash" { name = "redash" family = "aurora-postgresql11" parameter { name = "log_min_duration_statement" value = "100" } } resource "aws_rds_cluster_parameter_group" "redash" { name = "redash" family = "aurora-postgresql11" parameter { name = "log_min_duration_statement" value = "100" } } /* DBクラスター */ resource "aws_rds_cluster" "redash" { cluster_identifier = "redash" engine = "aurora-postgresql" engine_version = "11.6" master_username = "postgres" master_password = "password" // 仮の値 backup_retention_period = 5 preferred_backup_window = "07:00-09:00" db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.redash.name db_subnet_group_name = aws_db_subnet_group.redash.name skip_final_snapshot = true availability_zones = [ "ap-northeast-1a", "ap-northeast-1c", "ap-northeast-1d", ] vpc_security_group_ids = [ aws_security_group.rds-redash.id ] lifecycle { ignore_changes = [ master_password, // passwordはsecrets managerで管理しています。 ] } } /* プライマリDB */ resource "aws_rds_cluster_instance" "redash" { identifier = "redash-1" cluster_identifier = aws_rds_cluster.redash.id instance_class = "db.t3.medium" engine = "aurora-postgresql" engine_version = "11.6" } ElastiCacheのTerraform RDSとほぼ同じです。 /* defaultはよくないので追加。必要があればパラメータを増やしていく */ resource "aws_elasticache_parameter_group" "redash" { name = "redash" family = "redis5.0" } resource "aws_elasticache_subnet_group" "redash" { name = "redash" subnet_ids = [ data.aws_subnet.private-subnet-1a.id, data.aws_subnet.private-subnet-1c.id, data.aws_subnet.private-subnet-1d.id, ] } /* Redashの6379ポートを開くセキュリティグループ */ resource "aws_security_group" "redis-redash" { name = "redis-redash" description = "for redash" vpc_id = data.aws_vpc.vpc.id } resource "aws_security_group_rule" "redis-redash-ingress" { type = "ingress" from_port = 6379 to_port = 6379 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.redis-redash.id } resource "aws_security_group_rule" "redis-redash-egress" { type = "egress" from_port = 0 to_port = 0 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.redis-redash.id } /* Redashでジョブのキューイングを行うRedis */ resource "aws_elasticache_cluster" "redash" { cluster_id = "redash" engine = "redis" node_type = "cache.t2.micro" num_cache_nodes = 1 parameter_group_name = aws_elasticache_parameter_group.redash.name subnet_group_name = aws_elasticache_parameter_group.redash.name security_group_ids = [ aws_security_group.redis-redash.id ] engine_version = "5.0.6" port = 6379 } ECS Fargate タイミーではFargate Serviceを構築するためのTerraform Moduleがあり、Redash構築でも利用しています。 CPUやメモリを 閾値 としたオートスケーリングや、firelensを利用したDatadog Logsへのログ配信が容易に行えるようになっています。 この説明については後日記事にしたいと思います。 FargateではRedashの 公式Dockerイメージ をコンテナで実行しています。 ECS Cluster内に4つのECS Serviceが動いており、それぞれの役割は以下です。 Server ... WebUIを提供するサービス Scheduled Worker ... スケジューリングされたクエリを処理する Adhoc Worker ... 都度実行されるクエリを処理する Scheduler ... Redisにjobをキューイングする Redashは実行するコマンドを変更することによって、それぞれの役割を振る舞うことができます。 さらに 環境変数 を設定することで柔軟に設定を変更することができます。 redash.io 環境変数 がどのように設定されているかを知ることで、それぞれのサービスの理解がしやすくなるかと思います。 ここではそれぞれのサービスで利用するタスク定義から、実行コマンドと設定した 環境変数 を抜粋して説明します。 Server ServerはALBに紐付けられWeb UIを提供します。 ユーザーが実行するクエリの処理はこのサービスでは行いません。 クエリはWorkerが処理し、 PostgreSQL に書き込まれた結果をWebUIが表示します。 " command ": [ " server " ] , " environment ": [ { " name ": " PYTHONUNBUFFERED ", " value ": " 0 " } , { " name ": " REDASH_ADDITIONAL_QUERY_RUNNERS ", " value ": " redash.query_runner.python " } , { " name ": " REDASH_HOST ", " value ": " https://example.com " } , { " name ": " REDASH_LOG_LEVEL ", " value ": " INFO " } , { " name ": " REDASH_MAIL_DEFAULT_SENDER ", " value ": " example@example.com " } , { " name ": " REDASH_MAIL_PORT ", " value ": " 587 " } , { " name ": " REDASH_MAIL_SERVER ", " value ": " smtp.sendgrid.net " } , { " name ": " REDASH_MAIL_USE_TLS ", " value ": " true " } , { " name ": " REDASH_MAIL_USERNAME ", " value ": " apikey " } , { " name ": " REDASH_REDIS_URL ", " value ": " redis://XXXXX.apne1.cache.amazonaws.com:6379 " } , { " name ": " REDASH_THROTTLE_LOGIN_PATTERN ", " value ": " 1000/minute " } , { " name ": " REDASH_WEB_WORKERS ", " value ": " 4 " } ] , " secrets ": [ { " valueFrom ": " /fargate/redash/REDASH_COOKIE_SECRET ", " name ": " REDASH_COOKIE_SECRET " } , { " valueFrom ": " /fargate/redash/REDASH_MAIL_PASSWORD ", " name ": " REDASH_MAIL_PASSWORD " } , { " valueFrom ": " /fargate/redash/REDASH_DATABASE_URL ", " name ": " REDASH_DATABASE_URL " } ] , REDASH_DATABASE_URL REDASH_COOKIE_SECRET REDASH_MAIL_PASSWORD は秘匿情報のためParameter Storeに保管し、値をコンテナ起動時に注入しています。 REDASH_DATABASE_URL が秘匿情報な理由はパスワードも含めた接続情報なためです。 postgres://<ユーザー名>:<パスワード>@ホスト名 といった文字列が格納されています。 注意すべき点は REDASH_THROTTLE_LOGIN_PATTERN です。これは /login エンドポイントへのレートリミットが設定されており、デフォルトで "50/hour" が設定されています。 FargateにおいてALBのヘルスチェックは有効にしておきたいところですが、Redashにはヘルスチェック用のパスが用意されておらず、ログインせずとも見られるページは /login だけでした。そのためレートリミットを緩和することでヘルスチェックができるようにしています。 メールの送信にはタイミーではSendGridを利用しています。 Adhoc Worker, Scheduled Worker Adhoc Workerはユーザーが都度実行するクエリを処理し、Scheduled Workerは定期実行されるクエリを処理します。 Redisのキューを受け取って処理を開始し、データソースに問い合わせた結果を PostgreSQL に保存します。 " command ": [ " worker " ] , " environment ": [ { " name ": " PYTHONUNBUFFERED ", " value ": " 0 " } , { " name ": " QUEUES ", " value ": " queries " } , { " name ": " REDASH_ADDITIONAL_QUERY_RUNNERS ", " value ": " redash.query_runner.python " } , { " name ": " REDASH_HOST ", " value ": " https://example.com " } , { " name ": " REDASH_LOG_LEVEL ", " value ": " INFO " } , { " name ": " REDASH_MAIL_DEFAULT_SENDER ", " value ": " example@example.com " } , { " name ": " REDASH_MAIL_PORT ", " value ": " 587 " } , { " name ": " REDASH_MAIL_SERVER ", " value ": " smtp.sendgrid.net " } , { " name ": " REDASH_MAIL_USE_TLS ", " value ": " true " } , { " name ": " REDASH_MAIL_USERNAME ", " value ": " apikey " } , { " name ": " REDASH_REDIS_URL ", " value ": " redis://XXXXX.apne1.cache.amazonaws.com:6379 " } , { " name ": " WORKERS_COUNT ", " value ": " 2 " } ] , " secrets ": [ { " valueFrom ": " /fargate/redash/REDASH_DATABASE_URL ", " name ": " REDASH_DATABASE_URL " } , { " valueFrom ": " /fargate/redash/REDASH_COOKIE_SECRET ", " name ": " REDASH_COOKIE_SECRET " } , { " valueFrom ": " /fargate/redash/REDASH_MAIL_PASSWORD ", " name ": " REDASH_MAIL_PASSWORD " } ] , 上記はAdhoc Workerのタスク定義ですが、 Scheduled Workerとの違いは QUEUES がqueriesかscheduled_queriesかどうかのみです。 カンマで区切って両方指定することで、1つのworkerで両方の責務を担うこともできます。 EC2の頃に インスタンス のCPUを押し上げていたのはこのAdhoc Workerでした。非エンジニアで SQL に慣れていないメンバーが多いため、パフォーマンスを考慮できず数分以上かかる重いクエリがたくさん叩かれることが原因でした。 そのためAdhoc WorkerのサービスのみCPUとメモリを増やし、コンテナの最低数/最大数を増やすことで解決しました。 サービスを分割したことで、特定のコンテナのみスペックを増強することができるようになったのもFargate化の利点です。 Scheduler Redash Schedulerは Python のライブラリ RQ Scheduler を利用し、Redisにキューを追加します。 " command ": [ " scheduler " ] , " environment ": [ { " name ": " PYTHONUNBUFFERED ", " value ": " 0 " } , { " name ": " QUEUES ", " value ": " celery " } , { " name ": " REDASH_ADDITIONAL_QUERY_RUNNERS ", " value ": " redash.query_runner.python " } , { " name ": " REDASH_HOST ", " value ": " https://example.com " } , { " name ": " REDASH_LOG_LEVEL ", " value ": " INFO " } , { " name ": " REDASH_MAIL_DEFAULT_SENDER ", " value ": " example@example.com " } , { " name ": " REDASH_MAIL_PORT ", " value ": " 587 " } , { " name ": " REDASH_MAIL_SERVER ", " value ": " smtp.sendgrid.net " } , { " name ": " REDASH_MAIL_USE_TLS ", " value ": " true " } , { " name ": " REDASH_MAIL_USERNAME ", " value ": " apikey " } , { " name ": " REDASH_REDIS_URL ", " value ": " redis://XXXXX.apne1.cache.amazonaws.com:6379 " } , { " name ": " WORKERS_COUNT ", " value ": " 5 " } ] , " secrets ": [ { " valueFrom ": " /fargate/redash/REDASH_DATABASE_URL ", " name ": " REDASH_DATABASE_URL " } , { " valueFrom ": " /fargate/redash/REDASH_COOKIE_SECRET ", " name ": " REDASH_COOKIE_SECRET " } , { " valueFrom ": " /fargate/redash/REDASH_MAIL_PASSWORD ", " name ": " REDASH_MAIL_PASSWORD " } ] , QUEUES を celery に指定することで、Schedulerとして振る舞います。 サービスを分割して運用しているものの負荷が全然かからないため、Scheduled Workerとの統合も検討しています。 firelensを利用した、Datadog Logsへのログ転送 上記で紹介した4つのECS Serviceのログは、firelensを通してDatadog LogsとS3に転送されています。 id:sion_cojp のこちらの記事で詳しく紹介しています。 sioncojp.hateblo.jp Datadog Dashboard による監視 ALB, ECS Service, RDSを ダッシュ ボードで一覧できるようにしました。 DatadogもTerraformで管理しており、 ダッシュ ボード作成作業はコピペで済むようになって楽です。Datadogはapplyが早いのもよいです。 ダッシュ ボードのTerraform resource "datadog_dashboard" "redash" { title = "[${local.env}] ${var.service_name}" description = "Created using the Datadog provider in Terraform" layout_type = "ordered" widget { group_definition { layout_type = "ordered" title = "ALB: ${var.service_name}" widget { timeseries_definition { title = "リクエスト数" request { q = "sum:aws.applicationelb.request_count{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } widget { timeseries_definition { title = "4xxリクエスト数" request { q = "sum:aws.applicationelb.httpcode_elb_4xx{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } widget { timeseries_definition { title = "5xxリクエスト数" request { q = "sum:aws.applicationelb.httpcode_elb_5xx{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } widget { timeseries_definition { title = "コネクション数" request { q = "sum:aws.applicationelb.active_connection_count{name:${var.service_name},env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "ALBに紐づいてる正常なコンテナ数" request { q = "sum:aws.applicationelb.healthy_host_count{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } widget { timeseries_definition { title = "ALBに紐づいてる異常なコンテナ数" request { q = "sum:aws.applicationelb.un_healthy_host_count{name:${var.service_name},env:${local.env},account:${local.name}} by {availability-zone}.as_count()" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "ECS: redash-server" widget { timeseries_definition { title = "起動タスク数" request { q = "sum:aws.ecs.service.running{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "sum:aws.ecs.service.pending{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.ecs.service.cpuutilization.maximum{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "max:aws.ecs.service.cpuutilization{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "Memory Utilization(MAX)" request { q = "max:aws.ecs.service.memory_utilization.maximum{servicename:redash-server,env:${local.env},account:${local.name}}" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "ECS: redash-scheduler" widget { timeseries_definition { title = "起動タスク数" request { q = "sum:aws.ecs.service.running{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "sum:aws.ecs.service.pending{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.ecs.service.cpuutilization.maximum{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "max:aws.ecs.service.cpuutilization{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "Memory Utilization(MAX)" request { q = "max:aws.ecs.service.memory_utilization.maximum{servicename:redash-scheduler,env:${local.env},account:${local.name}}" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "ECS: redash-scheduled-worker" widget { timeseries_definition { title = "起動タスク数" request { q = "sum:aws.ecs.service.running{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "sum:aws.ecs.service.pending{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.ecs.service.cpuutilization.maximum{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "max:aws.ecs.service.cpuutilization{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "Memory Utilization(MAX)" request { q = "max:aws.ecs.service.memory_utilization.maximum{servicename:redash-scheduled-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "ECS: redash-adhoc-worker" widget { timeseries_definition { title = "起動タスク数" request { q = "sum:aws.ecs.service.running{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "sum:aws.ecs.service.pending{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.ecs.service.cpuutilization.maximum{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } request { q = "max:aws.ecs.service.cpuutilization{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } widget { timeseries_definition { title = "Memory Utilization(MAX)" request { q = "max:aws.ecs.service.memory_utilization.maximum{servicename:redash-adhoc-worker,env:${local.env},account:${local.name}}" display_type = "area" } } } } } widget { group_definition { layout_type = "ordered" title = "RDS: ${var.service_name}" widget { timeseries_definition { title = "CPU Utilization(MAX)" request { q = "max:aws.rds.cpuutilization{account:${local.name},dbclusteridentifier:${var.service_name}}" display_type = "area" } } } widget { timeseries_definition { title = "DBコネクション数(MAX)" request { q = "max:aws.rds.database_connections{account:${local.name},dbclusteridentifier:${var.service_name}}" display_type = "area" } } } widget { timeseries_definition { title = "空きストレージ容量 (MB)" request { q = "max:aws.rds.free_storage_space{account:${local.name},dbclusteridentifier:${var.service_name}}" display_type = "area" } } } widget { timeseries_definition { title = "使用可能なメモリ(MB)" request { q = "max:aws.rds.freeable_memory{account:${local.name},dbclusteridentifier:${var.service_name}}" display_type = "area" } } } } } } その他 Redashを AWS で構築するにあたって、Route53や ACM , WAFなどを使用しましたが、今回は記事が長くなってしまうため割愛します。 また、 stg 環境とprod環境を AWS アカウント単位分けており、 stg 環境として全く同じ構成のRedashを立てています。 理由はredashのバージョンアップや saml を使ったSSO認証の検証のためです。 今はコンテナ数を0にして寝かせています。 Fargateに移行した利点 抱えていた課題がほぼ解消できた 1. オートスケールできないため、クエリが詰まってCPUが100%になってサービスが停止する → オートスケールができるようになり、サービスが停止することはなくなりました。 2. セットアップしたエンジニアが退社しており、インフラ構成図やノウハウ、IaCによる管理ができていない。 → 全てコードで管理されている。構成図や wiki も残すことで、後任者がキャッチアップできるようになりました。 3. ダッシュ ボードなどのデータの定期的なバックアップができていない。 → AWS マネージドサービスに移行し、RDSのスナップショットで解決しました。 4. v7系からv8系へのアップデートがしたいが、アップデートによる影響範囲がわからず恐怖感がある。 → stg 環境があるので、本番環境で実施する前に試すことができるようになりました。 5. 事業に大きく関わるサービスなのにも関わらず、モニタリングやアラートができていない。 → datadogでモニタリングができるようになりました。まだコンテナ数などの調整中のため、アラートは保留としています。 また移行前は週に数回オンコールが発生していたが、移行してからほぼ0になりました。 サービスをきちんと分離したことで、負荷がかかることが多いAdhoc Workerのみスペックを上げる事が可能になった それまでは重いクエリを実行するとEC2 インスタンス のCPUが100%に達して他のユーザーにも影響を与えてしまっていたのが、サービスを分離したことでAdhoc Worker以外のサービスへの影響を減らすことができるようになりました。かつAdhoc Workerのみスペックを上げることができるようになりました。コンテナとサーバレスの特性をうまく活かすことができたと思っています。 まだ残っている課題 Redashのバージョンをv7からv8に上げる v8にアップデートできるとクエリ名を日本語で正しく検索できるようになるため、社内からアップデートしてほしいと要望があります。しかし今回のFargate移行でアップデートしやすくなったため、近いうちに着手します。 ログイン認証をSSOで行う タイミーでは従業員に発行する各種アカウントをGSuiteでのSSOでできるよう移行を進めています。Redashも SAML 認証によるSSOに対応しているので、次にやっていきたいと思っています。 まとめ 今回EC2で動いていたRedashをFargateに移行することによって、Redashにまつわる事柄全てをマネージドサービスとIaCで管理することができるようになりました。タイミーのSREがアプリケーションをどのように運用しているかも紹介できたかと思っています。 また今回よりタイミーのプロダクトチームのブログを開設することになりました。SRE/サーバーサイドエンジニア/フロントエンドエンジニア/デザイナーそれぞれの、タイミーのプロダクトにまつわる記事を投稿していきたいと思っております。ぜひお楽しみに!