こんにちは、タイミー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は実行するコマンドを変更することによって、それぞれの役割を振る舞うことができます。
さらに環境変数を設定することで柔軟に設定を変更することができます。
環境変数がどのように設定されているかを知ることで、それぞれのサービスの理解がしやすくなるかと思います。
ここではそれぞれのサービスで利用するタスク定義から、実行コマンドと設定した環境変数を抜粋して説明します。
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 のこちらの記事で詳しく紹介しています。
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/サーバーサイドエンジニア/フロントエンドエンジニア/デザイナーそれぞれの、タイミーのプロダクトにまつわる記事を投稿していきたいと思っております。ぜひお楽しみに!