タイトルそのままの内容です🙂
PulumiでAzure OpenAI Serviceを使ったLineBotを一撃で作りたいなと思ったのでやってみました。
最近はAWS CDKを使ったLambdaの開発体験が良いので浮気気味だったのですが、Azure Functionsも好きです。ただ、IaCはBicepでは無く書きたいなーという気分で、CDK for Terraformも試したのですが、いやここはPulumiっしょ!と思いやってみました。そういえばプルミはハワイ語で「ほうき」を意味するみたいです。
因みにですが、Azure OpenAI Serviceは2023年7月、東日本リージョンが選択できるようになりましたね⭐
Azure OpenAIが使える事が前提なので、Azure Functions、Azure OpenAIの詳しい説明などは割愛します。
あと、Pulumiの初期設定も飛ばします。過去に記事も書いてますので良ければご参考ください。
本題
今回、構築テンプレートとしてこちらから始めてみる事にしました。
ドキュメント通りにやると直ぐにAzure Functionsがデプロイできたので、この内容を変更します。
以下は最終的な階層です。
├── app
| └── chat
| └── function.json
| └── index.js
│ └── host.json
│ └── package.json
├── index.ts
├── helpers.ts
├── package-lock.json
├── package.json
├── Pulumi.dev.yaml
├── Pulumi.yaml
├── tsconfig.json
以下は最終的なPulumiのコードです。
import * as pulumi from "@pulumi/pulumi";
import * as azure from "@pulumi/azure-native";
import { getConnectionString, signedBlobReadUrl } from "./helpers";
import * as dotenv from "dotenv";
dotenv.config();
// Import the program's configuration settings.
const config = new pulumi.Config();
const appPath = config.get("appPath") || "./app";
// Create a resource group
const resourceGroup = new azure.resources.ResourceGroup("resource-group", {});
// Create a Cognitive Services
const cognitiveservices = new azure.cognitiveservices.Account("cognitiveservices", {
resourceGroupName: resourceGroup.name,
kind: "OpenAI",
sku: {
name: "S0",
},
location: resourceGroup.location,
properties:{
publicNetworkAccess: "Enabled",
}
});
// Create a OpenAI Model
const deployment = new azure.cognitiveservices.Deployment("deployment", {
accountName: cognitiveservices.name,
deploymentName: "gpt-35-turbo",
properties: {
model: {
format: "OpenAI",
name: "gpt-35-turbo",
version: "0613",
},
},
resourceGroupName: resourceGroup.name,
});
// Get the keys for the Cognitive Services account.
const openaiKeys = azure.cognitiveservices.listAccountKeysOutput({
accountName: cognitiveservices.name,
resourceGroupName: resourceGroup.name,
});
const openaiKey = openaiKeys.apply(openaiKeys => openaiKeys.key1 || "");
// Create LogAnalyticsWorkspace and Application Insights.
const logAnalyticsWorkspace = new azure.operationalinsights.Workspace("logAnalyticsWorkspace", {
resourceGroupName: resourceGroup.name,
});
const appInsights = new azure.insights.Component("appInsights", {
applicationType: "web",
kind: "web",
resourceGroupName: resourceGroup.name,
workspaceResourceId: logAnalyticsWorkspace.id,
});
// Create a blob storage account.
const storageAccount = new azure.storage.StorageAccount("account", {
resourceGroupName: resourceGroup.name,
kind: azure.storage.Kind.StorageV2,
sku: {
name: azure.storage.SkuName.Standard_LRS,
},
});
// Create a storage container for the serverless app.
const appContainer = new azure.storage.BlobContainer("app-container", {
accountName: storageAccount.name,
resourceGroupName: resourceGroup.name,
publicAccess: azure.storage.PublicAccess.None,
});
// Upload the Function app to the storage container.
const appBlob = new azure.storage.Blob("app-blob", {
accountName: storageAccount.name,
resourceGroupName: resourceGroup.name,
containerName: appContainer.name,
source: new pulumi.asset.FileArchive(appPath),
});
const storageConnectionString = getConnectionString(resourceGroup.name, storageAccount.name);
const codeBlobUrl = signedBlobReadUrl(appBlob, appContainer, storageAccount, resourceGroup);
// Create an App Service plan for the Function App.
const plan = new azure.web.AppServicePlan("plan", {
resourceGroupName: resourceGroup.name,
sku: {
name: "Y1",
tier: "Dynamic",
},
kind: "Linux",
reserved: true,
});
// Create the Function App.
const functionApp = new azure.web.WebApp("function-app", {
resourceGroupName: resourceGroup.name,
serverFarmId: plan.id,
kind: "FunctionApp",
siteConfig: {
linuxFxVersion: "Node|18",
appSettings: [
{
name: "AzureWebJobsStorage",
value: storageConnectionString,
},
{
name: "FUNCTIONS_WORKER_RUNTIME",
value: "node",
},
{
name: "WEBSITE_NODE_DEFAULT_VERSION",
value: "~18",
},
{
name: "FUNCTIONS_EXTENSION_VERSION",
value: "~4",
},
{
name: "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING",
value: storageConnectionString,
},
{
name: "WEBSITE_RUN_FROM_PACKAGE",
value: codeBlobUrl,
},
{
name: "APPINSIGHTS_INSTRUMENTATIONKEY",
value: appInsights.instrumentationKey,
},
{
name: "OPENAI_API_KEY",
value: openaiKey
},
{
name: "OPENAI_ENDPOINT",
value: cognitiveservices.properties.endpoint,
},
{
name: "OPENAI_DEPLOYMENT_NAME",
value: deployment.name,
},
{
name: "LINE_CHANNEL_ACCESS_TOKEN",
value: process.env["LINE_CHANNEL_ACCESS_TOKEN"] || "",
},
{
name: "LINE_CHANNEL_SECRET",
value: process.env["LINE_CHANNEL_SECRET"] || "",
},
],
cors: {
allowedOrigins: [
"*"
],
},
},
});
// Export the serverless endpoint.
export const apiURL = pulumi.interpolate`https://${functionApp.defaultHostName}/api`;
import * as resources from "@pulumi/azure-native/resources";
import * as storage from "@pulumi/azure-native/storage";
import * as pulumi from "@pulumi/pulumi";
export function getConnectionString(resourceGroupName: pulumi.Input<string>, accountName: pulumi.Input<string>): pulumi.Output<string> {
// Retrieve the primary storage account key.
const storageAccountKeys = storage.listStorageAccountKeysOutput({ resourceGroupName, accountName });
const primaryStorageKey = storageAccountKeys.keys[0].value;
// Build the connection string to the storage account.
return pulumi.interpolate`DefaultEndpointsProtocol=https;AccountName=${accountName};AccountKey=${primaryStorageKey}`;
}
export function signedBlobReadUrl(blob: storage.Blob,
container: storage.BlobContainer,
account: storage.StorageAccount,
resourceGroup: resources.ResourceGroup): pulumi.Output<string> {
const blobSAS = storage.listStorageAccountServiceSASOutput({
accountName: account.name,
protocols: storage.HttpProtocol.Https,
sharedAccessExpiryTime: "2030-01-01",
sharedAccessStartTime: "2021-01-01",
resourceGroupName: resourceGroup.name,
resource: storage.SignedResource.C,
permissions: storage.Permissions.R,
canonicalizedResource: pulumi.interpolate`/blob/${account.name}/${container.name}`,
contentType: "application/json",
cacheControl: "max-age=5",
contentDisposition: "inline",
contentEncoding: "deflate",
});
return pulumi.interpolate`https://${account.name}.blob.core.windows.net/${container.name}/${blob.name}?${blobSAS.serviceSasToken}`;
}
LINE_CHANNEL_ACCESS_TOKEN=<値をセット>
LINE_CHANNEL_SECRET=<値をセット>
AppServicePlanのOSはWindowsでは無く、Linuxにしてます。
(SecretにはAzure KeyVaultとか使ったほうが良いのだと思いますが、とりあえずで...💦)
ここまでは以下を参考にしつつ、諸々変更しました。
あとは、httpTrigger
で動くFunctions
のコードを書きます。
以下はLineボットのサンプルです。
const { OpenAIClient, AzureKeyCredential } = require("@azure/openai");
const crypto = require("crypto");
const line = require('@line/bot-sdk');
const LINE_CHANNEL_ACCESS_TOKEN = process.env.LINE_CHANNEL_ACCESS_TOKEN;
const LINE_CHANNEL_SECRET = process.env.LINE_CHANNEL_SECRET;
const config = {
channelAccessToken: LINE_CHANNEL_ACCESS_TOKEN,
channelSecret: LINE_CHANNEL_SECRET
};
// LINE Client
const client = new line.Client(config);
//Azure OpenAI Client
const AZ_OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const AZ_OPENAI_ENDPOINT = process.env.OPENAI_ENDPOINT;
const modelName = process.env.OPENAI_DEPLOYMENT_NAME || "gpt-35-turbo";
if(!AZ_OPENAI_API_KEY || !AZ_OPENAI_ENDPOINT){
throw new Error("Missing required configuration values.");
}
const openaiClient = new OpenAIClient(AZ_OPENAI_ENDPOINT, new AzureKeyCredential(AZ_OPENAI_API_KEY));
module.exports = async function (context, req) {
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
};
//Test
if (req.method === "GET") {
context.res = {
headers,
body: "Hello, world!",
status: 200,
};
return;
}
if(req.body["events"].length === 0){
context.res = { status: 200, body: "Please pass a message on the query string or in the request body" };
return;
}
if(req.body["events"][0].type !== "message" || req.body["events"][0].message.type !== "text"){
message = {
type: "text",
text: "申し訳ありませんが、テキストメッセージのみ対応しております"
};
client.replyMessage(req.body["events"][0].replyToken, message);
return;
}
if(!validate_signature(req.body, req.headers["x-line-signature"])){
context.res = {
status: 202,
body: "fail to validate signature"
};
return;
}
try{
const ans = await chatgpt(req.body["events"][0].message.text);
message = { type: "text", text: ans };
client.replyMessage(req.body["events"][0].replyToken, message);
return;
}catch(error){
console.error(error);
message = { type: "text", text: "申し訳ありませんが、エラーが発生しました" };
client.replyMessage(req.body["events"][0].replyToken, message);
return;
}
};
//ChatGPT
const chatgpt = async (message) => {
try{
const messages = [
{ role: "system", content: "親切に丁寧に思いやりをもって返答してください" },
{ role: "user", content: message },
];
const result = await openaiClient.getChatCompletions(modelName, messages);
for (const choice of result.choices) {
const response = choice.message.content || "...";
return response;
}
}
catch (error) {
throw new Error(`OpenAI API Error: ${error.message}`);
}
};
const validate_signature = (body, signature) => {
try{
const hash = crypto.createHmac("sha256", LINE_CHANNEL_SECRET).update(Buffer.from(JSON.stringify(body))).digest("base64");
return hash === signature;
}catch(e){
console.log(e)
return false;
}
};
{
"name": "azureOpenAi-func-app",
"version": "0.1.0",
"dependencies": {
"@azure/openai": "^1.0.0-beta.6",
"@line/bot-sdk": "^7.7.0",
"node-fetch": "^3.3.2"
}
}
最初のテンプレート時に構築したリソースは一回pulumi destroy
して再度pulumi up
します。
Outputs
にAPIのURLが出力されます。
Outputs:
apiURL: "https://************.azurewebsites.net/api"
Resources:
+ 11 created
ポータルで見ると以下のリソースがちゃんとデプロイされてます。
Messaging API設定のWebhook URLは今回の場合、https://************.azurewebsites.net/api/chat
です。
因みに、変なメッセージ(どんなメッセージを送ったのかは内緒)を送るとAzure OpenAI Serviceのコンテンツフィルターに引っかかりますね。
安心感があります。
さいごに
Pulumi良きです!
Pulumi AIも軽く使ってみましたが、今のところはドキュメントとARMテンプレートを見ながらやったほうが確実で、そちらのほうが早かったので今回Pulumi AIはあまり使いませんでした。
今後もPulumiは追っていこうと思います!