🍎

【AWS】社内ツールをオンプレからAWSに移行した話

2022/05/15に公開

概要

先日、社内でのタスクチケット管理で使用していたRedmineをオンプレ環境からAWSに移行させた。

今回はどのようにオンプレからAWSへの移行を実施したのか書いていく。

移行前の課題

  • 自社でオンプレサーバーの面倒を見るのが大変
  • DBのバックアップが取れていない

、、、などなど、上げればキリがないが、代表的な課題は上記だった。

移行元であるオンプレの構成

幸いにも、サーバーにはDockerでインストールされており、実際のRedmineやMySQLはコンテナで動いていた。

また、Redmineの画像や動画などはAWSのS3で管理されていた。
(移行先のAWSとは異なる別プロジェクトだったが)

最終的な移行先のAWS構成

  • 自社でオンプレサーバーの面倒を見るのが大変
    → サーバーレスにしよう

  • DBのバックアップが取れていない
    → マネージドサービスで自動的にバックアップしてくれるものにしよう

という方針で、下記のように構成した。

【使用したAWSリソース】

リソース 用途 選定理由
ECS(Fargate) サーバー EKSほど複雑なシステムも必要なかったので、Fargateを選択。
NAT Gateway FargateとS3などの通信 サーバーはまるっとPrivate Subnetに置きたかったので、インターネットへの外向き通信用に建てた。今思えばPrivate Link使えば料金安く済んだかも、、
ALB ルーティング L7レベルでルーティングをする必要があったので選択。
RDS データーベース こちらも自前でDBサーバー作るのが面倒だったのと、障害等でデータが吹っ飛んだ時にすぐに復旧が出来るのでマネージドサービスにした。社内ツールなので、1時間ほどダウンタイムが許容出来たので、料金を抑える意味でもマルチAZ構成にはしなかった。かつ、QPSもそこまで高くないので、レプリケーションなども特にしていない。
ElastiCache キャッシュサーバー Redmineのチケット参照などレスポンスが早くなるので導入。こちらもマネージドの方が楽だったのでElastiCacheにした。
SES SMTPサーバー チケットの通知等でメールを使用したい、、という要件があったので導入。
ECR コンテナリポジトリ Redmineのイメージにpluginや、GitHubSecrets経由で秘匿情報も入れていたので、なるべく保管時に暗号化してくれたり、HTTPS通信で転送してくれたりなどセキュアなコンテナレジストリサービスを使いたかった。
S3 ストレージサービス Redmineのチケットの画像や動画を管理するのにS3が好都合だった。Redmineのプラグインで楽に送信も出来る。
DataSync データ転送 オンプレ環境からAWSに画像と動画を転送する際に、aws s3 syncコマンドより圧倒的に料金がかからなかったので選んだ。1GB/6円という破格の値段だった。
WAF IP制限 誰でも社内ツールを見れる状態、、というのはセキュリティ上よろしくないので、会社のグローバルIPならアクセス可能、、などがしたかった。
CloudWatch ログ サーバーが立ち上がっているか、DataSyncの転送が終わっているかどうかを確認したかった。

【その他】

  • GitHubActions (Dockerイメージのビルド & ECRへのPush)
  • Terraform (IaC)

移行先のAWSの構築方法

Terraformのディレクトリ構成はこんな感じ。

modules/xxの配下に

  • main.tf (リソース記述)
  • variables.tf (外から渡す変数を定義)
  • output.tf (外へ渡す変数を定義)

を置いている。

modules
  |_ alb
  |_ alb_target_group
  |_ datasync_s3
  |_ ecr
  |_ ecs
  |_ memcached_cluster
  |_ rds_instance
  |_ s3_bucket
  |_ waf_acl
  |_ waf_ip_set
products
  |_ redmine
    |_ main.tf

ECR

特にmodule化はせず、そのままのresourceを使用。

resource "aws_ecr_repository" "ecr" {
  name = "redmine"
  # イメージタグは上書き可能
  image_tag_mutability = "MUTABLE"

  # push時に脆弱性スキャンを行う
  image_scanning_configuration {
    scan_on_push = true
  }
}

GitHubActions

ビルドしたいブランチで、

$ git tag v1.0.0
$ git push origin v1.0.0

を行うと、Actionsが走ってイメージがビルドされるようにした。

尚、OIDCでAssumeRoleする形でGitHub Actions → AWSヘのアクセスをしている。

下記の記事通りにやれば動くはず。

GitHub Actions OIDCでconfigure-aws-credentialsでAssumeRoleする

RoleのARNとかは適宜GithubActionsのSecrets管理で。

name: build-and-push

# タグのpushで走らせる
on:
  push:
    tags:
      - 'v*'

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  build-and-push:
    runs-on: ubuntu-18.04
    steps:
    - name: Check out source code
      uses: actions/checkout@v1

    - name: Configure AWS Credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
        aws-region: ap-northeast-1

    - name: Login to Amazon ECR
      id: login-ecr
      uses: aws-actions/amazon-ecr-login@v1

    - name: Build and push docker image
      env:
        ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        ECR_REPOSITORY: ${{ secrets.AWS_ECR_REPO_NAME }}
      run: |
        IMAGE_TAG=$(echo ${{ github.ref }} | sed -e "s#refs/tags/##g")
        docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
        docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

