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.
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.
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")
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)
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'))"]
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