hiroportation

ITの話だったり、音楽の話、便利なガジェットの話題などを発信しています

Terraform によるマルチクラウド(AWS / GCP / Azure)環境構築と動作検証

Terraform にてAWSGCP、Azure を使ったマルチクラウドを構築してみました。

今回使うgithubレポジトリは以下になります。

https://github.com/vn-cdr/multicloud-docker

1. 何を検証したいか

Terraform にて自由な形式のマルチクラウド環境を短時間で用意し、様々なケースを想定したアプリケーション開発役立てたい。
以下クラウドVPNで繋げてDirectory Serviceを設定するところまでを対象にしたいと思います。



2. Terraform 実行環境をコンテナで用意

Docker コンテナ上であれば、プラットフォームを気にすることなくTerraformが使えます。

AWS/Azure/GCP APIクライアントを一つのコンテナにて管理するようにする。複数にしても管理が大変なだけ。
※terraformコマンドをローカルホスト上で実行できるようにしたいが、今後別途考える

f:id:thelarklife1021:20210429050024p:plain:h500
terraform を コンテナ上で実行する



3. 接続設定

Terraform実行のためにAPIクライアントを準備します。 各クラウドAPI用ユーザの作成方法がやや異なります。



3.1. AWS APIクライアントの準備

AWSでは一般ユーザと同じIAM画面で作成できます。

認証に必要なパラメータ

AWS_ACCESS_KEY_ID="<aws_access_key_id>"
AWS_SECRET_ACCESS_KEY="<aws_secret_access_key>"

取得手順:


① IAMにてユーザを作成する

  • ユーザタブへ移動
  • ユーザ作成を押下
    f:id:thelarklife1021:20210429051555p:plain:h200


② ユーザ設定

  • ユーザ名設定
  • アクセス種類から「プログラムによるアクセス」をチェック
    f:id:thelarklife1021:20210429051804p:plain:h200


③ ユーザの権限設定

  • アクセス許可の設定から「既存ポリシーを直接アタッチ」を選択
  • ポリシー一覧からポリシーを選択(AdministratiorAccessを選べば全てのサービスのオーナー権限を設定できます)
    f:id:thelarklife1021:20210429051932p:plain:h200


④ タグ設定(オプション)

  • 必要な場合はここでタグを作成することにより、ユーザ管理に役立てられます
  • 最後に右下の設定確認をして完了
    f:id:thelarklife1021:20210429052020p:plain:h200



3.2. GCP APIクライアントの準備

GCPでは サービスアカウント にてCLIアクセスが可能になります。 サービスアカウントでは扱うアカウント情報が多いためか一般的にjsonファイルを指定してCLIアクセスするようです。

  • 認証に必要なパラメータ(GCPjsonファイルになります)
<account名>.json

※今回account名はgcp-service-accountで設定します。

取得手順: サービスアカウント作成


① サービスアカウントの作成

  • 「IAMと管理」からサービスタブを選択
  • 「サービスアカウントの作成」を押下
  • サービスアカウント名の設定や権限の設定を入力し進めていく
  • 完了を押下
    f:id:thelarklife1021:20210429052419p:plain:h200


② サービスアカウントのアカウント情報を取得

  • サービスアカウント一覧からの作成したアカウントの行の操作 列を選択して「鍵を作成」を選択する
  • そうするとjsonファイルがダウンロードされる
    f:id:thelarklife1021:20210429052506p:plain:h200


③ サービスアカウント権限の有効化

  • 使用するサービスアカウントのAPIを有効化します



3.3. Azure APIクライアントの準備

Azureでは サービスプリンシパル というユーザにてCLIアクセスを可能にします。

  • 認証に必要なパラメータ
export ARM_SUBSCRIPTION_ID="<arm_subscription_id>"
export ARM_CLIENT_ID="<arm_client_id>"
export ARM_CLIENT_SECRET="<arm_client_secret>"
export ARM_TENANT_ID="<arm_tenant_id>"


① アプリの登録


② 新規ユーザ作成

  • 新規作成を押下
    f:id:thelarklife1021:20210429052646p:plain:h200


③ 必要事項入力

  • 情報を入力すれば完了(名前入力して、"サポートされているアカウントの種類"はそのまま)
    f:id:thelarklife1021:20210429052750p:plain:h200