ACM

コンソールからポチポチした。
3分くらいで出来たので、特に難しくなかった。

下記の記事を参考↓
ACMで証明書のインポートする

ALB

modules/alb/main.tf

   
data "aws_subnet" "public_subnet" {
  id = var.public_subnet_ids[0]
}

data "aws_acm_certificate" "acm" {
  domain      = var.acm_domain
  most_recent = true
}

# publicとprivateは同じVPCなの前提
locals {
  vpc_id = data.aws_subnet.public_subnet.vpc_id
}

# ---------- ALB ----------

resource "aws_lb" "alb" {
  load_balancer_type = "application"
  name               = "${var.name}-alb"

  internal = false

  security_groups = [aws_security_group.alb.id]
  subnets         = var.public_subnet_ids
}

resource "aws_security_group" "alb" {
  name        = var.name
  vpc_id      = local.vpc_id
  description = "Managed by Terraform"

  tags = {
    Name = "${var.name}-alb"
  }
}

resource "aws_security_group_rule" "alb_ingress_https" {
  # インターネット → セキュリティグループ内のリソースのアクセスを許可する
  type              = "ingress"
  security_group_id = aws_security_group.alb.id

  from_port = "443"
  to_port   = "443"
  protocol  = "tcp"

  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_security_group_rule" "alb_ingress_http" {
  # インターネット → セキュリティグループ内のリソースのアクセスを許可する(HTTPリダイレクト用)
  type              = "ingress"
  security_group_id = aws_security_group.alb.id

  from_port = "80"
  to_port   = "80"
  protocol  = "tcp"

  cidr_blocks = ["0.0.0.0/0"]
}


resource "aws_security_group_rule" "alb_egress" {
  # セキュリティグループ内のリソース → インターネットへのアクセスを許可する
  type              = "egress"
  security_group_id = aws_security_group.alb.id

  from_port = "443"
  to_port   = "443"
  protocol  = "-1"

  cidr_blocks = ["0.0.0.0/0"]
}

# ---------- listener ----------

resource "aws_lb_listener" "alb_listener_https" {
  # HTTPSでのアクセスを受け付ける  
  load_balancer_arn = aws_lb.alb.arn
  port              = "443"
  protocol          = "HTTPS"

  certificate_arn = data.aws_acm_certificate.acm.arn

  # デフォルトアクションがないとApply出来ないのでとりあえず固定レスポンス返す
  # todo: 404を返すようにする
  default_action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/plain"
      message_body = "Fixed response content"
      status_code  = "200"
    }
  }

  tags = {}
}

resource "aws_alb_listener" "alb_listener_http" {
  # HTTPでのアクセスを受け付けて、HTTPSにリダイレクトさせる 
  load_balancer_arn = aws_lb.alb.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}
modules/alb/variable.tf
variable "public_subnet_ids" {
  description = "パブリックサブネットのID配列"
  default     = []
  type        = list(string)
}

variable "name" {
  description = "ALB名"
  type        = string
}

variable "acm_domain" {
  description = "ACMで管理している証明書のドメイン"
  type        = string
}
modules/alb/output.tf
output "arn" {
  description = "albのARN"
  value       = aws_lb.alb.arn
}

output "alb_security_group_id" {
  description = "albのセキュリティグループID"
  value       = aws_security_group.alb.id
}

output "alb_listener_arn" {
  description = "ALBのlistenerのARN"
  value       = aws_lb_listener.alb_listener_https.arn
}

ALB - TargetGroup

modules/alb_target_group/main.tf
resource "aws_alb_listener_rule" "listener_rule" {
  listener_arn = var.alb_listener_arn

  priority = var.alb_listener_priority

  # lb_target_groupへルーティング
  condition {
    host_header {
      values = [var.target_host]
    }
  }
  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.lb_target.arn
  }
}

resource "aws_lb_target_group" "lb_target" {
  name        = var.target_group_name
  port        = var.target_port
  protocol    = var.target_protocol
  vpc_id      = var.target_vpc_id
  target_type = var.target_type

  health_check {
    interval            = 60
    path                = var.health_check_path
    port                = "traffic-port"
    protocol            = "HTTP"
    timeout             = 30
    unhealthy_threshold = 5
    # 200番ならOK
    matcher = 200
  }
}
modules/alb_target_group/variables.tf
variable "alb_listener_arn" {
  description = "ALBリスナーのARN"
  type        = string
}

variable "target_host" {
  description = "ルーティング対象となるホスト名"
  type        = string
}

variable "target_group_name" {
  description = "target_group名"
  type        = string
}

variable "target_port" {
  description = "ルーティング対象のポート番号"
  default     = 80
}

variable "target_protocol" {
  description = "ルーティング対象への通信プロトコル"
  default     = "HTTP"
}

variable "target_vpc_id" {
  description = "ルーティング対象の位置するVPCID"
  type        = string
}

