LoginSignup
6
6

More than 5 years have passed since last update.

GolangでHTTPのヘルスチェックをベースにDNSのレコードを更新してみる

Posted at

DNSのプロバイダがオプションで提供するようなフェイルオーバーはお値段がそこそこする。
今回とりあえずなんらかのClockWorkで自前で回せるようにする理由があった。

各種CLIとShellでやれば小一時間でできるような話だが、勉強がてら単品バイナリを目指してGo言語でやってみた。

目標は環境変数に必要なパラメータをセットして、healthbased-dnsrr dnsmple, healthbased-dnsrr route53な感じでよろしくやる。

ゆるい挙動

  • ホストリストをHTTPでそれぞれチェック
  • 200を返すホストだけAレコードの候補にして、
    • レコードがなければ登録
    • 差分があれば修正
  • 200を返すホストがなければ
    • レコードを削除しておく
    • バックアップIPをAレコードにセット(未実装)

こんなもんでいいか。

選べるプロバイダ戦略

DNSを使わせてもらう所はDNSimpleとRoute 53。最終的にどちらにするか決めてないので両方対応しておくことにした。

ベタ書きでDNSimpleを書いた所で、まるでGoらしくないような気がしたのでインターフェースを使ってみようと書き直し。
DnsManage型をつくって、healthBased_rrというメソッドをもってれば何でもいいという感じにmainを作成。
道中がfmt.Printfだらけだったり、色々と汚いのは今日は諦めよう。

go-healthbased-dnsrr/main.go
package main

import (
    "fmt"
    envmap "github.com/higanworks/go-envmap"
    "os"
    "strings"
)

var envs = envmap.All()
var targetHosts = strings.Split(envs["DNS_TARGET_HOSTS"], ",")
var backupHosts = strings.Split(envs["DNS_BACKUP_HOSTS"], ",")

type DnsManage interface {
    healthBased_rr() int
    //  enableBackup() error
}

func main() {
    os.Exit(realMain())
}

func realMain() int {
    var manager DnsManage

    if len(os.Args) < 2 {
        fmt.Printf("Usage: healthbased-dnsrr [dnsimple|route53]\n")
        return 1
    }

    switch os.Args[1] {
    default:
        fmt.Printf("Usage: healthbased-dnsrr [dnsimple|route53]\n")
        return 1
    case "dnsimple":
        manager = &DnsimpleManage{
            TargetHosts:      targetHosts,
            BackupHosts:      backupHosts,
            TargetRR:         envs["DNS_TARGET_RR"],
            targetDomainName: envs["DNS_TARGET_DOMAIN"],
            apiToken:         envs["DNSIMPLE_TOKEN"],
            email:            envs["DNSIMPLE_USER"],
        }
    case "route53":
        manager = &Route53Manage{
            TargetHosts:      targetHosts,
            BackupHosts:      backupHosts,
            TargetRR:         envs["DNS_TARGET_RR"],
            targetDomainName: envs["DNS_TARGET_DOMAIN"],
        }
    }

    life := manager.healthBased_rr()
    if life == 0 {
        fmt.Printf("All Dead\n")
        //  manager.enableBackup()
    }

    return 0
}

引数からmanagerを選択し、あとは同じメソッドで同じことをやればいいはず。。
Goの本ではゴルーチンやチャネルがかっこよさそうに見えた、しかし今日の理解度では無理なのでスルー。

開発中の環境変数はdirenvで入れておくようにした。

.envrc
.envrc 
layout go
export DNSIMPLE_TOKEN='*********************'
export DNSIMPLE_USER='*********************@higanworks.com'

export DNS_TARGET_DOMAIN='example.net'
export DNS_TARGET_RR='wwwww'
export DNS_TARGET_HOSTS='210.152.xxx.xx1,210.152.xxx.xx2'
export DNS_BACKUP_HOSTS='210.152.xxx.xx3'

export AWS_ACCESS_KEY='*********************'
export AWS_SECRET_ACCESS_KEY='*********************'

DNSimple用の処理

DNSimpleはAレコードを愚直に付け外しするだけなので、ドメインIDを取得したらホストの死活結果を個別に反映させればよかった。

DnsimpleManage型が進むごとにじゃんじゃん太っていった。後でテストを書けば減らせそうだ。

go-healthbased-dnsrr/dnsmple.go
package main

import (
    "fmt"
    dnsimple "github.com/rubyist/go-dnsimple"
    "os"
)

type DnsimpleManage struct {
    client            *dnsimple.DNSimpleClient
    TargetHosts       []string
    BackupHosts       []string
    TargetRR          string
    targetDomainName  string
    targetDomainId    int
    countAlivedTarget int
    apiToken          string
    email             string
}

func (dm *DnsimpleManage) RecordExists(records []dnsimple.Record, host string) int {
    for _, val := range records {
        if val.Name != dm.TargetRR {
            continue
        }
        if val.Content == host {
            fmt.Printf("Record: %s -> %s\n", val.Name, val.Content)
            return val.Id
        }
    }
    return 0
}

