kasei_sanのブログ

かせいさんのIT系のおぼえがきです。胡乱の方はnoteとtwitterへ

Terraformで、プロバイダのバイナリが大量に作られてストレージを圧迫していた件とその対処法

症状

Macのディスク空き容量が5.9GBまで減っていた。調査したところ、~/work 配下のTerraformリポジトリにある .terraform ディレクトリが、102箇所合計で148GBを占めていた。

原因

terraform init は実行したディレクトリごとにプロバイダのバイナリ(AWSプロバイダなら数百MB〜1GB)を .terraform/providers/ にダウンロードする。

Terraformはデフォルトでこのキャッシュをディレクトリ間で共有しないため、prod / stage / qanetwork / rds / ecs のようにワークスペースをディレクトリ単位で分けている構成だと、同じバージョンの同じプロバイダが何十箇所にも重複コピーされる

対処

  1. まず溜まった分を削除(.terraform は中身が全て再ダウンロード可能なキャッシュなので安全)

        find ~/work -type d -name ".terraform" -exec rm -rf {} +
    
  2. 再発防止に TF_PLUGIN_CACHE_DIR を設定し、全ディレクトリでプロバイダキャッシュを共有する

          # ~/.zshrc
          export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
          mkdir -p "$TF_PLUGIN_CACHE_DIR"
    

まとめ

複数のTerraformワークスペースを持つ環境では TF_PLUGIN_CACHE_DIR を最初から設定しておくべき。.terraform はいつ消しても安全(terraform.lock.hcl と state は別管理)なので、容量を食っていたら気軽に消してよい。

AWS RDSで、他アカウントのスナップショットをリストアする方法

流れ

  1. AWS KMS で暗号化キーを作る(スナップショットの移動時に必要
  2. 移動元アカウントでスナップショットを取る
  3. スナップショットを1. で作成した暗号化キーで再暗号化してコピー
  4. スナップショットを、移動先アカウントで共有できるようにする命令を実行
  5. 移動先アカウントにスナップショットをコピー(このときに暗号化キーで復号化する
  6. スナップショットからリストアを実行

AWS KMS で暗号化キーを作る

terraformだとこんな感じ。

  • デフォルトの暗号化キー alias/aws/rds だと、他アカウントで復号できないので、別途KMSキーが必要
    • alias/aws/rdsAWS管理キーで、アカウントごとに異なるキー。他アカウントには共有できない
    • カスタムKMSキーなら、以下のように、キーポリシーで他アカウントにアクセス権を付与できる
# クロスアカウントスナップショット共有用のKMSキー
resource "aws_kms_key" "rds_snapshot_sharing" {
  description             = "KMS key for cross-account RDS snapshot sharing"
  deletion_window_in_days = 7
  enable_key_rotation     = true

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "Enable IAM User Permissions"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
        }
        Action   = "kms:*"
        Resource = "*"
      },
      {
        Sid    = "AllowNewAccountAccess"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${var.new_account_id}:root" # `var.new_account_id` は移動先アカウントのID
        }
        Action = [
          "kms:Encrypt",
          "kms:Decrypt",
          "kms:ReEncrypt*",
          "kms:GenerateDataKey*",
          "kms:DescribeKey",
          "kms:CreateGrant"
        ]
        Resource = "*"
      }
    ]
  })

  tags = {
    Name        = "snapshot-sharing-key"
    Service     = var.service
    Environment = var.environment
  }
}

resource "aws_kms_alias" "rds_snapshot_sharing" {
  name          = "alias/snapshot-sharing"
  target_key_id = aws_kms_key.rds_snapshot_sharing.key_id
}

# 現在のアカウントIDを取得
data "aws_caller_identity" "current" {}

移動元アカウントでスナップショットを取る

aws rds create-db-cluster-snapshot \
  --db-cluster-identifier hoge\
  --db-cluster-snapshot-identifier cross-account-$(date +%Y%m%d)

スナップショットを1. で作成した暗号化キーで再暗号化してコピー

デフォルトだと、alias/aws/rds で暗号化しているので、共有用の暗号化キーでコピー

aws rds copy-db-cluster-snapshot \
  --source-db-cluster-snapshot-identifier cross-account-$(date +%Y%m%d) \
  --target-db-cluster-snapshot-identifier cross-account-shared \
  --kms-key-id alias/snapshot-sharing

スナップショットを、移動先アカウントで共有できるようにする命令を実行

aws rds modify-db-cluster-snapshot-attribute \
  --db-cluster-snapshot-identifier cross-account-shared \
  --attribute-name restore \
  --values-to-add <NEW_ACCOUNT_ID>