variable "target_type" {
  description = "ルーティング対象の種類(instance,ip,alb,lambdaなど)"
  default     = "ip"
}

variable "health_check_path" {
  description = "ヘルスチェックのパス"
  default     = "/"
}

variable "alb_listener_priority" {
  description = "リスナールールの優先度"
  default     = 1
}
modules/alb_target_group/output.tf
output "arn" {
  description = "target_groupのARN"
  value       = aws_lb_target_group.lb_target.arn
}

ECS(Fargate)

ECS Execでコンテナの中に入れるようにしてる。
(デバッグする際にかなり便利なのでオススメ)

modules/ecs/main.tf
data "aws_subnet" "private_subnet" {
  id = var.private_subnet_id
}

locals {
  vpc_id = data.aws_subnet.private_subnet.vpc_id
}

# ---------- ECS Task Role with SSM Get Parameter Policy ----------


data "aws_iam_policy_document" "ecs_exec" {
  version = "2012-10-17"
  statement {
    actions = [
      "ssmmessages:CreateControlChannel",
      "ssmmessages:CreateDataChannel",
      "ssmmessages:OpenControlChannel",
      "ssmmessages:OpenDataChannel"
    ]
    resources = ["*"]
  }
}

resource "aws_iam_role" "ecs" {
  name = var.name

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_policy" "ecs_exec" {
  name_prefix = "${var.name}-ecs-ecs-exec"
  policy      = data.aws_iam_policy_document.ecs_exec.json
}