※手順は以下より https://docs.microsoft.com/ja-jp/azure/active-directory/develop/howto-create-service-principal-portal



4. 各クラウドVPN接続

今回Terraform で構築するマルチクラウド論理構成は以下のようになります。

f:id:thelarklife1021:20210430035956p:plain
マルチクラウド論理構成図



4.1. AWS/Azureクレデンシャルの設定

cd multicloud-docker

mkdir ./.secret

cat << EOL > ./.secret/admin-rc.txt
# AWS Credential
AWS_ACCESS_KEY_ID="<aws_access_key_id>"
AWS_SECRET_ACCESS_KEY="<aws_secret_access_key>"

# Azure Credential
export ARM_SUBSCRIPTION_ID="<arm_subscription_id>"
export ARM_CLIENT_ID="<arm_client_id>"
export ARM_CLIENT_SECRET="<arm_client_secret>"
export ARM_TENANT_ID="<arm_tenant_id>"
EOL

# GCPサービスアカウントファイルは以下に配置
vi ./.secret/gcp-service-account.json


4.2. terraform変数の設定

# サンプルからコピー
cp  terraform.tfvars.sample terraform.tfvars

# e.g. を参考に変数を埋める (詳しくは 5.3. terraform.tfvars を参照ください)
vi terraform.tfvars


4.3. コンテナ起動

# コンテナビルド
docker build . -t multicloud-docker:latest

# コンテナ起動
docker run -it --name multicloud-docker --env-file .secret/admin-rc.txt multicloud-docker:latest

# コンテナに入れたことを確認
root@9f2f313b91bc:~# ls
aws  azure  google  main.tf  providers.tf  terraform.tfvars  variables.tf  vpn


4.4. Terraform 実行

# Terraform 初期設定
terraform init

# tfファイルのフォーマットチェック
terraform fmt -check

# Terraform 実行計画
terraform plan 

# Terraform 実行
time TF_LOG=debug TF_LOG_PATH="./terraform_log_`date "+%Y%m%d-%H%M%S"`" terraform apply


GCPAPIが無効になってエラーになる場合は有効化しておいてください。
terrafrom上でも有効化、無効化は可能ですが、エラーなどで意図せず有効化さたままになってしまうことを防ぐため、今回terraformには含めませんでした。


以下のようなエラーになる場合は terraform applyを再実行してくさい.

