Azure Private LinkをTerraformスクリプトを用いて構築する


概要/構造

Azure Private LinkをTerraformスクリプトを用いて構築することにより、構成するコンポーネントや接続の仕組みについて理解を深めます

  • Azure PrivateLink+SQL Server構成をTerraformを用いて構築する方法を紹介します
  • Terraformスクリプトの実行、および動作確認用VMからSQLServerへの接続確認手順を記載します
  • Terraformスクリプトの内容についてAzure Private Linkを構成する箇所を中心に解説します

学習成果

  • Azure Private LinkをTerraformスクリプトを用いて構築できるようになります
  • Terraformスクリプトで作成するAzureリソースの役割を理解できます

対象者

  • Azure Private LinkをTerraformスクリプトで構築したい人
  • AzurePortalで構築した際に自動作成されるAzureリソースについて理解したい人

前提条件

  • Azureリソースを作成できる権限を持ったAzure サブスクリプションが準備できている
  • Terraform 実行およびAzureVirtualMachineへSSH 接続できる環境が準備できている
    普段からWSLを使っている方はWSLを。それ以外の方はAzurePortalで利用できるCloudShellをオススメします

はじめに

AzurePrivateLinkとは、インターネット経由でアクセスするSQLサーバ等のAzureサービスについて、インターネット向けの接続ポイントだけでなく、PrivateEndpointという仕組みを用いてVNETにも接続ポイントを作成することで、インターネットを介さずAzureサービスに接続でき、よりセキュアな通信を実現できるサービスです。
そんなPrivateLinkですが、2019年9月17日にプレビューリリースされ翌年の2020年2月18日に一般提供が開始されました。一般提供の開始時にはまだ一部のAzureサービスがプレビュー扱いでしたが、3月17日にはAzureStorageをはじめとする各種Azureサービスに対しても一般提供が開始されました。
今回は3月17日に一般提供されたサービスのうちSQLサーバを対象に、PrivateLinkを用いてVNETに接続する環境を構築します。

AzurePortalで構築する場合はウィザードにしがって進めれば構築できるのですが、Terraformを用いて構築する場合は注意点がありました。

本エントリーでは完成版のTerraformスクリプトを公開すると共に、スクリプトの適用と動作確認までの手順を紹介し、Terraformスクリプトを作成する上でのナレッジを共有します。
エントリー後半のスクリプト説明まで読んでいただくとインターネットを経由しないVNET内の通信経路について理解いただける内容を目指しています。

構成

構築手順

準備

  1. 文末あるスクリプト全体をコピーし「main.tf」という名前で保存します。
  2. azcliコマンドでAzureにログインします
$ az login

構築

(1) Terraform実行

  1. Terraformを実行します。
  2. main.tfを保存したディレクトリで次のコマンドを実行します。
    $ terraform init
    $ terraform paln
    $ terrafrom apply

(2) アウトプット確認

以下の通り Terraformからアウトプットが出ることを確認します。
ここで出力された内容は後のデータベース接続に用います。

アウトプット例は次の通りです。
データベース接続先やユーザ名、パスワード等が出力されます。

    Outputs:
    SQL_1_Server = al3gyr-sqlserver.database.windows.net
    SQL_2_Database = al3gyr-sqldatabase
    SQL_3_UserName = mradministrator
    SQL_4_Password = t7D1QzNX2g6i
    VM_1_PublicIpAddress = 104.215.43.12
    VM_2_UserName = adminuser
    VM_3_Password = t7D1QzNX2g6i
    VM_4_sshcommand = ssh [email protected]

接続確認

仮想マシンにSSH接続し、簡単なPythonスクリプトを用いてSQLサーバへの接続確認を行います。

(1) ログイン

アウトプット確認で表示されたVM_4_sshcommandの内容で仮想マシンにSSH接続します。
パスワードについてもアウトプット確認で表示された値を入力します。

(2) パッケージインストール

下記コマンドを実行して動作確認に必要なパッケージをインストールします。

    $ sudo su
    $ curl https://packages.microsoft.com/keys/microsoft.asc?|?apt-key?add?-
    $ curl https://packages.microsoft.com/config/ubuntu/16.04/prod.list?>?/etc/apt/sources.list.d/mssql-release.list
    $ exit
    $ sudo apt-get update
    $ sudo ACCEPT_EULA=Y apt-get -y install msodbcsql17
    $ sudo apt?install -y python-pyodbc

(3) 接続確認スクリプト実行

Terraform アウトプットの内容を元に下記スクリプトを修正。

python コマンドを実行してインタラクティブシェルを起動し、下記スクリプトを貼り付けます。