resource "aws_iam_role_policy_attachment" "ecs" {
  role       = aws_iam_role.ecs.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

resource "aws_iam_role_policy_attachment" "ecs_exec" {
  role       = aws_iam_role.ecs.name
  policy_arn = aws_iam_policy.ecs_exec.arn
}

resource "aws_iam_role_policy_attachment" "s3" {
  role       = aws_iam_role.ecs.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}

# ---------- ECS Task Execution Role ----------
# https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task_execution_IAM_role.html

resource "aws_iam_role" "ecs_task_execution" {
  name = "${var.name}-ecsTaskExecutionRole"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "ecs_task_execution" {
  role       = aws_iam_role.ecs_task_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

resource "aws_iam_role_policy_attachment" "ecr_power_user" {
  role       = aws_iam_role.ecs_task_execution.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser"
}

# ---------- Fargate ECS Cluster ----------
resource "aws_security_group" "ecs" {
  name        = "${var.name}-ecs"
  vpc_id      = local.vpc_id
  description = "Managed by Terraform"

  tags = {
    Name = "${var.name}-ecs"
  }
}

resource "aws_security_group_rule" "ecs_ingress_alb_security" {
  # ECSへのアクセスを許可する(セキュリティグループ)
  type                     = "ingress"
  security_group_id        = aws_security_group.ecs.id
  source_security_group_id = var.from_security_group_id

  from_port = var.from_port
  to_port   = var.to_port
  protocol  = "TCP"
}

resource "aws_security_group_rule" "ecs_egress" {
  # ECS → NAT経由でのインターネットアクセスを許可する
  type              = "egress"
  security_group_id = aws_security_group.ecs.id

  from_port = 0
  to_port   = 0
  protocol  = "-1"

  cidr_blocks = ["0.0.0.0/0"]
}

resource "aws_ecs_cluster" "cluster" {
  name = "${var.name}-cluster"
}

resource "aws_ecs_task_definition" "task_definition" {
  family                   = "${var.name}-task"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = var.container_cpu
  memory                   = var.container_memory
  task_role_arn            = aws_iam_role.ecs.arn
  execution_role_arn       = aws_iam_role.ecs_task_execution.arn

  container_definitions = jsonencode(var.container_definitions)
}

resource "aws_ecs_service" "service" {
  launch_type      = "FARGATE"
  name             = "${var.name}-service"
  cluster          = aws_ecs_cluster.cluster.id
  task_definition  = aws_ecs_task_definition.task_definition.arn
  platform_version = var.fargate_platform_version
  desired_count    = var.task_count
  # デバッグしたいのでECS Execを有効に
  enable_execute_command = true

  network_configuration {
    # プライベートサブネット指定
    subnets = [data.aws_subnet.private_subnet.id]

    security_groups = [
      aws_security_group.ecs.id,
    ]

    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = var.lb_target_group_arn
    container_name   = var.name
    container_port   = 80
  }

  depends_on = [
    aws_ecs_task_definition.task_definition,
  ]
}

# ---------- CloudWatch Log Group ----------

resource "aws_cloudwatch_log_group" "log_group" {
  name              = var.name
  retention_in_days = var.log_retention_in_days
}
modules/ecs/variables.tf
variable "name" {
  description = "ECSクラスタやコンテナ名"
  type        = string
}

variable "private_subnet_id" {
  description = "プライベートサブネットのID"
  type        = string
}

variable "from_port" {
  description = "リクエストを許可するポート番号"
  default     = 80
}

variable "from_security_group_id" {
  description = "リクエストを許可するセキュリティグループのID"
  type        = string
}

variable "to_port" {
  description = "リクエストを受け付けるポート番号"
  default     = 80
}

variable "fargate_platform_version" {
  description = "Fargateのプラットフォームバージョン"
  type        = string
}

variable "task_count" {
  description = "Fargateのタスク数"
  default     = 1
}

variable "lb_target_group_arn" {
  description = "LBのtarget_groupのARN"
  type        = string
}

variable "container_definitions" {
  description = "コンテナ定義"
  default     = []
}

variable "log_retention_in_days" {
  description = "ログの保持期間"
  default     = 0
}

variable "container_cpu" {
  description = "コンテナのCPU"
  default     = 256
}


variable "container_memory" {
  description = "コンテナのメモリ"
  default     = 512
}
modules/ecs/output.tf
output "ecs_security_group_id" {
  description = "ecsのセキュリティグループID"
  value       = aws_security_group.ecs.id
}

ElastiCache

Memcachedを使うかRedisを使うかでresourceも異なったので、memcached_clusterという命名にしてる。

modules/memcached_cluster/main.tf
data "aws_subnet" "private_subnet" {
  id = var.private_subnet_id
}

# ---------- Memcached Cluster ----------
resource "aws_security_group" "memcached" {
  name        = "${var.name}-memcached"
  vpc_id      = data.aws_subnet.private_subnet.vpc_id
  description = "Managed by Terraform"

  tags = {
    Name = "${var.name}-memcached"
  }
}

resource "aws_security_group_rule" "memcached_ingress" {
  # memcachedへのアクセスを許可する(セキュリティグループ)
  type                     = "ingress"
  security_group_id        = aws_security_group.memcached.id
  source_security_group_id = var.from_security_group_id

  from_port = var.from_port
  to_port   = var.to_port
  protocol  = "TCP"
}

resource "aws_elasticache_subnet_group" "memcached_subnets" {
  name       = "${var.name}-subnets"
  subnet_ids = [var.private_subnet_id]
}

resource "aws_elasticache_cluster" "memcached_cluster" {
  cluster_id           = "${var.name}-cluster"
  engine               = "memcached"
  node_type            = var.node_type
  num_cache_nodes      = var.num_cache_nodes
  parameter_group_name = "default.memcached1.6"
  port                 = var.to_port

  security_group_ids = [aws_security_group.memcached.id]

  subnet_group_name = aws_elasticache_subnet_group.memcached_subnets.name
}
modules/memcached_cluster/variables.tf
variable "name" {
  description = "elasticacheのクラスタ名"
  default     = ""
}

variable "private_subnet_id" {
  description = "設置するプライベートサブネットのID"
  default     = ""
}

variable "node_type" {
  description = "ノードスペック"
  default     = ""
}

variable "num_cache_nodes" {
  description = "ノード数"
  default     = 0
}

variable "from_port" {
  description = "リクエストを許可するポート番号"
  default     = ""
}

variable "from_security_group_id" {
  description = "リクエストを許可するセキュリティグループのID"
  default     = ""
}

variable "to_port" {
  description = "リクエストを受け付けるポート番号"
  default     = ""
}

RDS

Redmineに使ったイメージが、起動時にRDBにデータベースとテーブルが

  • なければ勝手に作成
  • あれば何もしない

という挙動だったので、特にデータベースはTerraformで管理していない。

modules/rds_instance/main.tf
#--------------------------------------------------------------
# Security group
#--------------------------------------------------------------

resource "aws_security_group" "rds-sg" {
  name        = "${var.instance_name}-rds-sg"
  description = "RDS service security group for rds-sg"
  vpc_id      = var.vpc_id
  tags = {
    Name = "rds-sg of mysql"
  }
}

resource "aws_security_group_rule" "rds-sg-rule" {
  security_group_id = aws_security_group.rds-sg.id
  type              = "ingress"
  from_port         = 3306
  to_port           = 3306
  protocol          = "tcp"

  # 指定したプライベートのサブネットからのアクセスを許可する
  cidr_blocks = [for cidr_block in var.ingress_cidr_blocks : cidr_block]
}

#--------------------------------------------------------------
# Subnet group
#--------------------------------------------------------------

resource "aws_db_subnet_group" "rds-subnet-group" {
  name        = "${var.instance_name}-rds-subnet-group"
  description = "rds subnet group"

  subnet_ids = [for id in var.subnet_ids : id]
}

#--------------------------------------------------------------
# RDS
#--------------------------------------------------------------

resource "aws_db_instance" "db" {
  engine         = "mysql"
  engine_version = "5.7"
  identifier     = var.instance_name
  instance_class = var.instance_class

  # 汎用SSD
  storage_type = "gp2"

  parameter_group_name = "default.mysql5.7"

  final_snapshot_identifier = "final-snapshot"

  allocated_storage     = var.allocated_storage
  max_allocated_storage = var.max_allocated_storage

  username = var.master_username
  password = var.master_password

  backup_retention_period = var.backup_retention_period
  backup_window           = var.backup_window

  maintenance_window = var.maintenance_window

  vpc_security_group_ids = [aws_security_group.rds-sg.id]
  db_subnet_group_name   = aws_db_subnet_group.rds-subnet-group.name

  apply_immediately = var.apply_immediately
}
modules/rds_instance/variables.tf
variable "subnet_ids" {
  description = "RDSを配置するサブネットIDリスト (RDSを使うために最低2つ必要)"
  default     = []
  type        = list(string)
}

variable "ingress_cidr_blocks" {
  description = "RDSへのアクセスを許可するCIDRブロックを指定"
  default     = []
  type        = list(string)
}

variable "vpc_id" {
  description = "RDSを配置するVPCのID"
  default     = ""
}

variable "instance_name" {
  description = "インスタンスの名前"
  default     = ""
}

variable "instance_class" {
  description = "インスタンスクラス"
  default     = ""
}

variable "allocated_storage" {
  description = "RDSの最低ストレージ"
  default     = ""
}

variable "max_allocated_storage" {
  description = "RDSのMAXのストレージ"
  default     = ""
}

variable "master_username" {
  description = "RDSのマスターユーザー名"
  type        = string
}

variable "master_password" {
  description = "RDSのマスターパスワード"
  type        = string
}

variable "backup_retention_period" {
  description = "バックアップの保持期間(日)。0〜35が指定可能。"
  default     = 7
}

variable "backup_window" {
  description = "RDSのバックアップ実行時間帯(UTC)。デフォルトはmaintenance_window以外の時間帯。maintenance_windowと被らないように注意。"
  default     = "19:01-17:59"
}

variable "maintenance_window" {
  description = "RDSのメンテナンス実行時間帯(UTC)。デフォルトは日曜03:30 ~ 04:00にしてる。"
  default     = "Sat:18:30-Sat:19:00"
}

variable "apply_immediately" {
  description = "RDSへの設定変更を即時反映するかどうかの設定。即時反映しない場合は、次回のメンテナンスウィンドウで反映される。"
  default     = false
}
modules/rds_instance/output.tf
output "rds_address" {
  description = "RDSのホストネーム"
  value       = aws_db_instance.db.address
}

S3

modules/s3_bucket/main.tf
resource "aws_s3_bucket" "bucket" {
  bucket = var.name
  acl    = var.acl

  tags = {
    Name = var.name
  }
}

resource "aws_s3_bucket_public_access_block" "public_access_block" {
  bucket = aws_s3_bucket.bucket.id

  block_public_acls       = var.is_block_public_acls
  block_public_policy     = var.is_block_public_policy
  ignore_public_acls      = var.is_ignore_public_acls
  restrict_public_buckets = var.is_restrict_public_buckets
}
modules/s3_bucket/variables.tf
variable "name" {
  description = "バケット名"
  default     = ""
}

variable "acl" {
  description = "aclの設定"
  default     = "private"
}

variable "is_block_public_acls" {
  default = true
}

variable "is_block_public_policy" {
  default = true
}

variable "is_ignore_public_acls" {
  default = true
}

variable "is_restrict_public_buckets" {
  default = true
}
modules/s3_bucket/output.tf
output "bucketname" {
  description = "バケット名"
  value       = aws_s3_bucket.bucket.bucket
}

WAF ACL

modules/waf_acl/main.tf
resource "aws_wafv2_web_acl" "acl" {
  name  = var.name
  scope = "REGIONAL"

  # デフォでRule以外のIPは弾く
  default_action {
    block {}
  }

  dynamic "rule" {
    for_each = toset(var.allow_ip_sets)
    content {
      name     = rule.value.name
      priority = rule.value.priority

      action {
        allow {}
      }

      statement {
        ip_set_reference_statement {
          arn = rule.value.arn
        }
      }

      visibility_config {
        cloudwatch_metrics_enabled = false
        metric_name                = "${rule.value.name}-rule-firewall"
        sampled_requests_enabled   = false
      }
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = false
    metric_name                = "${var.name}-firewall"
    sampled_requests_enabled   = false
  }
}

resource "aws_wafv2_web_acl_association" "association" {
  resource_arn = var.association_resource_arn
  web_acl_arn  = aws_wafv2_web_acl.acl.arn
}
modules/waf_acl/variables.tf
variable "name" {
  description = "ACLの名前"
  type        = string
}

variable "allow_ip_sets" {
  description = "許可するIPSetのリスト"
  type        = list(map(any))
}

variable "association_resource_arn" {
  description = "ACLと紐付けるリソースのARN(ALBやAPI Gatewayとか)"
  type        = string
}

WAF IPSet

modules/waf_ip_set/main.tf
resource "aws_wafv2_ip_set" "ip_set" {
  for_each           = var.name_map
  name               = each.key
  scope              = "REGIONAL"
  ip_address_version = "IPV4"
  addresses          = each.value
}
modules/waf_acl/variables.tf
variable "name_map" {
  # key→名前、value→アドレス
  description = "IPセットの名前とアドレスの組み合わせ"
  type        = map(any)
}

DataSync

modules/datasync_s3/main.tf
# ---------- source iam policy ----------

resource "aws_iam_role" "datasync_source" {
  name = "${var.name}_datasync_source"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "datasync.amazonaws.com"
        }
      }
    ]
  })
}

