オンプレミスDNSからAmazon Route53へ移行した件について

# はじめに
こんにちは。KLabでインフラエンジニアをしている akaoka-t です。
今回は権威DNSサーバ(以降DNSサーバ)を、
オンプレミス環境(以降オンプレ)からAmazon Route53環境(以降Route53)へ移行した件について記載します。

![overview.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618429/d2e5d5b4-705d-b42d-8188-5f447f21a8e2.png)


# オンプレからRoute53への移行背景
移行の背景は、「1. 可用性低下」と「2. 運用管理コストの低減」の2点があります。
1. 可用性低下
これまで、オンプレのDNSサーバではデータセンター(以降DC)の2拠点に分散して運用をしていました。
しかし、1拠点のDC利用が終了することになり、DNSサーバの可用性低下が問題にあがりました。
![canceldc.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618429/d160fb3b-9269-cc95-c5e9-c4d4df3674d5.png)


2. 運用管理コストの低減
オンプレ上にあるDNSサーバのハードウェア/OS/ミドルウェア等の運用管理コストを減らしたいという課題がありました。
管理コストを減らすためには、フルマネージドDNSを選択することが良さそうです。また運用コストを減らすためには、Infrastructure as Code(IaC) を利用した自動化が選択肢としてあげられます。

以上の問題を解決するために、高い可用性を担保しクラウドのフルマネージドなDNSサーバへの移行を検討し、自動化導入を検討しました。
本稿では主にクラウドへの移行までを記載したいと思います。

`AWS Route53`および`Google Cloud DNS` のマルチクラウド環境を検討しましたが、両DNSは **SLA100%**[^1] [^2] であるため、どちらかの単独運用で問題ないと考えました。今回はRoute53を利用することにしました。

[^1]: Amazon Route 53 サービスレベルアグリーメント: https://aws.amazon.com/route53/sla/
[^2]: Cloud DNS Service Level Agreement (SLA): https://cloud.google.com/dns/sla
[^3]: djbdns Wikipedia: https://ja.wikipedia.org/wiki/Djbdns
The tinydns program: https://cr.yp.to/djbdns/tinydns.html


# AWS Route53への移行
## 移行の事前検討
移行に際し、以下4点の検討が必要でした。