import pyodbc
server = 'tcp:<Terraform アウトプット SQL_1_Server の値を入力>,1433'
database = '<Terraform アウトプット SQL_2_Database の値を入力>'
username = 'mradministrator'
password = '<Terraform アウトプットSQL_4_Passwordの値を入力>'
cnxn = pyodbc.connect('DRIVER={ODBC Driver 17 for SQL Server};SERVER='+server+';DATABASE='+database+';UID='+username+';PWD='+ password)
cursor = cnxn.cursor()
cursor.execute("SELECT @@version;")
row = cursor.fetchone()
while row:
    print(row[0])
    row = cursor.fetchone()

(4) 結果確認

以下の通りバージョン情報が出れば 正常に接続できています。

Ctrl-Dキーを押してインタラクティブシェルを終了します。

> > > Microsoft SQL Azure (RTM) - 12.0.2000.8
        Apr  9 2020 16:39:55
       Copyright (C) 2019 Microsoft Corporation

リソース確認

すでに上記接続確認でリソースが正しく構築されていることは確認済ですが、一応どんなリソースが作成されているか確認してみましょう。

リソースグループ画面で以下の通り確認できます。

確認手順では意識することは無かったですが、PrivateLinkのネットワークインターフェース(上記画像のfiuht4-endpoint.nic.)が作成されています。

ネットワークインターフェースの詳細を参照することでVNETのプライベートIPアドレスが付与され、仮想ネットワーク/サブネットに接続されていることが確認できます。

「あれ?普通に***.database.windows.netのFQDNに接続したのに、このサブネットのIPアドレスに接続しているの?」という疑問については、下記スクリプトの説明を確認いただくと理解いただけると思います。

このプライベートIPアドレスはスクリプト説明の中でも出てきますので、存在だけ覚えておいてください。

スクリプトの説明

ここからは「プライベートDNSも併せて構築し、かつPrivateLinkに必要なDNSレコードの設定」部分について、スクリプトを元に解説していきます。

  • PrivateLinkをTerraformスクリプトで構築するにあたり、つまづくのは「プライベートDNSサーバを自分で作る」という点だけだと思います。
  • この点以外はTerraform公式ドキュメントにあるサンプルを切り貼りしてつなぎ合わせれば完成します
  • 冒頭に記載した「AzurePortalで構築する場合はウィザードにしがって進めれば構築できる」というのは、AzurePortalでPrivateLinkを構築した場合はプライベートDNSサーバが自動で作成されるためです
  • 今回のようにTerraformスクリプトを用いて構築する場合はTerraformスクリプトの中でプライベートDNSも併せて構築し、かつPrivateLinkに必要なDNSレコードの設定が必要です
    ちゃんとMSのドキュメントを読んでから構築すれば躓き無くできるのですが、いつものノリでTerraform公式ドキュメントるサンプルを見ながら作るとハマりポイントでした。MSドキュメント大事です。

工程は「プライベートDNS作る&VNETと紐づける」と「DNSレコード作る」です。

プライベートDNS作る&VNETと紐づける

まずはプライベートDNS作成部分です。

azurerm_private_dns_zoneを用いて構成し、ゾーン名に該当するnameにはAzure サービス DNS ゾーンの構成で指定されている"privatelink.database.windows.net"を設定します。この設定値はPrivateLinkで接続するAzureサービス毎に異なる値を設定しますのでSQLサーバ以外を接続する場合はMSドキュメントを参照して該当する値を設定します。

また、まれにPrivateLinkの構成に失敗した場合にプライベートDNSだけ作成されてしまう事を防ぐため、depends_onで依存関係を設定しています。ここは必須ではなく念のためです。

resource "azurerm_private_dns_zone" "example" {
  name                = "privatelink.database.windows.net"
  resource_group_name = azurerm_resource_group.example.name

  depends_on = [azurerm_private_endpoint.example]
}

次にプライベートDNSとVNETの紐づけです。

azurerm_private_dns_zone_virtual_network_linkを用いて構成します。

ここは必要とされるパラメーターに作成済のリソース名を割り当てるだけですね。これを設定しておくと、virtual_network_idに指定したVNETに作成したVMは自動的にプライベートDNSを参照するように構成されます。

resource "azurerm_private_dns_zone_virtual_network_link" "example" {
      name                  = "test"
      resource_group_name   = azurerm_resource_group.example.name
      private_dns_zone_name = azurerm_private_dns_zone.example.name
      virtual_network_id    = azurerm_virtual_network.example.id
}

DNSレコード作る