data "aws_iam_policy_document" "datasync_source_policy_document" {
  version = "2012-10-17"
  statement {
    effect = "Allow"
    actions = [
      "s3:GetBucketLocation",
      "s3:ListBucket",
      "s3:ListBucketMultipartUploads",
    ]
    resources = ["arn:aws:s3:::${var.source_bucket_name}"]
  }
  statement {
    effect = "Allow"
    actions = [
      "s3:AbortMultipartUpload",
      "s3:DeleteObject",
      "s3:GetObject",
      "s3:ListMultipartUploadParts",
      "s3:PutObjectTagging",
      "s3:GetObjectTagging",
      "s3:PutObject",
    ]
    resources = ["arn:aws:s3:::${var.source_bucket_name}/*"]
  }
}

resource "aws_iam_policy" "datasync_source_policy" {
  name_prefix = aws_iam_role.datasync_source.name
  policy      = data.aws_iam_policy_document.datasync_source_policy_document.json
}

resource "aws_iam_role_policy_attachment" "datasync_source_attachement" {
  role       = aws_iam_role.datasync_source.name
  policy_arn = aws_iam_policy.datasync_source_policy.arn
}

# ---------- destination iam policy ----------

resource "aws_iam_role" "datasync_destination" {
  name = "${var.name}_datasync_destination"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "datasync.amazonaws.com"
        }
      }
    ]
  })
}