Error: Error creating customer gateway: MissingParameter: The request must contain the parameter ipAddress
    status code: 400, request id: XXXXXXXXX

  on vpn/aws.tf line 49, in resource "aws_customer_gateway" "azure":
  49: resource "aws_customer_gateway" "azure" {


4.5. terraform apply 実行結果

# エラーなく以下の通りメッセージが出ればデプロイ完了です

Apply complete! Resources: 65 added, 0 changed, 0 destroyed.

real 59m21.090s
user 0m12.054s
sys  0m22.781s
root@99ae86e95c98:~#


5. VMインスタンスの追加と動作確認

最後に動作確認の手順を記載します。 AWSGCP、Azureで同じ準備が必要になります。


5.1. 各クラウドVMインスタンスを追加

AWSGCP、Azure、にて以下条件でインスタンスを立ち上げる

  • VPCは今回terraformで作成したものを使うこと
  • サブネットは作成したVPC内のprivate subnetを使うこと
  • セキュリティグループはICMPとSSHのインバウンド、アウトバウンドを許可にすること
  • 他OSやスペックは好みのものを選んでください

ファイアウォールまたはACLの穴あけが必要ですので、各クラウドVPCに紐づいているACLに穴を開けてください

③外部IP(GIP)を設定している場合はsshできることを確認してください


5.2. ネットワーク疎通

下記の通りprivate-ipにtracepathすると インターネットを経由せずにAWS-GCP間で疎通が通ることを確認できる。 これはVPNトンネルにて通信がフォワーディングされているためである。

vn-cdr@instance-1:~$ tracepath 10.0.21.84
 1?: [LOCALHOST]                      pmtu 1390
 1:  no reply
 2:  10.0.21.84                                           30.783ms reached
     Resume: pmtu 1390 hops 2 back 2 
vn-cdr@instance-1:~$

※インターネットを通ってしまう場合や疎通できない場合はVPC周りのルートまたはファイアウォールを確認すること


6. terraform 処理概要

処理を担うtfファイルは、なるべくサービスごとに分けて作成

.
├── aws
│   ├── dns
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   └── variables.tf
│   └── vpc
│       ├── main.tf
│       ├── outputs.tf
│       └── variables.tf
├── azure
│   └── vnet
│       ├── main.tf
│       ├── output.tf
│       └── variables.tf
├── google
│   └── vpc
│       ├── main.tf
│       ├── outputs.tf
│       └── variables.tf
├── main.tf
├── providers.tf
├── terraform.tfstate
├── terraform.tfstate.backup
├── terraform.tfvars
├── terraform.tfvars.sample
├── variables.tf
└── vpn
    ├── aws.tf
    ├── azure.tf
    ├── google.tf
    └── variables.tf


6.1. 処理フロー

変数群は./providors.tf ./terraform.tfvars を順次読み込み
 ↓
AWSのawsのvpc、dns作成(./main.tf)
 ↓
GCPのVPC作成(./google/vpc/main.tf)
 ↓
AzureのVNET作成(./azure/vnet/main.tf)
 ↓
VPNに必要なVGW作成をAWS GCP Azure順で作成(./vpn/{aws.tf, google.tf, azure.tf})
 ↓
設定に問題がなければここで各クラウドの VGW でステータスが BGP Established になる


6.2. Dockerfile

Dockerfileでは各クラウドでterraformを実行するために必要なパッケージをインストールするように設定している。

FROM python:3.8

ARG pip_installer="https://bootstrap.pypa.io/get-pip.py"
ARG awscli_version="1.16.168"

# install aws-cli
RUN pip install awscli==${awscli_version}

# install sam
RUN pip install --user --upgrade aws-sam-cli
ENV PATH $PATH:/root/.local/bin

# install command & azure-cli.
RUN apt update && apt install -y less vim wget unzip curl npm && npm install azure-cli

# install terraform.
RUN wget https://releases.hashicorp.com/terraform/0.14.4/terraform_0.14.4_linux_amd64.zip && \
    unzip ./terraform_0.14.4_linux_amd64.zip -d /usr/local/bin/

# copy resources.
COPY ./main.tf /root/
COPY ./providers.tf /root/
COPY ./variables.tf /root/
COPY ./aws /root/aws
COPY ./google /root/google
COPY ./azure /root/azure
COPY ./vpn /root/vpn
COPY ./.secret/gcp-service-account.json /root/.config/gcloud/gcp-service-account.json
COPY ./terraform.tfvars /root/

# initialize command.
ENTRYPOINT ["/bin/bash"]

WORKDIR /root


6.3. terraform.tfvars

以下の通りTerraform実行に必要な引数を定義している。構築リージョンや各PWなどをここで設定します。

aws_dns_suffix = ""                     # e.g. "compute.internal"
aws_directory_service_password = ""     # e.g. "Password123!"
google_region = ""                      # e.g. "us-central1"
google_project_id = ""                  # e.g. "sample-project"
google_service_account_json = ""        # e.g. "gcp-service-account.json"
azure_resource_group_name = ""          # e.g. "resource_group"
azure_location = ""                     # e.g. "westeurope"
項目 説明
aws_dns_suffix AWS Managed Microsoft AD に使うDNSサフィックスを設定してください
aws_directory_service_password AWS Managed Microsoft AD に使うパスワードを設定してください
google_region リソースを作成するGCPのリージョンを設定してください
google_project_id 使用する Project ID を設定してください
google_service_account_json 使用するサービスアカウントのjsonファイルを指定してください
azure_resource_group_name Azureで作成するリソースグループを設定してください


6.4. メイン処理(一部抜粋)

AWS全体に必要な変数はここに書きます。VPCDNSVPNはモジュール化します。

./main.tf

module "aws_vpc" {
  source = "./aws/vpc"

  vpc_name     = "aws-test"
  cidr_block   = "10.0.0.0/16"
  subnet_count = 1
}

module "dns" {
  source = "./aws/dns"

  vpc_id                 = module.aws_vpc.vpc_id
  directory_name         = "test.internal"
  directory_password     = var.aws_directory_service_password
  dns_subnet_cidr_prefix = "10.0.0.0/20"
  private_route_table_id = module.aws_vpc.private_route_table_id
}

module "vpn" {
  source = "./vpn"

  aws_vpc_id          = module.aws_vpc.vpc_id
  aws_route_table_ids = [module.aws_vpc.private_route_table_id, module.aws_vpc.public_route_table_id]

  dns_network_acl_id = module.dns.dns_network_acl_id
}

./providers.tf

terraform {
  required_providers {
    aws = "~> 2.39.0"
  }
}

provider "aws" {
  region = var.aws_region
}

./variables.tf

variable "aws_region" {
  type        = string
  description = "aws region to use"
}

variable "aws_dns_suffix" {
  type        = string
  description = "DNS suffix Setting"
}

variable "aws_directory_service_password" {
  type        = string
  description = "password to use for the aws directory service (enabling DNS)"
}

VPC設定

VPC周りはここに書きます。VPC、subnet、セキュリティグループ、ルーティングテーブルなどを作成します。

./aws/vpc/main.tf

resource "aws_vpc" "main" {
  cidr_block = var.cidr_block

  tags = {
    Name = var.vpc_name
  }
}

resource "aws_subnet" "private" {
  count = var.subnet_count

  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.cidr_block, 4, count.index * 2 + 1)

  tags = {
    Name = "private-subnet-${count.index}"
  }
}