移動先アカウントにスナップショットをコピー

このときの暗号化キーは alias/aws/rds を使用している

aws rds copy-db-cluster-snapshot \
  --source-db-cluster-snapshot-identifier arn:aws:rds:ap-northeast-1:<OLD_ACCOUNT_ID>:cluster-snapshot:cross-account-shared \
  --target-db-cluster-snapshot-identifier restored-from-old-account \
  --kms-key-id alias/aws/rds

スナップショットからリストアを実行

aws rds restore-db-cluster-from-snapshot \
  --db-cluster-identifier new-cluster-name \
  --snapshot-identifier restored-from-old-account \
  --engine aurora-postgresql \
  --vpc-security-group-ids sg-xxxxxxxx \
  --db-subnet-group-name your-subnet-group

AWS Organizationについての理解をまとめる

AWS Organization ってなに?

  • 複数のアカウントをまとめて管理できるサービス
  • 初期状態では、Organizationは存在しない

AWSにおけるアカウントってなに?

  • IAM ユーザーではなく、「完全に独立したセキュリティ境界と請求単位を持つ AWS の操作単位」
  • ECS、RDS、S3 などのリソースはすべてこのアカウントの内部に所属する
  • 初期状態では、単独のアカウントが1つ存在し、Organization には属していない

Organizationの作り方

  • AWS Organizations の画面から作成できる
  • Organization を作ると、自動的に Root OU(最上位の Organizational Unit)が作られる
  • Organization を作成したアカウントが「管理アカウント(Management Account)」になる
  • 管理アカウント以外のアカウントは「メンバーアカウント(Member Account)」と呼ばれる

管理アカウントってなに?

  • 全ての所属アカウントの支払いを一括で行うアカウント(Consolidated Billing)
  • 組織全体での Savings Plans や Reserved Instances の割引共有設定を管理する
    • 各メンバーアカウントでも購入は可能だが、割引の自動適用や共有は管理アカウントで制御される
  • OU 作成、アカウント作成、全体設定など、組織全体の管理権限を持つ

Organizational Unit ってなに?

  • 複数の AWS アカウントを階層的にまとめるためのグループ
  • OU 自体が権限を持つわけではなく、OU に適用された SCP(Service Control Policy)がアカウント権限を制御する
    • SCP は「許可」ではなく「制限」を加えるポリシーで、アカウントが実行できる操作の上限を定義する
  • すべての OU は Root OU の配下に作られ、必要に応じて階層化できる(OU の下にさらに OU を作ることもできる)
  • アカウントは Root OU の直下にも、任意の OU の直下にも配置できる

Organizational Unitとアカウントの定石

  • OU・アカウント単位で役割を明確にして、1つのアカウントに複数の責務を持たせない
    • 複数の役割を持たせると、不要な権限が混在しセキュリティリスクが増えるため
  • 管理アカウントでは本番ワークロードを実行しない
    • 管理アカウントが侵害されると Organization 全体が危険にさらされる
    • 管理アカウントは支払い・割引管理・組織管理など最小限の用途に限定する
  • 一般的な構成としては、プロダクトごとに OU を分け、環境(prod / stage)ごとに個別アカウントを作成する
    • これによりプロダクトや環境ごとのアクセス管理が明確になり、安全性が高い
    • 特に本番アカウントはアクセス可能なユーザを最小限に限定するのが推奨

参考

docs.aws.amazon.com

Rails + Puma環境で起きるクラス変数/クラスインスタンス変数の競合について

これはなに?

PumaでRailsをマルチスレッドで運用すると、同一メソッドが同時に実行されたタイミングで、クラス変数/クラスインスタンス変数の値がごっちゃになることがあるよってお話です

前提: Pumaの基本構造

Pumaはマルチプロセス + マルチスレッドのハイブリッド構成

  • ワーカープロセス : 複数立ち上がり、それぞれ独立したRailsアプリをメモリにロード
  • スレッド : 各ワーカー内で複数スレッドを持ち、リクエストを並列処理する

各ワーカー内でのRailsの挙動

  1. アプリケーションの初期化: ワーカープロセス起動時にRailsアプリ全体をメモリにロード
    • 同一ワーカープロセス内の全スレッドは、同じメモリ空間(クラス定義、クラス変数等)を共有する。
  2. リクエスト処理:
    • クライアントからのリクエストが来ると、空いているスレッドが1つ割り当てられる
    • そのスレッド内でRailsのリクエスト処理サイクル(ルーティング→コントローラー→モデル→ビュー)が実行される
    • レスポンスを返すとスレッドは解放される

実例

こんなコードのが複数メソッドで同時実行されると、競合が発生する