data "aws_iam_policy_document" "datasync_destination_policy_document" {
  version = "2012-10-17"
  statement {
    effect = "Allow"
    actions = [
      "s3:GetBucketLocation",
      "s3:ListBucket",
      "s3:ListBucketMultipartUploads",
    ]
    resources = ["arn:aws:s3:::${var.destination_bucket_name}"]
  }
  statement {
    effect = "Allow"
    actions = [
      "s3:AbortMultipartUpload",
      "s3:DeleteObject",
      "s3:GetObject",
      "s3:ListMultipartUploadParts",
      "s3:PutObjectTagging",
      "s3:GetObjectTagging",
      "s3:PutObject",
    ]
    resources = ["arn:aws:s3:::${var.destination_bucket_name}/*"]
  }
}

resource "aws_iam_policy" "datasync_destination_policy" {
  name_prefix = aws_iam_role.datasync_destination.name
  policy      = data.aws_iam_policy_document.datasync_destination_policy_document.json
}

resource "aws_iam_role_policy_attachment" "datasync_destination_attachement" {
  role       = aws_iam_role.datasync_destination.name
  policy_arn = aws_iam_policy.datasync_destination_policy.arn
}

# ---------- main resource ----------

resource "aws_datasync_location_s3" "source" {
  s3_bucket_arn = "arn:aws:s3:::${var.source_bucket_name}"
  subdirectory  = var.source_subdirectory

  s3_config {
    bucket_access_role_arn = aws_iam_role.datasync_source.arn
  }
}

resource "aws_datasync_location_s3" "destination" {
  s3_bucket_arn = "arn:aws:s3:::${var.destination_bucket_name}"
  subdirectory  = var.destination_subdirectory

  s3_config {
    bucket_access_role_arn = aws_iam_role.datasync_destination.arn
  }
}

resource "aws_cloudwatch_log_group" "log_group" {
  name              = "${var.name}_datasync_log_group"
  retention_in_days = 0
}

resource "aws_datasync_task" "task" {
  name = var.name

  source_location_arn      = aws_datasync_location_s3.source.arn
  destination_location_arn = aws_datasync_location_s3.destination.arn
  cloudwatch_log_group_arn = aws_cloudwatch_log_group.log_group.arn

  options {
    bytes_per_second  = -1
    posix_permissions = "NONE"
    uid               = "NONE"
    gid               = "NONE"
    # デフォルトだとログを送ってくれないので明示的に指定
    # TRANSFERだとファイルごとにロクでそうで料金が心配なのでBASICにする
    log_level = "BASIC"
  }
}
modules/datasync_s3/variables.tf

   
variable "name" {
  description = "datasync関連のリソースのprefix"
  default     = ""
}

variable "source_bucket_name" {
  description = "datasyncの移行元バケット名"
  default     = ""
}

variable "source_subdirectory" {
  description = "datasyncの移行元サブディレクトリ"
  default     = ""
}

variable "source_bucket_access_role_arn" {
  description = "datasyncの移行元バケットへアクセスするためのロール"
  default     = ""
}

variable "destination_bucket_name" {
  description = "datasyncの移行先バケット名"
  default     = ""
}

variable "destination_subdirectory" {
  description = "datasyncの移行先サブディレクトリ"
  default     = ""
}

variable "destination_bucket_access_role_arn" {
  description = "datasyncの移行先バケットへアクセスするためのロール"
  default     = ""
}

main.tf

実際に各々のmoduleを呼び出すと下記のような感じになる。

main.tf
data "aws_subnet" "private-a" {
  id = "subnet-xx"
}