1. ゾーンファイルのインポート
    IaC運用を前提に、ゾーンファイルをどのようにインポートしていくか検討する必要があります。
    Route53では、DNSサーバに[BINDを利用していればゾーンファイルのインポートができる]( https://docs.aws.amazon.com/ja_jp/Route53/latest/DeveloperGuide/resource-record-sets-creating-import.html)ので簡単にデータ投入が可能です。
    しかしtinydns[^3]を利用しているので、ゾーンファイルのデータ変換作業が必要です。
    IaCによる運用の自動化を目的の一つとしているので、適切なコードへ変換するパーサーを作成することにしました。
2. IaCとしてCloudforamtionまたはCDKv2のどちらを利用するか
    Cloudformationではコードの行数が膨大になり、運用が複雑になりそうだったためCDKv2を利用することにしました。
    CDKv2では、レコード内容によってファイルを作成しファイル単位で管理することにしました。例えば以下のような構成図です。
    ```
    bin/
    ├─ typescriptファイル
    domains/
    ├─ レコードタイプ_ドメイン名.yaml
    ├─ :
    ├─ レコードタイプ_ドメイン名.yaml
    ```

3. Route53のテスト環境をどのように作成するか
    先行してドメインを登録する可能性があるテスト環境は、VPC内で利用できるPrivate Hosted Zone(以下PHZ)を利用することにしました。
    しかし、PHZには[様々な制約](https://docs.aws.amazon.com/ja_jp/Route53/latest/DeveloperGuide/hosted-zone-private-considerations.html)があるため、通常のHosted Zoneを利用した方が良い場合が多いと思います。
    例えば次のNSレコードをテストできない制約があります。
    - PHZ上にNSレコードは登録することができず、登録した場合でもNXDOMAINが返ってくる
        - AWSコンソール上からは登録できない
        - CDKv2(やCloudformation)を利用して設定反映する場合は、NSレコードの登録はできる
        ```
        サブドメインの責任の委任
        サブドメインの責任を委任する NSレコードをプライベートホストゾーンに作成することはできません。
        https://docs.aws.amazon.com/ja_jp/Route53/latest/DeveloperGuide/hosted-zone-private-considerations.html
        ```

4. 運用自動化の手法
    各ブランチへのプルリクエストをトリガーに、ワークフローを実行するようにGithub actionsを使うことにしました。
    こちらは[本稿末尾](#運用の自動化概要)に概要を記載します。

## 移行の実施
次のの順番に移行を実施していきました。
- Route53 テスト環境/本番環境へのCDKv2を利用したデータ投入完了
- オンプレ/Route53 のデータ差分チェック
- オンプレ/Route53 並列運用
- Route53 単独運用
- オンプレ DNS運用終了

本項では、`オンプレ/Route53 のデータ差分チェック`以降を詳細に記載していきます。

参考: [Route 53 を使用中のドメインの DNS サービスにする](https://docs.aws.amazon.com/ja_jp/Route53/latest/DeveloperGuide/migrate-dns-domain-in-use.html)

### オンプレ/Route53 のデータ差分チェック
データ投入が完了した後は、オンプレとRoute53でのレコードデータに差分が無いかのチェックが必要です。
次のようなシェルスクリプトでチェックをしました。

![check.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618429/b63580c9-c87e-a1ad-ce56-85fc512b807f.png)

:::note info
Private Hosted Zoneの確認には、AWS ec2上でのスクリプト実行を想定しています。
tinydns[^3]のゾーンファイルを正しいデータとしています。
:::

<details><summary>差分チェックシェルスクリプト</summary><div>

```bash
#!/bin/bash
SCRIPT_NAME=${0##*/}
SCRIPT_DIR=$(cd $(dirname $0) && pwd)
TYPE="public"

#### Please EDIT ####
DNS_ONPRE=
DNS_R53=
DNS_resolver=
ZONEFILE=
ZONE=
#####################

usage(){
     cat << END
Usage: $SCRIPT_NAME <option>
Options:
    -h     ; this help
    -p     ; set Route53 PRIVATE hosted zone (default. PUBLIC hosted zone)
    -s     ; set the required variables from standard input
Example:
    $SCRIPT_NAME     ; Check Route53 PUBLIC hosted zone
    $SCRIPT_NAME -p  ; Check Route53 PRIVATE hosted zone
    $SCRIPT_NAME -s  ; SET variables adn Check Route53 PUBLIC hosted zone
    $SCRIPT_NAME -sp ; SET variables adn Check Route53 PRIVATE hosted zone
END
exit 0
}

stdin_env(){
  read -p "On-Premises DNS: " DNS_ONPRE
  read -p "Cloud DNS: " DNS_R53
  read -p "Cloud DNS resolver[optional]: " DNS_resolver
  read -p "TinyDNS Zonefile: " ZONEFILE
  read -p "Zone[EX.)example.com]: " ZONE
  export DNS_ONPRE DNS_R53 DNS_resolver ZONEFILE ZONE
}

dig_func(){
  local resultfile=$1
  local DNSauthority=
  local DNSresolver=
  local count=0

  #ZONEFILEにあるレコード総数にSOAレコード分を1つ足す
  local lines=$(expr $(grep ^[^\#] ${ZONEFILE} | cut -d':' -f1 | sort -u | wc -l) + 1)

  echo "## ${resultfile}"
  : > ${resultfile}

  if [[ ${resultfile} =~ onpre|ONPRE ]];then DNSauthority="+norec @${DNS_ONPRE}";fi
  if [[ ${resultfile} =~ public.*(aws|AWS) ]];then DNSauthority="+norec @${DNS_R53}";fi
  if [[ ${resultfile} =~ private.*(aws|AWS) ]];then DNSresolver="@${DNS_resolver}";fi

  if [ -z "${DNSresolver}" ] && [ -z "${DNSauthority}" ]; then echo "error ### Both of resolver and authority are not set">&2 ; exit 1; fi
  if [ -n "${DNSresolver}" ] && [ -n "${DNSauthority}" ]; then echo "error ### Both of resolver and authority are set">&2 ; exit 1; fi

  echo "# DNS resolver: ${DNSresolver}"
  echo "# DNS authority: ${DNSauthority}"
  
  cat ${ZONEFILE} | sed -e 's/^:/SRV/g' | cut -d':' -f1 | sort -u | awk -v "Z=${ZONE}" -F':' '
  BEGIN{
    print "SOA",Z
  }
  /^#/{
    next
  }
  /^\+/{
    print "A",substr($1, 2)
  }
  /^@/{
    print "MX",substr($1, 2)
  }
  /^C/{
    print "CNAME",substr($1, 2)
  }
  /^\047/{
    print "TXT",substr($1, 2)
  }
  /^SRV/{
    print "SRV",substr($1, 4)
  }
  $0 ~ "^&"Z{
    print "NS",substr($1, 2)
  }
  /^\&/ && $0 !~ "^&"Z {
    print "NS/authority",substr($1, 2)
  }' | while read type_w record_w
  do
    if [ -z "${DNSauthority}" ];then type_w="${type_w%/*}";fi
    if [ ${type_w} = "NS/authority" ]
    then
      printf '%s:%s:%s\n' ${type_w%/*} ${record_w} "$(dig +noall +authority ${DNSauthority} ${DNSresolver} ${record_w} ${type_w%/*}  | awk '{print $5}' | sort | sed -z -e 's/\n/:/g')" >> ${resultfile} &
    else
      printf '%s:%s:%s\n' ${type_w} ${record_w} "$(dig +short ${DNSauthority} ${DNSresolver} ${record_w} ${type_w} | sort | sed -z -e 's/\n/:/g')" >> ${resultfile} &
    fi

    #20並列
    (( count+=1 ))
    if (( $count % 20 == 0 ));then
      wait
    fi
    if [ $lines -eq $count ];then
      wait
    fi
  done

  sort -k1,2 -t: -o ${resultfile} ${resultfile}
}

result_blank(){
  local FNAME=$1
  echo "## $FNAME: BLANK lines ##"
  RES_BLANK=$(cat $FNAME  | awk -F':' '$3 ~ /^$/{print}')
  if [ -z "${RES_BLANK}" ];then echo "No blank";else echo "${RES_BLANK}";fi
}

OPTS=0
while getopts "hps" opt; do
  case $opt in
    h)
      usage
      ;;
    p)
      TYPE="private"
      ;;
    s)
      OPTS=1
      ;;
    *)
      usage
      ;;
   esac
done
shift $(expr $OPTIND - 1)


### MAIN

#標準入力で変数を設定する場合
if [ $OPTS -eq 1 ];then
  stdin_env
fi

#変数チェック
if [ ${TYPE} = public ];then
  if [ -z "${DNS_ONPRE}" ] || [ -z "${DNS_R53}" ]; then
    echo -e "Error: undefined variable\n### DNS_ONPRE: ${DNS_ONPRE}\n### DNS_R53: ${DNS_53}" >&2
    exit 1 
  fi
else
  if [ -z "${DNS_ONPRE}" ] || [ -z "${DNS_resolver}" ]; then
    echo -e "error ### undefined variable\n### DNS_ONPRE: ${DNS_ONPRE}\n### DNS_R53: ${DNS_resolver}">&2 
    exit 1 
  fi
fi
if [ ! -f "./${ZONEFILE}" ] || [ -z "${ZONE}" ]; then
  echo -e "Error: undefined variable\n### ZONEFILE: ${ZONEFILE}\n### ZONE: ${ZONE}" >&2
  exit 1 
fi


cd $SCRIPT_DIR

#出力するファイル名を設定
DIG_ONPRE_FNAME=dig_onpre_file
DIG_AWS_FNAME=dig_${TYPE}_aws_file

#オンプレDNSサーバにてレコードを引きファイルへ保存する
dig_func $DIG_ONPRE_FNAME $Authority

#Route53 DNSサーバにてレコードを引きファイルへ保存する
dig_func $DIG_AWS_FNAME $Authority

#オンプレとRoute53の差分の結果を返す
echo "## differene result ## ${DIG_ONPRE_FNAME} | ${DIG_AWS_FNAME} "
diff -W100 -y --suppress-common-lines ${DIG_ONPRE_FNAME} ${DIG_AWS_FNAME} && echo "# No difference"

#オンプレおよびRoute53にて、それぞれdigで引けなかったレコード(blank)を出力
result_blank ${DIG_ONPRE_FNAME} 
result_blank ${DIG_AWS_FNAME}
```
</div></details>

- 使い方
  - 最初にdigの実行ホストにスクリプトを配置してください。
  - スクリプトと同じフォルダにTinyDNSのゾーンファイルを配置してください。
  - 次にスクリプトの次の部分を編集してください
    ```
    #### Please EDIT ####
    DNS_ONPRE=      #オンプレミスDNSのFQDNまたはIP
    DNS_R53=        #クラウドのDNSのFQDN
    DNS_resolver=   #プライベートホステッドゾーンのリゾルバーのIP(ex. 10.0.0.2)
    ZONEFILE=       #TinyDNSのゾーンファイル名
    ZONE=           #今回移設対象のゾーン名(ex. example.com)
    #####################
    ```
  - スクリプトを実行すると、2点のファイルが作成されます。
    - オンプレ側の確認結果ファイル
    - クラウド側の確認結果ファイル
  - 出力結果は以下です
    - `diff -W100 -y --suppress-common-lines` にてオンプレ側/クラウド側の確認結果ファイルの差分
    - オンプレ側の確認結果ファイルにて応答のなかったレコード一覧
    - クラウド側の確認結果ファイルにて応答のなかったレコード一覧

以上の出力結果に差分が出ていたらクラウド側のレコード設定を見直し修正していました。

### オンプレ/Route53 並列運用
TTL値を通常より短縮した後、Route53のNS情報をレジストラへ追加登録しました。
TTL値の関係から、1週間程度オンプレ/Route53の並列運用期間を設けました。
また、オンプレ側の問い合わせがほとんど来なくなった段階で、オンプレ側のNSレコードをRoute53へ向けました。

下記リンクのように、先にオンプレ側のNSレコードをRoute53へ向けた方が、オンプレ側の問い合わせがなくなるスピードが速くなった可能性があります。
参考: [JPRS トピックス＆コラム No.019 DNSサーバーの引っ越し～トラブル発生を未然に防ぐ手順とポイント～](https://jprs.jp/related-info/guide/topics-column/no19.html)

### Route53 単独運用
その後のレジストラへNS更新依頼を実施し、オンプレのNSレコード情報を削除しました。
情報の更新は瞬時に反映されました。

### オンプレ DNS運用終了
一定期間置いてから、オンプレのDNSを解放することができました。

以上で、Route53への移行が完了しました。
次の課題は運用の自動化になります。


# 運用の自動化概要
CDKv2を利用してデータ投入していましたので、Github actionsを利用することで自動化を導入します。
アーキテクチャは以下のようになることを目指します。
こちらは機会があればまた記事にしたいと思います。
![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618429/a0ee11b6-7818-d893-92d1-48817e97bf72.png)


- 準備
    0. hogeブランチにてレコード変更内容を取り込む
- テスト環境への適用
    1. hogeブランチからstagingブランチへのプルリクエストを作成
    2. プルリクエストをトリガーに、Github actions ワークフローをec2上のランナーにて実行開始
    3. ワークフローからPrivate hosted zoneへ変更を適用
    4. Route53 Resolverを使用してレコードの変更内容を確認
- 本番環境への適用
    5. hogeブランチからmasterブランチへのプルリクエストを作成
    6. プルリクエストをトリガーに、Github actions ワークフローをec2上のランナーにて実行開始
    7. ワークフローからRoute53 hosted zoneへ変更を適用
    8. レコードの変更内容を確認

# おわりに
本稿ではオンプレDNSからRoute53への移行した件について記載しました。
サービス影響など発生せずに移行できたので安心しました。

何かの参考になれば幸いです。
アドバイスなどありましたらコメントいただければ幸いです。