class UserService
  @@request_counter = 0     # クラス変数(全スレッドで共有)
  @processing_users = {}    # クラスインスタンス変数(全スレッドで共有)
  
  def self.process_user(user_id)
    # ⚠️ 競合発生ポイント1: カウンターの更新
    @@request_counter += 1
    
    # ⚠️ 競合発生ポイント2: ハッシュの更新
    @processing_users[user_id] = Time.current
    
    # 何らかの処理(DBアクセスなど)
    # この間に他のスレッドが動く可能性がある
    sleep 0.1  # I/O待機をシミュレート
    
    @processing_users.delete(user_id)
    
    puts "処理完了: #{@@request_counter}件目"
    # → 期待する連番にならない可能性がある
  end
end

クラス変数、クラスインスタンス変数は、Railsのコードが各クラスをロードしたとき、ワーカープロセスのメモリに格納される。

  • Railsのコードが各クラスをロードするのは、pumaのworker(プロセス)が起動した後
  • ワーカープロセス内の全スレッドが同じメモリ空間を共有している

そのため、 クラス変数、クラスインスタンス変数の値をそれぞれのスレッドが変更すると競合が発生しえる。

補足

MRI Rubyでは、同時に実行できるRubyコードは実質1つのスレッドのみで、DBアクセスやHTTP通信などのI/O待機中は他のスレッドが実行できる。

なので、実際には同時に並列実行されることはないが、上記のような途中にI/O待機がある場合、I/O待機中に、同一メソッドが実行されて、競合が発生する。

対策案

1. 【最推奨】そもそも共有状態を持たない設計

Railsでは各リクエストは独立して処理されるべき。状態をクラスに持たせず、必要な情報はDBやキャッシュで管理すべき。

class UserService
  def self.process_user(user_id)
    # リクエストIDは外部から渡すか、UUIDを生成
    request_id = SecureRandom.uuid
    
    # 状態はDBに保存
    ProcessingLog.create!(user_id: user_id, status: 'processing', request_id: request_id)
    
    # 処理実行
    perform_business_logic(user_id)
    
    # 状態を更新
    ProcessingLog.find_by(request_id: request_id).update!(status: 'completed')
  end
  
  private
  
  def self.perform_business_logic(user_id)
    # ステートレスな処理
  end
end

2. ローカル変数を使用

共有する必要がないデータは、メソッド内のローカル変数として扱う

def self.process_user(user_id)
  # スレッド間で共有されない
  local_counter = 0  
  local_state = {}
  
  local_counter += 1
  local_state[user_id] = "processing"
  
  # 処理...
  
  local_state[user_id] = "processed"
  puts "処理完了: #{local_counter}件目"
end

3. スレッドローカル変数を使用

どうしてもスレッド間でデータを分離したい場合

def self.process_user(user_id)
  # スレッドごとに独立したデータ
  Thread.current[:counter] ||= 0
  Thread.current[:counter] += 1
  
  Thread.current[:processing_users] ||= {}
  Thread.current[:processing_users][user_id] = Time.current
  
  # 処理...
  
  puts "処理完了: #{Thread.current[:counter]}件目(このスレッド内)"
end

4. 【最終手段】Mutexで排他制御

スレッドがロックされて、パフォーマンスに影響がでるので、最終手段

class UserService
  @@request_counter = 0
  @@processing_users = {}
  @@mutex = Mutex.new  # 排他制御用
  
  def self.process_user(user_id)
    @@mutex.synchronize do
      @@request_counter += 1
      @@processing_users[user_id] = Time.current
      current_count = @@request_counter
    end
    
    # 長時間の処理はロック外で実行
    perform_business_logic(user_id)
    
    @@mutex.synchronize do
      @@processing_users.delete(user_id)
    end
    
    puts "処理完了: #{current_count}件目"
  end
end

まとめ

  • pumaでは、同一worker上のスレッドでは、クラス変数やクラスインスタンス変数は、同一のメモリを参照する
  • そのため、スレッドで並列実行される可能性があるメソッドで、クラス変数やクラスインスタンス変数を変更/参照すると、競合が発生する可能性がある
  • 厳密には、Rubyではスレッドの同時並列実行は発生しないが、I/O 待機時に、待機しているメソッドと同一のメソッドが実行される可能性はある
  • そもそもRailsのようなwebアプリでは、各リクエストが独立して処理されるべきで、状態はDBやキャッシュで管理すべき、もしくはローカル変数を使うべき
  • mutexや、スレッドローカル変数は最終手段

MemcachedとRedisのノード障害時の挙動

これはなに?

MemcachedとRedisのクラスタ構成ごとのノードが死んだ場合の挙動のメモです