data "aws_subnet" "private-c" {
  id = "subnet-xx"
}

# 事前にSecretはコンソールで登録しておく
data "aws_secretsmanager_secret" "prd_redmine_secret" {
  arn = "arn:aws:secretsmanager:xxx"
}

data "aws_secretsmanager_secret_version" "prd_redmine_secret_version" {
  secret_id = data.aws_secretsmanager_secret.prd_redmine_secret.id
}

data "aws_region" "current" {}

locals {
  vpc_id = data.aws_subnet.private-a.vpc_id

  datasync_source_bucket_name      = "xxx-redmine"
  datasync_destination_bucket_name = "xxx-redmine"

  prd_secret_map = jsondecode(data.aws_secretsmanager_secret_version.prd_redmine_secret_version.secret_string)
}

# ---------- ECR ----------

module "ecr" {
  source = "../../modules/ecr"

  name = "redmine"
}

# ---------- RDS ----------
module "rds_instance" {
  source = "../../modules/rds_instance"

  # RDSを配置するサブネットのID
  subnet_ids = [
    data.aws_subnet.private-a.id,
    data.aws_subnet.private-c.id
  ]

  # RDSへのアクセスを許可するCIDR
  ingress_cidr_blocks = [
    data.aws_subnet.private-a.cidr_block,
    data.aws_subnet.private-c.cidr_block
  ]

  vpc_id = local.vpc_id

  instance_name  = "{DBのname}"
  instance_class = "db.t3.micro"

  master_username = local.prd_secret_map["REDMINE_USER"]
  master_password = local.prd_secret_map["REDMINE_PASSWORD"]

  allocated_storage     = 20
  max_allocated_storage = 1000

  apply_immediately = true
}

# ---------- ALB ----------

module "alb" {
  source = "../../modules/alb"

  name = "redmine"

  public_subnet_ids = [
    "subnet-xx",
    "subnet-xx"
  ]

  acm_domain = "*.xx.com"
}

module "alb_target_group" {
  source = "../../modules/alb_target_group"

  # ホスト名
  target_host = "redmine.xx.com"

  # 対象ALBのlistenerのARNを指定
  alb_listener_arn = module.alb.alb_listener_arn

  alb_listener_priority = 2

  target_group_name = "redmine-target-group"
  target_port       = 80
  target_protocol   = "HTTP"
  target_vpc_id     = local.vpc_id
  target_type       = "ip"

  health_check_path = "/"
}

module "waf_acl" {
  source = "../../modules/waf_acl"

  name = "redmine_firewall"

  # ACLとALBを紐付ける
  association_resource_arn = module.alb.arn

  allow_ip_sets = [
    {
      name     = "会社",
      priority = 1,
      arn      = "arn:aws:wafv2:xx"
    },
  ]

  depends_on = [
    module.alb
  ]
}

# ---------- ECS クラスタ ----------
module "ecs" {
  source = "../../modules/ecs"

  name = "redmine"

  # private-a
  private_subnet_id = "subnet-xx"

  # ALBのsecurity_group_id
  from_security_group_id = module.alb.alb_security_group_id

  # ecs_serviceに紐付けるLBのtarget_groupのARN
  lb_target_group_arn = module.alb_target_group.arn

  fargate_platform_version = "1.4.0"
  task_count               = 1

  log_retention_in_days = 30

  # オンプレの時のスペックに合わせる
  container_cpu    = 2048
  container_memory = 6144

  container_definitions = [
    {
      name      = "redmine"
      image     = "${module.ecr.ecr_repository_url}:v0.0.1"
      essential = true
      portMappings = [
        { "protocol" : "tcp", "containerPort" = 80 }
      ]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          awslogs-group         = "redmine"
          awslogs-region        = data.aws_region.current.name
          awslogs-stream-prefix = "redmine"
        }
      }
      environment = [
        { "name" : "TZ", "value" : "Asia/Tokyo" },
        { "name" : "DB_ADAPTER", "value" : "mysql2" },
        { "name" : "DB_HOST", "value" : "${module.rds_instance.rds_address}" },
        { "name" : "DB_PORT", "value" : "${local.prd_secret_map["RDS_PORT"]}" },
        { "name" : "DB_NAME", "value" : "${local.prd_secret_map["REDMINE_DB"]}" },
        { "name" : "DB_USER", "value" : "${local.prd_secret_map["REDMINE_RDS_MASTER_USER"]}" },
        { "name" : "DB_PASS", "value" : "${local.prd_secret_map["REDMINE_RDS_MASTER_PASSWORD"]}" },
        { "name" : "DB_ENCODING", "value" : "utf8mb4" },
        { "name" : "REDMINE_PORT", "value" : "${local.prd_secret_map["REDMINE_PORT"]}" },
      ]
    }
  ]
}

# ---------- S3(Redmineの画像管理) ----------
module "s3_bucket" {
  source = "../../modules/s3_bucket"

  name = "xxx-redmine"
}

# ---------- Elasticache(Memcached) ----------
module "memcached_cluster" {
  source = "../../modules/memcached_cluster"