func (dm *DnsimpleManage) getDomainId() int {
    domains, _ := dm.client.Domains()
    for _, domain := range domains {
        if domain.Name == dm.targetDomainName {
            fmt.Printf("Domain: %s %d\n", domain.Name, domain.Id)
            return domain.Id
        }
    }
    return 0
}

func (dm *DnsimpleManage) healthBased_rr() int {
    dm.countAlivedTarget = len(dm.TargetHosts)
    dm.client = dnsimple.NewClient(dm.apiToken, dm.email)

    // Get a list of your domains
    dm.targetDomainId = dm.getDomainId()

    if dm.targetDomainId == 0 {
        fmt.Printf("Exit: Target Domain Not found.\n")
        os.Exit(1)
    }

    //   Get a list of records for a domain
    records, _ := dm.client.Records(dm.targetDomainName, "", "")

    for _, val := range dm.TargetHosts {
        r_id := dm.RecordExists(records, val)
        status, err := tryHttp(val)
        if err != nil {
            if r_id != 0 {
                fmt.Printf("Dead But Exists!: I'll delete [ %d ]. \n", r_id)
                delRec := dnsimple.Record{Id: r_id, DomainId: dm.targetDomainId}
                err := delRec.Delete(dm.client)
                if err != nil {
                    fmt.Printf("Delete returned error: %v\n", err)
                }
            } else {
                fmt.Printf("Result: Dead\n")
            }
            dm.countAlivedTarget--
            continue
        }

        fmt.Printf("Result: %d\n", status)
        // Create a new Record
        //      go func() {
        if r_id == 0 {
            newRec := dnsimple.Record{Name: dm.TargetRR, Content: val, RecordType: "A", TTL: 600}
            rec, _ := dm.client.CreateRecord(dm.targetDomainName, newRec)
            fmt.Printf("RecordID: %d\n", rec.Id)
        } else {
            fmt.Printf("Record Already Exists\n")
        }
        //      }()
    }

    return dm.countAlivedTarget
}

func (dm *DnsimpleManage) enableBackup() int {
    return 0
}

DNSimple対応を動かすとこんな感じ。末尾1のホストに到達性が無いので外した時のログ。

$ ./build/darwin/amd64/healthbased-dnsrr dnsimple
Domain: example.net 119xxx
Record: wwwww -> 210.152.xxx.xx1
Dead But Exists!: I'll delete [ 39xxxxx ]. 
Record: wwwww -> 210.152.xxx.xx2
Result: 200
Record Already Exists

Route 53用の処理

Route 53は1つのレコードセットに複数のコンテンツを含むので微妙にややこしい。
死活の結果に加えて、現状をとっておいて差分があれば上書きする必要があり、APIコールもバッチ風でけっこうな非同期。UPSERTが無かったらきっとつらい思いをしていた。

今回は共通でドメイン名を使いたかったので、HostedZoneIDの特定にListHostedZonesByNameを使った。
HostedZoneは一度に最大100件取得できるが、対象のアカウントではドメイン200を超えているので全部取ってくるのが面倒だった。

なおRoute 53では同名のドメインを作れるので、実はこのままでは誤爆の可能性はある。すなおにIDを控えたほうがいいんだろう。
ついでに、このままではhealthBased_rrが長すぎる。 errがかぶってerr2とか出てきているし。後で関数にしておこう。

go-healthbased-dnsrr/route53.go
package main

import (
    "fmt"
    "github.com/higanworks/goamz/route53"
    "github.com/mitchellh/goamz/aws"
    "reflect"
    "sort"
    "strings"
)

type Route53Manage struct {
    client            *route53.Route53
    TargetHosts       []string
    currentRRHosts    []string
    updateRRHosts     []string
    BackupHosts       []string
    TargetRR          string
    targetDomainName  string
    targetDomainId    string
    countAlivedTarget int
}

func (dm *Route53Manage) extractDomainId(res_id string) string {
    id := strings.Split(res_id, "/")
    return id[2]
}

func (dm *Route53Manage) RecordExists(records []route53.ResourceRecordSet) route53.ResourceRecordSet {
    fmt.Printf("Finding: " + dm.TargetRR + "." + dm.targetDomainName + ".\n")
    for _, val := range records {
        if val.Name == dm.TargetRR+"."+dm.targetDomainName+"." {
            return val
        } else if val.Name == dm.TargetRR+"."+dm.targetDomainName {
            return val
        }
    }
    return route53.ResourceRecordSet{}
}

func (dm *Route53Manage) getDomainId() string {
    res, _ := dm.client.ListHostedZonesByName(dm.targetDomainName, "", 1)

    for _, zone := range res.HostedZones {
        if zone.Name == dm.targetDomainName+"." {
            fmt.Printf("Found: %s: %s\n", zone.ID, zone.Name)
            return dm.extractDomainId(zone.ID)
        } else if zone.Name == dm.targetDomainName {
            fmt.Printf("Found: %s: %s\n", zone.ID, zone.Name)
            return dm.extractDomainId(zone.ID)
        }
    }
    return "0"
}