Memcached クラスタ

構成

resource "aws_elasticache_cluster" "memcached" {
  engine = "memcached"
  num_cache_nodes = 3
}

ノード障害時の挙動

操作 挙動 説明
既存データの読み取り ❌ 失敗 障害ノードのデータは完全に失われる
新規データの書き込み ✅ 成功 生きているノードにハッシュ再計算で書き込まれる
他のノードへの影響 ✅ 正常動作 他のノードは影響を受けない

特徴

  • クライアントサイドハッシュで分散
  • レプリケーション機能なし
  • 障害ノードのデータは復旧不可

Redis クラスタ

1. 単一ノード(レプリカなし)

構成

resource "aws_elasticache_cluster" "redis" {
  engine = "redis"
  num_cache_nodes = 1
}

ノード障害時の挙動

操作 挙動 説明
既存データの読み取り ❌ 失敗 データは完全に失われる
新規データの書き込み ❌ 失敗 ノードが死んでいるため書き込み不可
他のノードへの影響 - 単一ノードのため該当なし

2. マルチノード(レプリカなし)

構成

resource "aws_elasticache_replication_group" "redis" {
  cluster_mode_enabled = true
  num_node_groups = 3
  replicas_per_node_group = 0
}

シャード障害時の挙動

操作 挙動 説明
既存データの読み取り ❌ 失敗 障害シャードのデータは失われる
新規データの書き込み ❌ 失敗 障害シャードには書き込み不可
他のシャードへの影響 ✅ 正常動作 他のシャードは影響を受けない

3. マルチノード(レプリカあり)

構成

resource "aws_elasticache_replication_group" "redis" {
  cluster_mode_enabled = true
  num_node_groups = 3
  replicas_per_node_group = 1
  automatic_failover_enabled = true
}

シャード障害時の挙動

操作 挙動 説明
既存データの読み取り ✅ 成功 レプリカが自動でプライマリになる
新規データの書き込み ✅ 成功 レプリカが自動でプライマリになる
他のシャードへの影響 ✅ 正常動作 他のシャードは影響を受けない

まとめ

  1. Memcached: 新規書き込みは可能だが、既存データは失われる
  2. Redis レプリカなし: データ損失 + 書き込み失敗
  3. Redis レプリカあり: データ保持 + 自動フェイルオーバー

比較表

構成 データ損失 読み取り 書き込み 可用性 コスト
Memcached クラスタ ❌ あり ❌ 失敗 ✅ 成功
Redis 単一ノード ❌ あり ❌ 失敗 ❌ 失敗 最低 最安
Redis シャーディング ❌ あり ❌ 失敗 ❌ 失敗
Redis シャーディング+レプリカ ✅ なし ✅ 成功 ✅ 成功

Fastlyでwwwありのドメインへのアクセスをwwwなしに転送する

先にコード

sub vcl_recv {
#FASTLY recv

  # www付きドメインはwwwなしにリダイレクト
  if (req.http.host ~ "^www\.") {
    error 618 "redirect";
  }
  return (lookup);
}
sub vcl_error {
#FASTLY error
  # www付きドメインはwwwなしにリダイレクト
  if (obj.status == 618 && obj.response == "redirect") {
    set obj.status = 308;
    set obj.http.Location = "https://" + regsub(req.http.host, "^www\.", "") + req.url;
    return (deliver);
  }
}

解説

ここに書いていることの引き写し

www.fastly.com

  • redirectしたい場合、vcl_recv から error に渡して vcl_error で処理すれば、オリジンを経由しない
  • レスポンスコード618は、内部処理用でコード自体に意味はない(はず)
  • 308は、301と同様にredirectをするが、301と異なり、メソッドやリクエストボディが保持されるので、こちらの方が良い(らしい)
    * memo: google的には301も308も同様に扱うとのこと
    

Capybaraでフルサイズのスクショを取る方法メモ

def take_full_page_screenshot(device, path, host)
  Capybara.app_host = "https://#{host}"
  visit path

  # 一旦windowサイズをリセット
  window = Capybara.current_session.driver.browser.manage.window
  window.resize_to(1600, 1080)
  # その後、縦幅は最大にしてスクリーンショットをとる
  height = Capybara.page.execute_script('return Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight);')

  window.resize_to(1600, height + 100)
  page.save_screenshot File.join(__dir__, 'screenshots', host, device, "#{path.gsub('/', '_')[0, 250]}.png")
end

memo

  • 一度windowサイズをリセットしないと、縦長のスクショを取った後に、別のスクショを取ると、その高さが維持されてしまう
  • 今テストしている画面に横幅が長いのは無いので固定値