  name                   = "redmine"
  private_subnet_id      = data.aws_subnet.private-a.id
  node_type              = "cache.t4g.micro"
  num_cache_nodes        = 1
  from_security_group_id = module.ecs.ecs_security_group_id
  from_port              = ${local.prd_secret_map["MEMCACHED_PORT"]}
  to_port                = ${local.prd_secret_map["MEMCACHED_PORT"]}
}

# ---------- Datasync(S3データの移行) ----------
module "datasync" {
  source = "../../modules/datasync_s3"

  name = "redmine"

  source_bucket_name  = "xxx-redmine"
  source_subdirectory = "/xxx"

  destination_bucket_name  = "xxx-redmine"
  destination_subdirectory = "/xxx"
}

# CloudWatch Logs のリージョンレベルのポリシー(特定のリソースには結びつかない)
# これがないとDataSync → CloudWatchにログが送れない
data "aws_iam_policy_document" "log_publishing_policy" {
  version = "2012-10-17"
  statement {
    sid    = "DataSyncLogsToCloudWatchLogs"
    effect = "Allow"
    principals {
      identifiers = ["datasync.amazonaws.com"]
      type        = "Service"
    }
    actions = [
      "logs:PutLogEvents",
      "logs:CreateLogStream"
    ]
    resources = ["arn:aws:logs:*"]
  }
}

resource "aws_cloudwatch_log_resource_policy" "log_publishing_policy" {
  policy_document = data.aws_iam_policy_document.log_publishing_policy.json
  policy_name     = "log-publishing-policy"
}

# ---------- WAF IP Set ----------

module "waf_ip_set" {
  source = "../../modules/waf_ip_set"

  name_map = {
    "会社" : [
      "xxx.xxx.x.xxx/32",
    ],
  }
}

実際の移行手順

移行する必要があったのは下記の2つだった。

①MySQLに入っているRedmineのユーザーデータ

こちらはシンプルにmysqldumpを行った。

mysqldumpは軽い処理ではないので、事前にサーバーのCPU使用率やメモリ負荷を調べておくこと。

  • オンプレ環境からmysqldumpでデータを取ってくる
  • 事前に踏み台サーバーを建てる
  • 踏み台サーバーにscpでダンプファイルを転送
  • 踏み台サーバーにsshして、RDSに対してダンプを投入
# sshポートフォワード (MySQL)
$ ssh -NL 12345:{オンプレのIP}:3306 {sshユーザー名}@{オンプレのIP}

# 別タブで実行
$ mysqldump -u {ユーザー名} -p{パスワード} -h 127.0.0.1 -P 12345 --single-transaction --compress --skip-column-statistics {DBのname} > ~/Desktop/redmine.sql

# jump-server(踏み台サーバー)にダンプファイルを転送
$ scp -i jump-server.pem redmine.sql ec2-user@{踏み台サーバーのPublicIP}:

# jump-server(踏み台サーバー)にssh
$ ssh -i jump-server.pem ec2-user@{踏み台サーバーのPublicIP}

# ダンプを投入する。
$ mysql -h xxx.rds.amazonaws.com  -u {ユーザー名} -p{パスワード} {DBのname} < redmine.sql

②S3に入っている画像、動画

DataSyncを採用したので、コンソールからポチるだけだった。

10TB近くあったが、40分程で転送は終了した。

DataSync自体が

  • 1GBあたり料金が6円
  • 1秒あたり3~4GBの転送速度

なので、採用してよかった。

実際に移行してみた感想

ハードだったが、楽しかった

インフラ構築以外にもやる事が結構あり、例えば

  • オンプレ時代の構成の把握/調査
    → オンプレ時代のドキュメントがなかった為

  • 移行実施日時を社内、社外に通知周りのディレクション
    → 社内DNSサーバーの設定を切り替える都合で、業務時間内に2時間程度止めて移行する必要があった且つ、Redmine自体が外部の協力会社の人も使っていた。各プロジェクトのPMと協力して外部の会社にメールを送ってもらって認知してもらった。

などのディレクション、舵取り周りも行った。
(中々大変だったが、楽しかった。)

計画のマイルストーンを建てて良かった

基本的にこういう社内ツール系のタスクは明確な期限がなく、後ろ倒しにどんどん出来てしまう。

後ろ倒しにするといつまで経っても終わらないので、しっかりと期限を決めて「何月に何が終わっているか」を明確化して、作業を進める事が重要だなと改めて感じた。

最終的に通常業務と並行する形で3ヶ月という期間で移行を完了させられたのは、1月にマイルストーンを建てて置いたのが大きかったと思う。

1月: 移行先のAWS環境構築
2月: 移行先のAWS環境のデバッグ
    各プロジェクト/外部会社への周知
3月: 移行テスト、移行実施

とにかくオーナーシップが大事

移行は1週間で終わるタスクでもなく、大体長丁場 & 日の目を見るまでに時間がかかるタスクになる。

精神論にはなってしまうが、「最後まで責任持ってやり遂げてやる!」 というオーナーシップを高く持って、遂行するのが大事だなって改めて思った。

Discussion