SQLサーバに該当するDNSレコードを作成します。
ここでは接続確認で設定、接続した***.database.windows.netのFQDNと、リソース確認の中で確認したPrivateLinkのnicのIPアドレスの紐づけを行います。
まず、azurerm_private_endpoint_connectionを用いてPrivateEndpointに割り当てられたIPアドレスを取得します。

すでに作成済のリソースの情報を得るだけなので、頭についているのがresourceではなくdataとなっています
次に、azurerm_private_dns_a_recordを用いてDNSレコード(Aレコード)を作成します。
nameにSQLサーバのホスト名、recordsにホスト名に対応するIPアドレスとして前の手順で準備したPrivateEndpointに割り当てられたIPアドレスを設定します。

※PrivateEndpointは複数のVNETに接続しそれぞれでIPアドレスを持つMulti~~という機能があります。そのため、.example.private_service_connectionとprivate_ip_addressの間に1個目を表す0(配列は0からはじまるので)を設定しています。

data "azurerm_private_endpoint_connection" "example" {
  name                = azurerm_private_endpoint.example.name
  resource_group_name = azurerm_resource_group.example.name
}
〜中略〜
resource azurerm_private_dns_a_record sql_server_dns_record {
  name                = azurerm_sql_server.example.name
  zone_name           = azurerm_private_dns_zone.example.name
  resource_group_name = azurerm_resource_group.example.name
  ttl                 = 300
  records             = [data.azurerm_private_endpoint_connection.example.private_service_connection.0.private_ip_address]
}

ここまで記載した「プライベートDNS作る&VNETと紐づける」と「DNSレコード作る」が行われることで、下記の流れでSQLサーバに接続する事になります。

  1. プライベートDNSZoneが作成され、VNETと紐づけされる
  2. 1で紐づけしたVNET上に存在するVMはプライベートDNSを参照する
  3. ***.database.windows.netの名前解決を行うと、レコード追加したPrivateLinkのIPアドレスが回答される
  4. VMはPrivateLinkを経由してSQLサーバに接続する

実際の名前解決を含めた具体的な通信については「カスタム DNS サーバーのない仮想ネットワークのワークロード」を参照ください。

プライベートDNSの必要性

リソース確認の章でPrivateEndpointのインターフェースに設定されたプライベートIPを確認しました。

このタイミングでプライベートIPに直接接続したら良いのでは?と思われたかと思いますが、結論としてはNGでした。

SQLサーバの手前にゲートウェイが存在し、SQLサーバとゲートウェイは多対多の構成になっているとドキュメントに記載があります。

ここからは想像ですがゲートウェイがどのSQLサーバにトラフィックを転送するかの判断情報として接続してきたFQDNを用いているためではないかと考えています。

上記より、IPアドレスではなくFQDNでアクセスすれば良いのでテスト用途であればhostsファイルによる名前解決でも問題無いようです。

まとめ

  • Terraformスクリプト自体はTerraform公式サイトにあるサンプルを切り貼りしているだけなのですが、その過程の「なんでこうしてるの?」を解説してみると思ったより色々ありました。
  • DNS作ったりレコード追加したり、ポータルで作れば自動作成なのにちょっとめんどくさいなと最初は思いましたが、このおかげでMSドキュメントをちゃんと読むきっかけになったし、接続の流れについても知ることができ、何も知らずに使うよりはよかったなと思います。
  • ひと手間あったDNS部分ですが、この仕組みにより、インターネット経由で構築済の既存アプリ+SQLサーバに対してもアプリの改修を行わずにPrivateLinkが導入できるというメリットを生み出していると思います。もしかしたらダウンタイム無しで切替できるかも?
    これ、ちょっといいですよね?ひと手間もゆるせる気持ちになりました。
  • 今回の教訓は”急がば回れ”、「ささっと作り終えたいと思ってもTerraform公式サイトのサンプルスクリプトよりも前にMSドキュメントをちゃんと読みましょう」です。