func (dm *Route53Manage) healthBased_rr() int {
    dm.countAlivedTarget = len(dm.TargetHosts)

    auth, _ := aws.EnvAuth() // AWS_ACCESS_KEY, AWS_SECRET_ACCESS_KEY
    dm.client = route53.New(auth, aws.USEast)

    // Get a list of your domains
    dm.targetDomainId = dm.getDomainId()

    res, err := dm.client.ListResourceRecordSets(dm.targetDomainId, nil)
    if err != nil {
        panic(err)
    }

    rrr := dm.RecordExists(res.Records)
    if rrr.Name != "" {
        dm.currentRRHosts = rrr.Records
        fmt.Printf("Current RR record: %q\n", dm.currentRRHosts)
    } else {
        fmt.Printf("Current RR record: Nothing\n")
    }

    for _, host := range dm.TargetHosts {
        status, _ := tryHttp(host)
        if err != nil {
            status = 0
        }
        fmt.Printf("Check HTTP: %s %d\n", host, status)
        if status != 200 {
            dm.countAlivedTarget--
            continue
        }
        dm.updateRRHosts = append(dm.updateRRHosts, host)
    }

    sort.StringSlice(dm.updateRRHosts).Sort()
    sort.StringSlice(dm.currentRRHosts).Sort()
    sort.StringSlice(dm.BackupHosts).Sort()
    fmt.Printf("Avaliable targets: %q\n", dm.updateRRHosts)
    if !reflect.DeepEqual(dm.updateRRHosts, dm.currentRRHosts) {
        var req route53.ChangeResourceRecordSetsRequest
        var req_rr route53.ResourceRecordSet
        var change route53.Change

        if dm.countAlivedTarget != 0 {
            fmt.Printf("Found difference, I'll fix it.\n")

            req_rr.Name = dm.TargetRR + "." + dm.targetDomainName + "."
            req_rr.Type = "A"
            req_rr.TTL = 600
            req_rr.Records = dm.updateRRHosts

            change.Action = "UPSERT"
            change.Record = req_rr

            req.Comment = change.Action + " to " + dm.TargetRR + "." + dm.targetDomainName + "."
            req.Changes = append(req.Changes, change)

            res, err2 := dm.client.ChangeResourceRecordSets(dm.targetDomainId, &req)
            if err2 != nil {
                panic(err)
            }
            fmt.Printf("Change Status: %s\n", res.ChangeInfo.Status)
        } else {
            // Delete if BackupHosts Empty
            if dm.targetDomainId != "0" {
                fmt.Printf("All Hosts dead. I'll delete rrset.\n")

                req_rr.Name = dm.TargetRR + "." + dm.targetDomainName + "."
                req_rr.Type = "A"
                req_rr.TTL = 600
                req_rr.Records = dm.currentRRHosts

                change.Action = "DELETE"
                change.Record = req_rr

                req.Comment = change.Action + " to " + dm.TargetRR + "." + dm.targetDomainName + "."
                req.Changes = append(req.Changes, change)

                res, err2 := dm.client.ChangeResourceRecordSets(dm.targetDomainId, &req)
                if err2 != nil {
                    panic(err2)
                }
                fmt.Printf("Change Status: %s\n", res.ChangeInfo.Status)
            }
        }
    }

    return dm.countAlivedTarget
}

Route 53対応を動かすとこんな感じ。さっき外れた末尾1のホストが復帰した時のログ。

$ ./build/darwin/amd64/healthbased-dnsrr route53
Found: /hostedzone/Z1KM1PIxxxxxxx: example.net.
Finding: wwwww.example.net.
Current RR record: ["210.152.xxx.xx2"]
Check HTTP: 210.152.xxx.xx1 200
Check HTTP: 210.152.xxx.xx2 200
Avaliable targets: ["210.152.xxx.xx1" "210.152.xxx.xx2"]
Found difference, I'll fix it.
Change Status: PENDING

ログがプロバイダによってまるで不揃いなのは日付をまたいだからだな。

HTTPの死活チェック

雑に10秒をリミットでつついた。Pingで見るよりいいだろう。

go-healthbased-dnsrr/http_checker.go
package main

import (
    "net/http"
    "time"
)

var httpClient = &http.Client{Timeout: time.Duration(10) * time.Second}

func tryHttp(host string) (int, error) {
    resp, err := httpClient.Get("http://" + host + "/")
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()
    return resp.StatusCode, err
}

これだけ書いてなんとか目的はだいたいOK。 ほぼわからない所からだったが、if err != nilからのpanic(err)(後でいくらか削除)を適当にいれておくだけでだいぶ違った。

MacとLinux用にビルド

Rundeckでの実行を想定したので、Linux用にビルドする。goxを使った。

goxで出力ファイル名を調整してビルド。

go-healthbased-dnsrr/build.sh
gox -osarch="linux/amd64 darwin/amd64" --output="build/{{.OS}}/{{.Arch}}/healthbased-dnsrr"

build.shでこの辺ができる。

$ tree build
build
├── darwin
│   └── amd64
│       └── healthbased-dnsrr
└── linux
    └── amd64
        └── healthbased-dnsrr

4 directories, 2 files

Linuxに単品バイナリを運んだ所、ちゃんと動いた。速いし、何より依存が全くないので配備がとっても楽だ。

6
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
6