タイトルのまんまですが、Azure Functions上でdockerコンテナを動かして、Azure Database for MySQLのスロークエリログを取得してmysqldumpslowをかけつつ、Teamsのチャネルに通知してみたというお話です。
背景
システム監視の一環で、
- Azure Database for MySQLのスロークエリログを通知したい
- 一旦1日1回の定期通知でOK
- 生のスロークエリログでなくmysqldumpslowで処理したものを通知したい
- 重複したクエリはまとめておきたい
- クエリのパラメータにセンシティブな情報が含まれる可能性があるので開発者が見れないようマスクしたい
というのをやりたいという話があがってました。
生ログを見れるようにするだけなら簡単なのですが、mysqldumpslowで処理するというひと手間が必要に。
VM立ててcronで回してやるのが手っ取り早いですが、1日1回の起動のためだけにVM立ち上げっぱなしというのも少々もったいない話なので、AzureのサーバレスコンピューティングサービスであるAzure Functionsでどうにかできないか調査していました。
が、外部プログラムであるmysqldumpslowが実行できない(そもそもインストールできない)ため、そのままでは使えそうもなく(´・ω・`)としていたところ、以下のようなものを見つけました。
カスタム イメージを使用して Linux で関数を作成する (プレビュー)
Azure Functionsで自前のdockerコンテナが実行できるとかいうお誂え向きの代物っぽいです。
「(プレビュー)」というのがどの程度のプレビュー具合なのか気になりましたが、ひとまず採用してみることにしました。(割とプレビューでした)
構成
こんな感じのものをエイヤーと考えていました。
- Azure Database for MySQLからスロークエリログを取得して、それをmysqldumpslowにかけたものをStorageBlobにアップロード→URLを発行し、TeamsのWebhookを叩いてそのURLを通知する、というdockerイメージをつくる。
- Azure Functions上で上記dockerコンテナが実行されるfunctionを1日1回タイマー起動する。
- 開発者はTeamsで通知されたURLにアクセスしてログを見る。
作る
構成をわからんけどこんな感じやろハナホジーとかしながら決めましたので、実装です。
以下、なにぶんAzure歴1ヶ月なのでいろいろ間違っているかもしれません。間違ってたらすみません。
開発環境
まず、functionをどのプラットフォームで動作させるかを決めます。
標準で用意されている雛形dockerイメージは.NET(c#)、node.js、pythonの3種類でしたのでnode.jsを使うことにしました。(.NETはよく知らない、pythonはプレビュー扱いなため)
ということで、以下をインストール。
-
node.js
- 執筆時点ではv8系かv10系が推奨されていました。
- 私は手元にあったv8の最新版を使用しました。
-
Azure Functions Core Tools
- プロジェクトやfunctionの雛形を作るのに使用します。
- 開発用のサーバを起動したりもできます。
-
npm i -g azure-functions-core-tools --unsafe-perm true
でインストールします。
-
.NET Core SDK
- Azure Functions Core Toolsで必要なextension(プラグインのようなもの)を自動的に導入する際に必要なようです。
- 私の場合はキュー契機起動のfunctionの雛形を作る際に必要でした。
- 「Download .NET Core SDK」のリンクからインストーラをダウンロードしてインストールします。
- docker環境
- dockerコマンドが実行できる環境です。docker for windowsでもなんでも。
- デプロイするdockerイメージのビルドとレジストリへのpushに使用します。
プロジェクト雛形作成
最初に一旦node.jsの空パッケージを作成します。
普通に
mkdir hoge
cd hoge
npm init
で。設定値を適当に指定して完了するとpackage.json
が出来上がります。
そのままこのパッケージの上にAzure Functionsのプロジェクトを作成します。
cd ..
func init hoge --docker
オプションに与えている--docker
がミソです。このオプションを付与すると雛形となるdockerfileを作成してくれます。
実行すると、どのプラットフォームを使用するか選択できます。
今回はnode.jsを使うのでカーソルキーでnode
を選んでEnter。
いくつかファイルが生成されます
あわせてDockerfileが生成されているのがわかります。
function作成
雛形作成
function本体の雛形を作成します。この辺は通常のfunctionの作成と同じです。
先程作成したプロジェクトフォルダ直下で以下を実行します。
func new
実行するとどのイベント契機(トリガ)のfunctionとするかを選べます。
今回は、定期起動ということで、Timer trigger
を選択してEnter。
選択したらfunction名を聞かれるので適当に入力してエンター。
- function.json
- index.js
のみです。中身はこんな感じ。
index.js
はfunctionが実行する処理を記載します。
module.exports = async function (context, myTimer) {
var timeStamp = new Date().toISOString();
if(myTimer.isPastDue)
{
context.log('JavaScript is running late!');
}
context.log('JavaScript timer trigger function ran!', timeStamp);
};
まんまnode.jsですね。。
module.exports
で露出されたメソッドがfunctionとして実行される感じです。
第1引数のcontext
にはAzure Functionsから管理情報などが渡されます。ログを出力するcontext.log
など共通のメソッド類も提供されています。
第2引数はトリガによって渡されるものが異なります。今回はタイマトリガーなので、関連するデータがmyTimer
オブジェクトとして渡されています。
function.json
はfunctionのメタ情報を管理します。トリガごとに設定できる項目は異なるようです。タイマトリガーの場合はこんな感じです。
{
"disabled": false,
"bindings": [
{
"name": "myTimer",
"type": "timerTrigger",
"direction": "in",
"schedule": "0 */5 * * * *"
}
]
}
今回はタイマ起動なので、スケジュールを指定するcron-expressionな項目があります。
あとはゴリゴリの実装です。
以下、ポイント抜粋です。
MySQLからスロークエリログの取得
Azure Database for MySQLのスロークエリログ(というかサーバログ)は取得用のREST-APIが用意されています。
https://docs.microsoft.com/en-us/rest/api/mysql/logfiles/listbyserver
ただ、これを直接叩くのはOAuth2による認証が必要になるなどするためちょっと骨が折れます。
探してみたところ、やはり純正のSDKが用意されていました。
https://www.npmjs.com/package/azure-arm-mysql
https://docs.microsoft.com/en-us/javascript/api/azure-arm-mysql/?view=azure-node-latest
こちらを使ってみます。
node.jsのパッケージとして提供されていますので、普通に
npm i --save azure-arm-mysql
で導入できます。--save
してpackage.jsonにdependenciesとして記載しておけば通常のAzure Functions上で実行する際も使用できるそうです。
また、使用時にクレデンシャルが必要ですので、事前にクレデンシャルを取得するためms-rest-azure
というパッケージも必要でした。
https://www.npmjs.com/package/ms-rest-azure
https://docs.microsoft.com/en-us/javascript/api/ms-rest-azure/?view=azure-node-latest
認証情報はMySQLのサーバログにアクセスできる権限があれば何でもいいですが、今回はサービスプリンシパルを作ってそれを使うことにしました。
(AWSとかだったらインスタンスとかにロールを貼り付けさえすればいいのであまり考える必要もないんですが他にやり方あるのかな....)
以降、必要なパッケージについては都度同様にインストールしていきます。
こんな感じになりました。
const msRestAzure = require('ms-rest-azure');
const mySQLManagementClient = require('azure-arm-mysql');
const getServerLogs = async function (settings) {
// ログイン(サービスプリンシパルを使用してクレデンシャル取得)
const credentials = await msRestAzure.loginWithServicePrincipalSecret(
settings.spAppId, settings.spPassword, settings.spTenantId);
// ログファイル情報取得
const logResult = await new mySQLManagementClient(
credentials, settings.mysqlSubscriptionId
).logFiles.listByServer(settings.mysqlResourceGroup, settings.mysqlServerName);
// レスポンスからログファイルのURLを取得
let logs = [];
logResult.some((item) => {
if (item.logFileType != 'slowlog') {
// スロークエリログでない場合はスキップ
return false;
}
logs.push({
name: item.name,
url: item.url,
lastmodified: item.lastModifiedTime
});
});
return logs;
}
これでログを直接ダウンロードするためのURLが取得できます(logs[].url
)。
なお、ソースコード中のsettings
は外出しされた設定値です。Azure Functionsでは外出しの設定値を環境変数などに設定することができます。
記載は割愛していますが、別途環境変数から取得・設定しています。
mysqldumpslow実行
URLが取得できればダウンロードしてmysqldumpslow実行です。ここが通常のfunctionでは行えません(mysqldumpslowのインストール・実行ができません)。
とは言え、function側の実装に関してはAzureやdockerに特有の事項はありません。mysqldumpslowがインストールされているという前提でrequestパッケージやexecSyncを使って愚直に処理しました。
こんな感じになりました。
const fs = require('fs');
const httpreq = require('request-promise-native');
const { execSync } = require('child_process');
const downloadLog = function (item) {
// streamがasyncできないので手動でPromise化
return new Promise((resolve, reject) => {
// 吐き出し先stream
const ws = fs.createWriteStream(item.name).on(
// 完了時にresolve
'finish', () => {
resolve(item.name)
}
).on(
// エラー時にreject
'error', (err) => {
reject(err)
}
);
// HTTP get発行 → ファイルに出力
httpreq.get(item.url).pipe(ws);
});
}
const processLogs = async function (logs, settings) {
// blobService(後述)
const blobService = azureStorage.createBlobService(settings.storageBlobConnectionString);
// スロークエリログダウンロード
let downloads = [];
for (const item of logs) {
downloads.push(downloadLog(item));
}
// 全部ダウンロードされるまで待ち合わせ
await Promise.all(downloads);
for (const item of logs ) {
// mysqldumpslow実行
// azureのmysqlが吐き出すログをそのまま食わせると先頭のクエリが適切に処理されないので、
// ヘッダ部分を削除して食わせる
execSync(`tail -n +4 ${item.name} | mysqldumpslow -s t - > ${item.name}.filtered.log`);
// ログファイルをアップロード(後述)
const url = await uploadLogs(blobService, settings, item);
// Teamsにポスト(後述)
await postReportToTeamsChannel(settings, item, url);
}
}
どこかわからんが今いるところにファイルをぶちまけるとかいう男気あふれる実装ですね。。どうせdockerコンテナの上で動くんで都度消されるからいいかとかなんとかいう言い訳。
ダウンロードしたスロークエリログはmysqldumpslowに通して、同様に今いるところに結果をぶちまけています。
作りとしてはあんまりよくないですね。。巨大なログが連発すると処理時間がかかりすぎてタイムアウトする可能性があります。(そもそもそんな巨大なスロークエリログが出ちゃ別の意味でよくないですが)
StorageBlobへアップロード
出来上がった処理済みのログをStorageBlobにアップロードします。
アップロードしつつ、アクセス用のSAS付きURLを発行します。
StorageBlobにアクセスするには、azure-storage
というSDKのパッケージが用意されていました。
https://www.npmjs.com/package/azure-storage
https://docs.microsoft.com/en-us/javascript/api/azure-storage/?view=azure-node-latest
BlobServiceオブジェクトを認証情報を渡して生成し、生成されたBlobServiceオブジェクトのメソッドを実行することでStorageBlobの操作を行わせるスタイルです。
今回はStorageアカウントへの接続文字列を渡してBlobServiceオブジェクトを生成しました。
Storage-Functions間的にはロールベースの認証認可もできそうな感じなのですが、SDKからはその方法を読み取ることができませんでした。。。
さらに、アップロードしたファイルを容易に参照できるようSAS付きのURLを発行してます。このURLを通知するメッセージに埋め込んで参照してもらいます。
こんな感じになりました。
const { promisify } = require('util');
const azureStorage = require('azure-storage');
const processLogs = async function (logs, settings) {
// blobService
const blobService = azureStorage.createBlobService(settings.storageBlobConnectionString);
// 中略
for (const item of logs ) {
// 中略
// ログファイルをアップロード
const url = await uploadLogs(blobService, settings, item);
// 中略
}
}
const uploadLogs = async function (blobService, settings, item) {
// promise化
const createBlockBlobFromLocalFile = promisify(blobService.createBlockBlobFromLocalFile).bind(blobService);
const setBlobMetadata = promisify(blobService.setBlobMetadata).bind(blobService);
// アップロード
await createBlockBlobFromLocalFile(settings.storageBlobContainerName, item.name+".filtered.log", item.name+".filtered.log");
// SASを発行
const sas = blobService.generateSharedAccessSignature(
settings.storageBlobContainerName, item.name+".filtered.log", {
AccessPolicy: settings.logAccessPolicy
});
// blob参照用URLを発行して返す
const url = blobService.getUrl(settings.storageBlobContainerName, item.name+".filtered.log", sas);
return url;
}
Teamsに通知
TeamsにもIncomingWebhookが用意されているのでそれを叩いて通知します。
(ちなみにTeamsはMicrosoft製のコラボレーションツールです。)
普通にrequestパッケージでwebhookのURLに対してpostします。
postするメッセージボディは
https://messagecardplayground.azurewebsites.net/
ここで雛形が作れます。
こんな感じになりました。SAS付きURLをメッセージに埋め込んで参照できるようにしています。
const httpreq = require('request-promise-native');
const postReportToTeamsChannel = async function (settings, item, url) {
// Teamsのwebhookを叩く
const options = {
url: settings.teamsWebHookUrl,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
"@context": "https://schema.org/extensions",
"@type": "MessageCard",
"themeColor": "0072C6",
"title": "スロークエリログレポート",
"text": "以下のスロークエリログが検出されました。",
"sections": [
{
"facts": [
{
"name": "ファイル名",
"value": item.name
}
]
}
],
"potentialAction": [
{
"@type": "OpenUri",
"name": "ダウンロード",
"targets": [
{
"os": "default",
"uri": url
}
]
}
]
})
}
await httpreq.post(options);
}
以上でひととおり実装は完了。
ローカル環境で動かす
作ったものはローカルPC上で動かして動作確認が行なえます。
設定値の類を環境変数に設定する必要がありますが、ローカル環境では、プロジェクト直下のlocal.settings.json
に記載すると実行時に自動的に反映されます。
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "node",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"ENV_NAME":"ローカル環境",
"MYSQL_SP_APPID":"xxxxx",
"MYSQL_SP_PASSWORD":"xxxx",
"MYSQL_SP_TENANTID":"xxxxx",
"MYSQL_SUBSCRIPTION_ID":"xxxx",
"MYSQL_RESOURCE_GROUP":"xxxx",
"MYSQL_SERVER_NAME":"xxxx",
"STORAGEBLOB_CONNECTION_STRING":"xxxx",
"STORAGEBLOB_CONTAINER_NAME":"xxxx",
"TEAMS_WEBHOOK_URL":"https://outlook.office.com/webhook/xxxxx"
}
このファイル、接続情報などを生で入れることもありますので、うっかりパブリックなgitリポジトリにpushしちゃったり、dockerイメージに突っ込んでパブリックなレジストリにpushしちゃったりするとその辺がダダ漏れになります。
あと、Azure FunctionsをローカルPCで動作させるにはAzure ストレージエミュレータが必要なようです。ちょっと環境汚れるのが嫌だったので、node.js上で動作するAzuriteをインストールして使用しました。windows以外の環境用に用意されたもののようですが、すでにnode.jsがあるなら導入もはるかに簡単です。
こちらを参考にしました。
インストールしたら、先程のlocal.settings.json
のValues
に"AzureWebJobsStorage": "UseDevelopmentStorage=true"
というエントリを追加します。
追加したら、
azurite -l .
などとして事前に起動しておきます。
あとは別のコンソールを開いて、プロジェクトのルートに移動し、
func start
で起動です。
しばらく待つと、ログが流れて定期起動していることが見て取れます。
なお、ローカルで動いていますので、実行されるmysqldumpslowなどの外部プログラム類もあくまでローカルのものが使用されることとなります。
ローカルで動作確認が取れたらAzureにデプロイします。
Azureで動かす
dockerイメージ準備
Azure Functions+dockerとかいいながら、全くdockerに触れてませんでしたが、ようやく登場。
Azure Functions上でのdockerコンテナの実行は、事前にdockerレジストリとdockerイメージを指定して、実行時にはそれを自動的にpullして実行する、というスタイルになっています。
なので、dockerイメージの作成とレジストリへのpushが必要となります。
Dockerfile作成
Azure Functionsの上で動作させるコンテナのDockerfileを作成します。
最初にプロジェクト直下に自動生成されたDockerfileを見てみるとこんな感じになっています。
FROM mcr.microsoft.com/azure-functions/node:2.0
ENV AzureWebJobsScriptRoot=/home/site/wwwroot
COPY . /home/site/wwwroot
node.jsのfunctionの標準構成のものです。カスタムしたい場合はここに追加していきます。
今回はmysqldumpslowが必要なのでインストールを行うよう記載します。ベースのDockerfileを見るとapt-getが使えるようですのでこんな感じに。
FROM mcr.microsoft.com/azure-functions/node:2.0
RUN apt-get -y update && apt-get install -y wget
RUN wget https://dev.mysql.com/get/mysql-apt-config_0.8.12-1_all.deb
RUN DEBIAN_FRONTEND=noninteractive dpkg --force-confdef -i mysql-apt-config_0.8.12-1_all.deb
RUN apt-get update -y
RUN apt-get install -y mysql-client
ENV AzureWebJobsScriptRoot=/home/site/wwwroot
COPY . /home/site/wwwroot
当初はRUN apt-get -y update && apt-get -y install mysql-client
だけで行けるかと思ってましたが、Azure Database for MySQLが使用しているMySQLよりも古いバージョンのものが入るようで、取得したスロークエリログの集計が正常に行なえませんでしたので、最新のものをインストールするようにしました。
ビルド&push
普通にdocker login,docker build,docker pushします。
レジストリはDockerHubも使えるようですが、今回はAzure上のプライベートレジストリ(Azure Container Registory)を使用しました。
cd path/to/hoge
az login
az acr login --name xxxx.azurecr.io
docker login
docker build -t xxxx.azurecr.io/hoge:latest .
docker push xxxx.azurecr.io/hoge:latest
Function作成
いよいよAzure上にデプロイです。
まずはfunctionの作成。azコマンドでもできそうですが、ひとまずはポータルから作成してみました。
リソースの作成から「Function App」を検索して「作成」。(この辺、名称の統一がされてなくてどれやってなります)
アプリ名、サブスクリプション、リソースグループをそれぞれ指定します。アプリ名はそのままこのfunctionのURLの一部になります。
指定したら、「OS」を「Linux(プレビュー)」、「公開」を「Dockerイメージ」に選択します。
選択すると...ん?
AppServiceプラン???
通常のfunctionなら、「ホスティングプラン」という選択肢が表示され「従量課金プラン」というのが選べるのですが、どうやら、いまのところ、Azure Functions上でdockerコンテナを動作させるには、それを動かすためのAppServiceが必要なようです。従量課金プランのように共用インスンタンスでfunctionが起動した時間のみの従量課金とはならないようで、結局のところVMと同じく固定費用が発生してしまうみたいです。
仕方ないので、最も安いB1インスタンス作って使用。サーバレスとは一体。。。(´・ω・`)
「コンテナの構成」をクリックすると、レジストリとイメージの指定ができます。
Azure Container Registoryだけでなく、DockerHubや汎用のプライベートレジストリも使用できるようです。
先程pushしたイメージとタグを指定して「適用」。
最後、「Application Insights」ではログの吐き出し先を指定しておけます。無効にもできますが、タイマー起動では動いてる感がかなり希薄になりますので、有効にしておいたほうがよかったです。
すべて設定して「作成」をクリックすると諸々の作成・デプロイが始まります。完了まで数分間を要しました。
Function設定
デプロイが完了すると、ポータル画面で作成されたfunctionが見れるようになります。
作ったプログラムは環境変数から接続情報などの設定値を取ってきていましたので、それらを設定します。
設定は「アプリケーション設定」から行います。
リンクを開くと画面中程に「アプリケーション設定」があるのでこちらに使用したい環境変数を追加していく感じです。
設定値がどのタイミングで反映されるのかはドキュメント類から読み取ることができませんでしたが、ポータル画面から「停止」→「起動」を行うと確実に読み込まれるようでした。
試行錯誤
ようやくAzure上で動き出しましたが、例によっていろいろありました。
特に往生した2つを。
タイマー起動しない(´・ω・`)
デプロイ完了して、ワクワクしながら起動を正座待機していたのですが、いつまで経っても起動している気配がありません。
試しにテンプレートそのままのものをデプロイしてみましたがやはり起動されません(´・ω・`)
なにか粗相してるんでしょうか....
致し方ないので、
- キュー契機で起動するよう変更
- キュー契機起動のテンプレートfunctionを作成して、そこからタイマ起動のfunctionを直接コール
- キューに外部から定期的にメッセージを投入する
- 手っ取り早くFlowから突っ込む
とかいうニントモカントモな有様に(´・ω・`)
Storageアカウントに弾かれる(´・ω・`)
StorageBlobに格納したスロークエリログはSAS付きURLを発行しているのですが、社内IP以外からのアクセスを遮断するため、Storageアカウントにファイアウォールを設置しました。が、設置した途端にFunction→StorageBlobへのアクセスエラーが(´・ω・`)
ここを見ると、「同一リージョンの」ファイアウォール設定済みStorageアカウントにFunctionsからアクセスする際は、Storageアカウントのファイアウォールの設定の「信頼された Microsoft サービスによるこのストレージ アカウントに対するアクセスを許可します」にチェックを入れると通してくれるとあります。
が、どうも、通してもらってる様子がありません。。。(今見たら記述が削除されとるし...(´・ω・`))
仕方ないので、Functionsとは別リージョンにStorageアカウントを作成してFunctionsのグローバルIPをファイアウォールで許可するとかいうニントモカントモな有様に(´・ω・`)(もうちょっとちゃんとした方法があるのかもしれないですが...)
なお、Functionsが使用しているグローバルIPは、ポータルで当該functionを開いて「プラットフォーム機能」から「プロパティ」を開き、
「送信IPアドレス」「追加の送信IPアドレス」で確認できます。
こんな感じで動いてます
所感
ちょっといろいろ早すぎた感じでした(´・ω・`)
- 言うてもプレビュー機能
- 圧倒的プレビュー感
- 今回はタイマー起動が使えなかったのが特に痛い
- コンソール画面も操作できない項目があったり、エラーが発生したり
- 意外に高くつく
- いまのところdockerコンテナを動かそうとするとAppServiceプランが必須
- プライベートなコンテナレジストリが必要ならその費用も追加で必要
- 結局VMとさして変わらない固定費用が発生
- 構成が限定される
- 現状同一リソースグループ内では、Functionsを実行するためのAppServiceとしてWindowsインスタンスのものとLinuxインスタンスのものが混在不可
- 別のFunctionsを構築しようとしても、同一リソースグループ内ではプレビュー状態のLinuxのAppServiceを使用せざるを得なくなる
現状ではプレビューということもあり、かなり使い道が限られる感じですが、プレビューが取れる頃にはもうちょっと使い勝手がよくなるかもしれませんね。