スクリプト全文

    provider "azurerm" {
      version = "=2.8.0"
      features {}
    }

    resource "azurerm_resource_group" "example" {
      name     = "example-resources"
      location = "japanwest"
    }

    # VNET & Subnet
    resource "azurerm_virtual_network" "example" {
      name                = "example-network"
      address_space       = ["10.0.0.0/16"]
      resource_group_name = azurerm_resource_group.example.name
      location            = azurerm_resource_group.example.location
    }

    resource "azurerm_subnet" "example" {
      name                 = "internal"
      resource_group_name  = azurerm_resource_group.example.name
      virtual_network_name = azurerm_virtual_network.example.name
      address_prefixes     = ["10.0.2.0/24"]

      enforce_private_link_endpoint_network_policies = true
    }

    resource "random_string" "password" {
      length  = 12
      special = false
      upper   = true
      number  = true
    }

    # SQL Server & Database
    resource "azurerm_sql_server" "example" {
      name                         = "${random_string.random.result}-sqlserver"
      resource_group_name          = azurerm_resource_group.example.name
      location                     = azurerm_resource_group.example.location
      version                      = "12.0"
      administrator_login          = "mradministrator"
      administrator_login_password = random_string.password.result
    }

    resource "azurerm_sql_database" "example" {
      name                = "${random_string.random.result}-sqldatabase"
      resource_group_name = azurerm_resource_group.example.name
      location            = azurerm_resource_group.example.location
      server_name         = azurerm_sql_server.example.name
    }

    # PrivateEndpoint
    resource "random_string" "random" {
      length  = 6
      special = false
      upper   = false
    }

    resource "azurerm_private_endpoint" "example" {
      name                = "${random_string.random.result}-endpoint"
      resource_group_name = azurerm_resource_group.example.name
      location            = azurerm_resource_group.example.location
      subnet_id           = azurerm_subnet.example.id

      depends_on = [azurerm_sql_database.example]

      private_service_connection {
        name                           = "${random_string.random.result}-privateserviceconnection"
        private_connection_resource_id = azurerm_sql_server.example.id
        subresource_names              = ["sqlServer"]
        is_manual_connection           = false
      }
    }

    data "azurerm_private_endpoint_connection" "example" {
      name                = azurerm_private_endpoint.example.name
      resource_group_name = azurerm_resource_group.example.name
    }

    # Private DNS Zone
    resource "azurerm_private_dns_zone" "example" {
      name                = "privatelink.database.windows.net"
      resource_group_name = azurerm_resource_group.example.name

      depends_on = [azurerm_private_endpoint.example]
    }

    resource "azurerm_private_dns_zone_virtual_network_link" "example" {
      name                  = "test"
      resource_group_name   = azurerm_resource_group.example.name
      private_dns_zone_name = azurerm_private_dns_zone.example.name
      virtual_network_id    = azurerm_virtual_network.example.id
    }

    resource azurerm_private_dns_a_record sql_server_dns_record {
      name                = azurerm_sql_server.example.name
      zone_name           = azurerm_private_dns_zone.example.name
      resource_group_name = azurerm_resource_group.example.name
      ttl                 = 300
      records             = [data.azurerm_private_endpoint_connection.example.private_service_connection.0.private_ip_address]
    }

    # Virtual Machine

    resource "azurerm_public_ip" "example" {
      name                = "acceptanceTestPublicIp1"
      resource_group_name = azurerm_resource_group.example.name
      location            = azurerm_resource_group.example.location
      allocation_method   = "Dynamic"
    }

    resource "azurerm_network_interface" "example" {
      name                = "example-nic"
      resource_group_name = azurerm_resource_group.example.name
      location            = azurerm_resource_group.example.location

      ip_configuration {
        name                          = "internal"
        subnet_id                     = azurerm_subnet.example.id
        private_ip_address_allocation = "Dynamic"
        public_ip_address_id          = azurerm_public_ip.example.id
      }
    }

    resource "azurerm_linux_virtual_machine" "example" {
      name                = "example-machine"
      resource_group_name = azurerm_resource_group.example.name
      location            = azurerm_resource_group.example.location

      disable_password_authentication = false
      admin_username                  = "adminuser"
      admin_password                  = random_string.password.result

      size                  = "Standard_F2"
      network_interface_ids = [azurerm_network_interface.example.id]
      os_disk {
        caching              = "ReadWrite"
        storage_account_type = "Standard_LRS"
      }
      source_image_reference {
        publisher = "Canonical"
        offer     = "UbuntuServer"
        sku       = "16.04-LTS"
        version   = "latest"
      }
    }

    output "SQL_1_Server" {
      value = "${azurerm_sql_server.example.name}.database.windows.net"
    }
    output "SQL_2_Database" {
      value = azurerm_sql_database.example.name
    }
    output "SQL_3_UserName" {
      value = azurerm_sql_server.example.administrator_login
    }
    output "SQL_4_Password" {
      value = azurerm_sql_server.example.administrator_login_password
    }

    output "VM_1_PublicIpAddress" {
      value = azurerm_public_ip.example.ip_address
    }
    output "VM_2_UserName" {
      value = azurerm_linux_virtual_machine.example.admin_username
    }
    output "VM_3_Password" {
      value = azurerm_linux_virtual_machine.example.admin_password
    }

    output "VM_4_sshcommand" {
      value = "ssh ${azurerm_linux_virtual_machine.example.admin_username}@${azurerm_public_ip.example.ip_address}"
    }