Deploying an ACI service with HTTPS and authentication

This document shows how you can deploy a fitted model as a web service to an Azure Container Instance, using the RestRserve package. RestRserve has a number of features that can make it more suitable than Plumber for building robust, production-ready services. These include:

In particular, we’ll show how to implement the latter two features in this vignette.

Deployment artifacts

Model object

For illustrative purposes, we’ll reuse the random forest model and resource group from the Plumber deployment vignette. The code to fit the model is reproduced below for convenience.

data(Boston, package="MASS")
library(randomForest)

# train a model for median house price as a function of the other variables
bos_rf <- randomForest(medv ~ ., data=Boston, ntree=100)

# save the model
saveRDS(bos_rf, "bos_rf.rds")

Basic authentication requires that we provide a list of usernames and passwords that grant access to the service. In a production setting, you would typically query a database, directory service or other backing store to authenticate users. To keep this example simple, we’ll just create a flat file in the standard Apache .htpasswd format. In this format, the passwords are encrypted using a variety of algorithms, as a security measure; we’ll use the bcrypt algorithm since an R implementation is available in the package of that name.

library(bcrypt)

user_list <- list(
    c("user1", "password1"),
    c("user2", "password2")
)
user_str <- sapply(user_list, function(x) paste(x[1], hashpw(x[2]), sep=":"))
writeLines(user_str, ".htpasswd")

TLS certificate/private key

To enable HTTPS, we need to provide a TLS certificate and private key. Again, in a production setting, the cert will typically be provided to you; for this vignette, we’ll generate a self-signed cert instead. If you are running Linux or MacOS and have openssl installed, you can use that to generate the cert. Here, since we’re already using Azure, we’ll leverage the Azure Key Vault service to do it in a platform-independent manner.

library(AzureRMR)
library(AzureContainers)
library(AzureKeyVault)

deployresgrp <- AzureRMR::get_azure_login()$
    get_subscription("sub_id")$
    get_resource_group("deployresgrp")

# create the key vault
vault_res <- deployresgrp$create_key_vault("mykeyvault")

# get the vault endpoint
kv <- vault_res$get_endpoint()

# generate the certificate: use the DNS name of the ACI container endpoint
kv$certificates$create(
    "deployrrsaci",
    "CN=deployrrsaci",
    x509=cert_x509_properties(dns_names=c("deployrrsaci.australiaeast.azurecontainer.io"))
)
secret <- kv$secrets$get("deployrrsaci")
key <- sub("-----BEGIN CERTIFICATE-----.*$", "", secret$value)
cer <- sub("^.*-----END PRIVATE KEY-----\n", "", secret$value)
writeLines(key, "cert.key")
writeLines(cer, "cert.cer")

App

Unlike Plumber, in RestRserve you define your service in R code, as a web app. An app is an object of R6 class Application: it contains various middleware and backend objects, and exposes the endpoint paths for your service. The overall server backend is of R6 class BackendRserve, and has responsibility for running and managing the app.

The script below defines an app that exposes the scoring function on the /score path. Save this as app.R:

library(RestRserve)
library(randomForest)

bos_rf <- readRDS("bos_rf.rds")

users <- local({
    usr <- read.table(".htpasswd", sep=":", stringsAsFactors=FALSE)
    structure(usr[[2]], names=usr[[1]])
})

# scoring function: calls predict() on the provided dataset
# - input is a jsonified data frame, in the body of a POST request
# - output is the predicted values
score <- function(request, response)
{
    df <- jsonlite::fromJSON(rawToChar(request$body), simplifyDataFrame=TRUE)
    sc <- predict(bos_rf, df)

    response$set_body(jsonlite::toJSON(sc, auto_unbox=TRUE))
    response$set_content_type("application/json")
}

# basic authentication against provided username/password values
# use try() construct to ensure robustness against malicious input
authenticate <- function(user, password)
{
    res <- FALSE
    try({
        res <- bcrypt::checkpw(password, users[[user]])
    }, silent=TRUE)
    res
}

# chain of objects for app
auth_backend <- AuthBackendBasic$new(FUN=authenticate)
auth_mw <- AuthMiddleware$new(auth_backend=auth_backend, routes="/score")
app <- Application$new(middleware=list(auth_mw))
app$add_post(path="/score", FUN=score)

backend <- BackendRserve$new(app)

Dockerfile

Here is the dockerfile for the image. Save this as RestRserve-aci.dockerfile:

FROM rexyai/restrserve

# install required packages
RUN Rscript -e "install.packages(c('randomForest', 'bcrypt'), repos='https://cloud.r-project.org')"

# copy model object, cert files, user file and app script
RUN mkdir /data
COPY bos_rf.rds /data
COPY .htpasswd /data
COPY cert.cer /data
COPY cert.key /data
COPY app.R /data

WORKDIR /data

EXPOSE 8080

CMD ["Rscript", "-e", "source('app.R'); backend$start(app, http_port=-1, https.port=8080, tls.key=normalizePath('cert.key'), tls.cert=normalizePath('cert.cer'))"]

Create the container

We now build the image and upload it to an Azure Container Registry. This assumes a fresh start; if you have created an ACR in this resource group already, you can reuse that instead by calling get_acr instead of create_acr.

call_docker("build -t rrs-aci -f RestRserve-aci.dockerfile .")

deployreg_svc <- deployresgrp$create_acr("deployreg")
deployreg <- deployreg_svc$get_docker_registry(as_admin=TRUE)
deployreg$push("rrs-aci")

We can now deploy the image to ACI and obtain predicted values from the RestRserve app. Because we used a self-signed certificate in this example, we need to turn off the SSL verification check that curl performs by default. There may also be a short delay from when the container is started, to when the app is ready to accept requests.

# ensure the name of the resource matches the one on the cert we obtained above
deployresgrp$create_aci("deployrrsaci",
    image="deployreg.azurecr.io/bos-rrs-https",
    registry_creds=deployreg,
    cores=2, memory=8,
    ports=aci_ports(8080))

Sys.sleep(30)

# tell curl not to verify the cert
unverified_handle <- function()
{
    structure(list(
        handle=curl::handle_setopt(curl::new_handle(), ssl_verifypeer=FALSE),
        url="https://deployrrsaci.australiaeast.azurecontainer.io"),
    class="handle")
}

# send the username and password as part of the request
response <- httr::POST("https://deployrrsaci.australiaeast.azurecontainer.io:8080/score",
    httr::authenticate("user1", "password1"),
    body=MASS::Boston[1:10, ], encode="json",
    handle=unverified_handle())

httr::content(response, simplifyVector=TRUE)
#> [1] 25.9269 22.0636 34.1876 33.7737 34.8081 27.6394 21.8007 22.3577 16.7812 18.9785