This sample shows how to call a HTTP-triggered Azure Function hosted in an Azure Functions Premium Plan with Regional VNET Integration using a private endpoint. In addition, the sample shows how the Azure Functions app can use a NAT Gateway for outbound connections and private endpoints to access managed resources.
services: azure, azure-functions, azure-storage, azure-blob-storage, azure-application-insights, azure-bastion, azure-cosmos-db, azure-dns, app-service, azure-monitor, service-bus-messaging, storage, virtual-network, azure-private-link, azure-resource-manager, azure-virtual-machines-windows, azure-virtual-network, dotnet-core, vs-code
This sample shows how to call an HTTP-triggered Azure Function using Azure Private Endpoints. The Azure Functions app is hosted in Azure Functions Premium Plan with Regional VNET Integration. In addition, the sample demonstrates how to use the Azure NAT Gateway to handle outbound connections from the Azure Functions app when this makes a large number of calls to external services.
For more information on how to use Azure Private Endpoints to let Azure Web App and HTTP-triggered Azure Functions be called via a private IP address by applications located in a virtual network, see:
For a similar sample with a non-HTTP-triggered Azure Function, see Azure Functions, Private Endpoints, and NAT Gateway.
You can use the following button to deploy the demo to your Azure subscription:
The following picture shows the architecture and network topology of the sample.
The ARM template deploys the following resources:
Here are some facts you need to consider when accessing a Web App or HTTP-triggered AZure Function via a Private Endpoint:
The following diagram shows the message flow of the demo:
The integration subnet is configured to use a NAT Gateway for outbound connections, hence all the calls from the Azure Functions app to any external service go through the NAT Gateway. The NAT Gateway solves another problem beyond providing a dedicated internet address. You can also now have 64k outbound SNAT ports usable by your apps. One of the challenges in the App Service is the limit on the number of connections you can have to the same address and port. There are more details on this problem in the Troubleshooting intermittent outbound connection errors guide. To use a NAT Gateway with your app, you need to
The following components are required to run this sample:
You can use the Azure Cosmos DB Emulator and Azure Storage Emulator, along with the Azure Functions Core Tools, if you wish to develop and test locally.
You can use the ARM template and Bash script included in the sample to deploy to Azure the entire infrastructure necessary to host the demo:
#!/bin/bash
# Variables
resourceGroupName="<your-resource-group-name>"
location="<your-favorite-location>"
deploy=1
# ARM template and parameters files
template="../templates/azuredeploy.json"
parameters="../templates/azuredeploy.parameters.json"
# SubscriptionId of the current subscription
subscriptionId=$(az account show --query id --output tsv)
subscriptionName=$(az account show --query name --output tsv)
# Check if the resource group already exists
createResourceGroup() {
local resourceGroupName=$1
local location=$2
# Parameters validation
if [[ -z $resourceGroupName ]]; then
echo "The resource group name parameter cannot be null"
exit
fi
if [[ -z $location ]]; then
echo "The location parameter cannot be null"
exit
fi
echo "Checking if [$resourceGroupName] resource group actually exists..."
if ! az group show --name "$resourceGroupName" &>/dev/null; then
echo "No [$resourceGroupName] resource group actually exists"
echo "Creating [$resourceGroupName] resource group..."
# Create the resource group
if az group create --name "$resourceGroupName" --location "$location" 1>/dev/null; then
echo "[$resourceGroupName] resource group successfully created"
else
echo "Failed to create [$resourceGroupName] resource group"
exit
fi
else
echo "[$resourceGroupName] resource group already exists"
fi
}
# Validate the ARM template
validateTemplate() {
local resourceGroupName=$1
local template=$2
local parameters=$3
local arguments=$4
# Parameters validation
if [[ -z $resourceGroupName ]]; then
echo "The resource group name parameter cannot be null"
fi
if [[ -z $template ]]; then
echo "The template parameter cannot be null"
fi
if [[ -z $parameters ]]; then
echo "The parameters parameter cannot be null"
fi
echo "Validating [$template] ARM template..."
if [[ -z $arguments ]]; then
error=$(az deployment group validate \
--resource-group "$resourceGroupName" \
--template-file "$template" \
--parameters "$parameters" \
--query error \
--output json)
else
error=$(az deployment group validate \
--resource-group "$resourceGroupName" \
--template-file "$template" \
--parameters "$parameters" \
--arguments $arguments \
--query error \
--output json)
fi
if [[ -z $error ]]; then
echo "[$template] ARM template successfully validated"
else
echo "Failed to validate the [$template] ARM template"
echo "$error"
exit 1
fi
}
# Deploy ARM template
deployTemplate() {
local resourceGroupName=$1
local template=$2
local parameters=$3
local arguments=$4
# Parameters validation
if [[ -z $resourceGroupName ]]; then
echo "The resource group name parameter cannot be null"
exit
fi
if [[ -z $template ]]; then
echo "The template parameter cannot be null"
exit
fi
if [[ -z $parameters ]]; then
echo "The parameters parameter cannot be null"
exit
fi
if [ $deploy != 1 ]; then
return
fi
# Deploy the ARM template
echo "Deploying [$template$] ARM template..."
if [[ -z $arguments ]]; then
az deployment group create \
--resource-group $resourceGroupName \
--template-file $template \
--parameters $parameters 1>/dev/null
else
az deployment group create \
--resource-group $resourceGroupName \
--template-file $template \
--parameters $parameters \
--parameters $arguments 1>/dev/null
fi
if [[ $? == 0 ]]; then
echo "[$template$] ARM template successfully provisioned"
else
echo "Failed to provision the [$template$] ARM template"
exit -1
fi
}
# Create Resource Group
createResourceGroup \
"$resourceGroupName" \
"$location"
# Validate ARM Template
validateTemplate \
"$resourceGroupName" \
"$template" \
"$parameters"
# Deploy ARM Template
deployTemplate \
"$resourceGroupName" \
"$template" \
"$parameters"
The following figure shows the Azure resources grouped by type deployed by the ARM template in the target resource group.
Once the Azure resources have been deployed to Azure (which can take about 10-12 minutes), you need to deploy the Azure Function contained in the src folder to the newly created Azure Function app. Each Azure Function app has an Advanced Tool (Kudu) site that is used to manage function app deployments. This site is accessed from a URL like:
func azure functionapp publish [YOUR-FUNCTION-APP-NAME]
To deploy Azure Functions app to a real-world testing or production environment, you should use Azure DevOps with a self-hosted agent in the same virtual network. Likewise, you can use GitHub Actions with a self-hosted runner deployed to the same virtual network.
Below you can read the code of the Azure Function. The code of the Azure Function makes use of the dependency injection (DI) software design pattern, which is a technique to achieve Inversion of Control (IoC) between classes and their dependencies.
For more information, see Use dependency injection in .NET Azure Functions.
In addition, the code makes use of Azure Functions Open API Extension to enable Open API extension on the HTTP-triggered Azure Function and render Swagger UI that you can use to send requests to the function or review its Open API schema.
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.OpenApi.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Attributes;
using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Enums;
namespace Microsoft.Azure.Samples
{
public class RequestReceiver
{
private const string IpifyUrl = "https://api.ipify.org";
private const string Unknown = "UNKNOWN";
private const string Dude = "dude";
private const string NameParameter = "name";
private readonly HttpClient httpClient;
public RequestReceiver(HttpClient httpClient)
{
this.httpClient = httpClient;
}
[OpenApiOperation(operationId: "GetPublicIpAddress",
tags: new[] { "name" },
Summary = "Gets the outbound public IP address",
Description = "Gets the outbound public IP address",
Visibility = OpenApiVisibilityType.Important)]
[OpenApiParameter(name: "name",
In = ParameterLocation.Query,
Required = false,
Type = typeof(string),
Summary = "The name of the user that sends the message.",
Description = "The name",
Visibility = OpenApiVisibilityType.Important)]
[OpenApiResponseWithBody(statusCode: HttpStatusCode.OK,
contentType: "text/plain",
bodyType: typeof(string),
Summary = "The response",
Description = "This returns the response")]
[FunctionName("ProcessRequest")]
public async Task<IActionResult> Run([HttpTrigger(AuthorizationLevel.Anonymous,
"get", Route = null)] HttpRequest request,
[CosmosDB(databaseName: "%CosmosDbName%",
collectionName:"%CosmosDbCollectionName%",
ConnectionStringSetting = "CosmosDBConnection")]
IAsyncCollector<Request> items,
ILogger log,
ExecutionContext executionContext)
{
try
{
// Read the name parameter
var name = request.Query[NameParameter].FirstOrDefault() ?? Dude;
// Log message
log.LogInformation($"Started '{executionContext.FunctionName}' " +
$"(Running, Id={executionContext.InvocationId}) " +
$"A request has been received from {name}");
// Retrieve the public IP from Ipify site
var publicIpAddress = Unknown;
try
{
var response = await httpClient.GetAsync(IpifyUrl);
if (response.IsSuccessStatusCode)
{
publicIpAddress = await response.Content.ReadAsStringAsync();
// Log message
log.LogInformation($"Running '{executionContext.FunctionName}' " +
$"(Running, Id={executionContext.InvocationId}) " +
$"Call to {IpifyUrl} returned {publicIpAddress}");
}
}
catch (Exception ex)
{
log.LogError(ex, $"Error '{executionContext.FunctionName}' " +
$"(Running, Id={executionContext.InvocationId}) " +
$"An error occurred while calling {IpifyUrl}: {ex.Message}");
}
// Create response message
var responseMessage = $"Hi {name}, the HTTP triggered function invoked " +
$"the external site using the {publicIpAddress} public IP address.";
// Initialize message
var customMessage = new Request
{
Id = executionContext.InvocationId.ToString(),
PublicIpAddress = publicIpAddress,
ResponseMessage = responseMessage,
RequestHeaders = request.Headers
};
// Store the message to Cosmos DB
await items.AddAsync(customMessage);
log.LogInformation($"Completed '{executionContext.FunctionName}' " +
$"(Running, Id={executionContext.InvocationId}) "+
$"The response has been successfully stored to Cosmos DB");
// Return
return new OkObjectResult(responseMessage);
}
catch (Exception ex)
{
log.LogError(ex, $"Failed '{executionContext.FunctionName}' " +
$"(Running, Id={executionContext.InvocationId}) {ex.Message}");
return new BadRequestObjectResult("An error occurred while processing the request.");
throw;
}
}
}
}
As an alternative, you can use a static, singleton instance of the HttpClient object to call the external ipify service via HTTPS. To avoid holding more connections than necessary, we suggest reusing client instances rather than creating new ones with each function invocation. We recommend reusing client connections for any language that you might write your function in. For example, .NET clients like the HttpClient, DocumentClient, and Azure Storage clients can manage connections if you use a single, static client. For more information, see https://docs.microsoft.com/en-us/azure/azure-functions/manage-connections#static-clients.
Note: when debugging the Azure Function locally, make sure to replace the placeholders in the local.settings.json
file with a valid connection string for the storage account, Service Bus namespace, and Cosmos DB account.
You can proceed as follows to run the sample:
Open a Windows or Bash command-prompt and run the nslookup
command passing byt the FQDN of the Azure Functions app as a parameter:
nslookup funcapp.azurewebsites.net
The command should return a result as follows:
When you deploy a Private Endpoint for the HTTP-triggered Azure Function, Azure updates the DNS entry to point to the canonical name funcapp.privatelink.azurewebsites.net
.
| Name | Type | Value | Remarks |
|:———————————————————|:———|:———————————————————|:————————————————————-|
| funcapp.azurewebsites.net | CNAME | funcapp.privatelink.azurewebsites.net | Azure creates this entry in Azure Public DNS to point the app service to the privatelink |
| funcapp.privatelink.azurewebsites.net | A | Private Endpoint IP | You manage this entry in your DNS system to point to your Private Endpoint IP address |
After this DNS configuration you can reach your Web App privately with the default name funcapp.azurewebsites.net
. You must use this name, because the default certificate is issued for *.azurewebsites.net
.
For the Kudu console, or Kudu REST API (deployment with Azure DevOps self-hosted agents for example), you must create two records in your Azure DNS private zone or your custom DNS server. This is done by the ARM template in this sample.
| Name | Type | Value |
|:—————————————————————|:——-|:——————————|
| funcapp.privatelink.azurewebsites.net | A | Private Endpoint IP |
| funcapp.scm.privatelink.azurewebsites.net | A | Private Endpoint IP |
You can use curl, Postman, Apache JMeter or simply your favorite internet browser from the jumpbox virtual machine located to send requests to the HTTP-triggered function at https://
SELECT DISTINCT VALUE r.publicIpAddress FROM Requests r
In case of failure of the call to the external service, the Azure Functions app sets the value of the public IP address to UNKNOWN
. As you can see below, none of the calls to the external service returned an error, all of them used one of the 16 public IP addresses provided by the NAT Gateway and Public IP Address Prefix.
[
"20.61.15.136",
"20.61.15.140",
"20.61.15.141",
"20.61.15.133",
"20.61.15.131",
"20.61.15.143",
"20.61.15.134",
"20.61.15.129",
"20.61.15.142",
"20.61.15.135",
"20.61.15.139",
"20.61.15.130",
"20.61.15.132",
"20.61.15.138",
"20.61.15.128",
"20.61.15.137"
]
Below you can see the public IP address range in CIDR notation of the Public IP Address Prefix resource used by the NAT Gateway.
The Public IP Address Prefix includes 16 public IP addresses that go from 20.61.15.128
to 20.61.15.143
. For more information, see CIDR Notation.
You can also use the following query to retrieve how many outbound calls were made with each public IP address provided by the Public IP Address Prefix:
SELECT r.publicIpAddress, COUNT(r.publicIpAddress) FROM Requests r GROUP BY r.publicIpAddress
The query should return something like this:
[
{
"publicIpAddress": "20.61.15.137",
"$1": 100
},
{
"publicIpAddress": "20.61.15.128",
"$1": 100
},
{
"publicIpAddress": "20.61.15.138",
"$1": 100
},
{
"publicIpAddress": "20.61.15.132",
"$1": 200
},
{
"publicIpAddress": "20.61.15.130",
"$1": 231
},
{
"publicIpAddress": "20.61.15.139",
"$1": 100
},
{
"publicIpAddress": "20.61.15.135",
"$1": 123
},
{
"publicIpAddress": "20.61.15.142",
"$1": 100
},
{
"publicIpAddress": "20.61.15.129",
"$1": 100
},
{
"publicIpAddress": "20.61.15.134",
"$1": 100
},
{
"publicIpAddress": "20.61.15.143",
"$1": 87
},
{
"publicIpAddress": "20.61.15.131",
"$1": 379
},
{
"publicIpAddress": "20.61.15.133",
"$1": 187
},
{
"publicIpAddress": "20.61.15.141",
"$1": 373
},
{
"publicIpAddress": "20.61.15.140",
"$1": 299
},
{
"publicIpAddress": "20.61.15.136",
"$1": 100
}
]