resource "aws_subnet" "public" {
  count = var.subnet_count

  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.cidr_block, 4, count.index * 2 + 2)

  tags = {
    Name = "public-subnet-${count.index}"
  }
}

resource "aws_network_acl" "public" {
  vpc_id     = aws_vpc.main.id
  subnet_ids = aws_subnet.public[*].id

  ingress {
    protocol   = "tcp"
    rule_no    = 100
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 80
    to_port    = 80
  }

  ingress {
    protocol   = "tcp"
    rule_no    = 200
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 443
    to_port    = 443
  }

  ingress {
    protocol   = "tcp"
    rule_no    = 400
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1024
    to_port    = 65535
  }

  ingress {
    protocol   = "udp"
    rule_no    = 500
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1024
    to_port    = 65535
  }

  ingress {
    protocol   = -1
    rule_no    = 1000
    action     = "allow"
    cidr_block = aws_vpc.main.cidr_block
    from_port  = 0
    to_port    = 0
  }

  egress {
    protocol   = -1
    rule_no    = 100
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 0
    to_port    = 0
  }

  tags = {
    Name = "public-acl"
  }
}

resource "aws_network_acl" "private" {
  vpc_id     = aws_vpc.main.id
  subnet_ids = aws_subnet.private[*].id

  ingress {
    protocol   = "tcp"
    rule_no    = 400
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1024
    to_port    = 65535
  }

  ingress {
    protocol   = "udp"
    rule_no    = 500
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1024
    to_port    = 65535
  }

  ingress {
    protocol   = -1
    rule_no    = 1000
    action     = "allow"
    cidr_block = aws_vpc.main.cidr_block
    from_port  = 0
    to_port    = 0
  }

  egress {
    protocol   = -1
    rule_no    = 100
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 0
    to_port    = 0
  }

  tags = {
    Name = "private-acl"
  }
}

# Gateways

resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.vpc_name}-internet-gateway"
  }
}

resource "aws_eip" "nat" {
  vpc = true

  tags = {
    Name = "nat-elastic-ip"
  }

  depends_on = [aws_internet_gateway.gw]
}

resource "aws_nat_gateway" "gw" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id

  tags = {
    Name = "${var.vpc_name}-nat-gateway"
  }

  depends_on = [aws_internet_gateway.gw]
}

# Route Tables

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "public-route-table"
  }
}

resource "aws_route" "public_igw" {
  route_table_id            = aws_route_table.public.id
  destination_cidr_block    = "0.0.0.0/0"
  gateway_id = aws_internet_gateway.gw.id
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "private-route-table"
  }
}

