1923

都内と業界の隅っこで生活しているエンジニアのノート

Azure Functions カスタム ハンドラー + Golang でマネージド ID を使用して Azure Database for MySQL に接続する

Azure Functions のカスタム ハンドラー の Timer trigger で Golang のプログラムを実行。 マネージド IDを使用して、Azure Database for MySQL のデータベースへの接続を試してみます。

Function App と MySQL のデータベースは作成済み。MySQLファイアウォールの設定が終わっている程度の状態です。

準備

マネージドID作成して、Function Appに割り当て
az identity create --resource-group TESTRG  --name TESTIDENTITY
$resourceID=$(az identity show --resource-group TESTRG --name TESTIDENTITY --query id --output tsv)
az functionapp identity assign --resource-group TESTRG --name TESTFUNC --identities $resourceID

MySQLユーザー作成時にクライアントIDを使うのでメモ

az identity show --resource-group Testing --name testscgo1220a1Identity --query clientId --output tsv
MySQL サーバー に Active Directory 管理者を設定

ポータルから Azure Database for MySQL サーバーの [Active Directory 管理者] > [管理者の設定] で設定できます。

Azure CLI で以下のコマンドで実行したときは結構時間がかかり、ポータルの方が早かった感じです。

$objectID=$(az ad user list --display-name "DISPLAYNAME" --query [].objectId --output tsv)
az mysql server ad-admin create --server-name TESTMYSQLSRV --resource-group TESTRG --display-name "DISPLAYNAME" --object-id $objectID

Command group 'mysql server ad-admin' is in preview. It may be changed/removed in a future release.

マネージド ID の MySQL ユーザーを作成してデータベースへの権限を設定

上記で設定した Azure AD 管理者ユーザーで MySQL データベースに接続し、先ほど作成したマネージド ID の MySQL ユーザー(user1)を作成します。ついでにデータベースの権限も適当に設定しておきます。

SET aad_auth_validate_oids_in_tenant = OFF;
CREATE AADUSER 'user1' IDENTIFIED BY 'クライアントID';
GRANT SELECT,INSERT,UPDATE,DELETE ON TESTMYSQLDB.* TO user1@'%'

MySQL Workbench で接続するときは、Advanced の Enable Cleartext Authentication Plugin にチェックします。パスワードに使用するトークンは以下で取得。

az account get-access-token --resource-type oss-rdbms

実装

helloフォルダを作成して、Timer Trigger の設定を記述した function.jsonを入れておけば、設定した時間(今回は毎時30分)に、"/hello" が呼ばれますので、その中に処理を記載します。

MySQLの接続は、Go を使用してトークンを取得するを参考にローカル トークン サービスからMySQLのアクセストークンを取得して、MySQLのパスワードに設定しています。アクセストークンの有効期限については無配慮です。

https://docs.microsoft.com/en-us/azure/mysql/howto-configure-ssl

hello/function.json

{
  "bindings": [
    {
      "schedule": "0 30 * * * *",
      "name": "myTimer",
      "type": "timerTrigger",
      "direction": "in"
    }
  ]
}

main.go

package main

import (
    "crypto/tls"
    "crypto/x509"
    "encoding/json"
    "errors"
    "fmt"
    "github.com/go-sql-driver/mysql"
    "github.com/jinzhu/gorm"
    "io/ioutil"
    "log"
    "net/http"
    "net/url"
    "os"
    "time"
)
type InvokeResponse struct {
    Outputs     map[string]interface{}
    Logs        []string
    ReturnValue interface{}
}
type ReturnValue struct {
    Data string
}
type Hello struct {
    gorm.Model
    Value  string
    DateTime  time.Time
}
type responseJson struct {
    AccessToken string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
    ExpiresIn string `json:"expires_in"`
    ExpiresOn string `json:"expires_on"`
    NotBefore string `json:"not_before"`
    Resource string `json:"resource"`
    TokenType string `json:"token_type"`
}

func main() {
    customHandlerPort, exists := os.LookupEnv("FUNCTIONS_CUSTOMHANDLER_PORT")
    if exists {
        fmt.Println("FUNCTIONS_CUSTOMHANDLER_PORT: " + customHandlerPort)
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/hello", helloHandler)
    fmt.Println("Go server Listening...on FUNCTIONS_CUSTOMHANDLER_PORT:", customHandlerPort)
    log.Fatal(http.ListenAndServe(":"+customHandlerPort, mux))
}

// Timer Trigger Event
func helloHandler(w http.ResponseWriter, r *http.Request) {
    db, err := sqlConnect()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer db.Close()

    user := Hello{ Value: "Hello", DateTime : time.Now() }
    db.NewRecord(user)
    db.Create(&user)

    returnValue := ReturnValue{Data: "return val"}
    outputs := make(map[string]interface{})
    outputs["output1"] = "output1"
    invokeResponse := InvokeResponse{outputs,  []string{ "log1" }, returnValue}
    json, err := json.Marshal(invokeResponse)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    w.Write(json)
}

func sqlConnect() (database *gorm.DB, err error) {
    json, err := getToken()
    if err != nil {
        return nil, err
    }

    fmt.Println("sqlConnect...")
    DBMS := "mysql"
    USER := "user1@TESTMYSQLSRV"
    PASS := json.AccessToken
    DB := "tcp(TESTMYSQLSRV.mysql.database.azure.com:3306)"
    DBNAME := "TESTMYSQLDB"
    CONNECT := fmt.Sprintf("%s:%s@%s/%s?tls=custom&parseTime=true&allowCleartextPasswords=1",USER , PASS, DB, DBNAME)

    rootCertPool := x509.NewCertPool()
    pem, err := ioutil.ReadFile("./BaltimoreCyberTrustRoot.crt.pem")
    if err != nil {
        return nil, err
    }
    if ok := rootCertPool.AppendCertsFromPEM(pem); !ok {
        return nil, errors.New(fmt.Sprint("Failed to append PEM."))
    }
    mysql.RegisterTLSConfig("custom", &tls.Config{RootCAs: rootCertPool})

    db, err := gorm.Open(DBMS, CONNECT)
    if err != nil {
        return nil, err
    }
    fmt.Println("db connected: ", &db)
    return db, nil
}

// トークン取得
func getToken() (responseJson, error) {
    var r responseJson

    // Create HTTP request for a managed services for Azure resources token to access Azure Resource Manager
    vaultURL := fmt.Sprintf("%s?resource=%s&api-version=2019-08-01&client_id=%s",
        os.Getenv("IDENTITY_ENDPOINT"),
        "https://ossrdbms-aad.database.windows.net",
        "クライアントID")
    msiEndpoint, err := url.Parse(vaultURL)
    if err != nil {
        return r, err
    }

    req, err := http.NewRequest("GET", msiEndpoint.String(), nil)
    if err != nil {
        return r, err
    }
    req.Header.Add("X-IDENTITY-HEADER", os.Getenv("IDENTITY_HEADER"))

    // Call managed services for Azure resources token endpoint
    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil{
        return r, err
    }

    // Pull out response body
    responseBytes, err := ioutil.ReadAll(resp.Body)
    defer resp.Body.Close()
    if err != nil {
        return r, err
    }
    if resp.StatusCode >= 400 {
        return r, fmt.Errorf("bad response status code %d", resp.StatusCode)
    }

    // Unmarshall response body into struct
    err = json.Unmarshal(responseBytes, &r)
    if err != nil {
        return r, err
    }
    return r, nil
}

参考