resource "aws_route" "private_nat" {
  route_table_id            = aws_route_table.private.id
  destination_cidr_block    = "0.0.0.0/0"
  nat_gateway_id = aws_nat_gateway.gw.id
}

resource "aws_route_table_association" "public" {
  count = var.subnet_count

  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "private" {
  count = var.subnet_count

  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

以下は ./main.tf のローカル変数から取得します。 ./aws/vpc/variables.tf

variable "vpc_name" {
  type        = string
  description = "the name for the vpc"
}

variable "cidr_block" {
  type        = string
  description = "the cidr block to use for the vpc"
}

variable "subnet_count" {
  type        = number
  description = "how many private and private subnets to create"
}

以下は ./main.tf のモジュール呼び出しに返します。 ./aws/vpc/outputs.tf

output "vpc_id" {
  value       = aws_vpc.main.id
  description = "the id of the vpc"
}

output "vpc_cidr_block" {
  value       = aws_vpc.main.cidr_block
  description = "the cidr block of the vpc"
}

output "private_route_table_id" {
  value = aws_route_table.private.id
  description = "the private route table of the vpc"
}

output "public_route_table_id" {
  value = aws_route_table.public.id
  description = "the public route table of the vpc"
}

以下にはRoute53とSimpleADを設定します。 ./aws/dns/main.tf

data "aws_vpc" "main" {
  id = var.vpc_id
}

data "aws_availability_zones" "available" {
  state = "available"
}

locals {
  dns_subnet_availability_zones = slice(data.aws_availability_zones.available.names, 0, 2)
}

resource "aws_subnet" "dns" {
  count = length(local.dns_subnet_availability_zones)

  vpc_id            = var.vpc_id
  cidr_block        = cidrsubnet(var.dns_subnet_cidr_prefix, 4, count.index)
  availability_zone = local.dns_subnet_availability_zones[count.index]

  tags = {
    Name = "directory-service-${local.dns_subnet_availability_zones[count.index]}"
  }
}

resource "aws_route_table_association" "dns" {
  count = length(aws_subnet.dns)

  route_table_id = var.private_route_table_id
  subnet_id      = aws_subnet.dns[count.index].id
}

resource "aws_network_acl" "dns" {
  vpc_id     = var.vpc_id
  subnet_ids = aws_subnet.dns[*].id

  tags = {
    Name = "directory-service-acl"
  }
}

resource "aws_network_acl_rule" "ingress" {
  network_acl_id = aws_network_acl.dns.id

  egress      = false
  protocol    = -1
  rule_number = 100
  rule_action = "allow"
  cidr_block  = data.aws_vpc.main.cidr_block
  from_port   = 0
  to_port     = 0
}

resource "aws_network_acl_rule" "egress" {
  network_acl_id = aws_network_acl.dns.id

  egress      = true
  protocol    = -1
  rule_number = 100
  rule_action = "allow"
  cidr_block = "0.0.0.0/0"
  from_port   = 0
  to_port     = 0
}

resource "aws_directory_service_directory" "dns" {
  name        = var.directory_name
  description = "internal directory for dns forwarding over vpns"

  type = "SimpleAD"
  size = "Small"

  password = var.directory_password

  vpc_settings {
    vpc_id     = var.vpc_id
    subnet_ids = aws_subnet.dns[*].id
  }
}

./aws/dns/variables.tf

variable "vpc_id" {
  type        = string
  description = "the id of the aws vpc"
}

variable "private_route_table_id" {
  type        = string
  description = "private route table id of the aws vpc"
}

variable "directory_name" {
  type        = string
  description = "the name for the directory"
}

variable "directory_password" {
  type        = string
  description = "the admin password for the directory"
}

variable "dns_subnet_cidr_prefix" {
  type        = string
  description = "the cidr prefix for aws dns server subnets"
}

./aws/dns/output.tf

output "dns_ip_addresses" {
  value = split(",", join(",", aws_directory_service_directory.dns.dns_ip_addresses))
}

output "dns_network_acl_id" {
  value = aws_network_acl.dns.id
}



7. 今後について

続きの投稿は以下を予定していますが、投稿日は未定です。気長にお願いします。

  • SSOでのMFAの検証
  • Terraformによる CI/CD
  • マルチクラウドによるサービス冗長化検証