Automating Dockerfile creation for Shiny apps
Introduction
For creating a production deployment of a {shiny} application it is often useful to be able to provide a Docker image that contains all the dependencies for that application. Here we explore how one might go about automating the creation of a Dockerfile that will allow us to build such an image for a {shiny} application.
What is docker?
Docker is an open source platform that enables developers to build, deploy and run containers, standardised executable components that combine application source code with the operating system libraries and dependencies required to run that code.
A general introduction to Docker for R users can be found in this blog post by Colin Fay, and the docker website also has some excellent documentation.
Obtaining system dependencies
When installing R packages, occasionally you will need additional system dependencies. When building a Docker image we will want to include the installation of those system dependencies into the Dockerfile. If we are to automate the process of writing a Dockerfile for building an image to run a {shiny} application then we need to find some programmatic solution to determining the required system dependencies.
It turns out that the RStudio Package Manager (RSPM) product has an API that can be queried to obtain the system requirements of a collection of R packages. The lovely folk at Posit also provide an instance of RSPM that anyone can make use of so it is trivial to obtain this information even if you do not have RSPM yourself. For example if we wanted to inspect the system dependencies of a package like {shiny} for Ubuntu 22.04 then a request to
https://packagemanager.rstudio.com/__api__/repos/1/sysreqs?all=false&pkgname=shiny&distribution=ubuntu&release=22.04
would do the trick.
In fact the {vetiver} package has a non-exported function,
glue_sys_reqs()
that will build a string for the command to install
these system requirements.
glue_sys_reqs = function(pkgs) {
rlang::check_installed("curl")
rspm = Sys.getenv("RSPM_ROOT", "https://packagemanager.rstudio.com")
rspm_repo_id = Sys.getenv("RSPM_REPO_ID", 1)
rspm_repo_url = glue::glue("{rspm}/__api__/repos/{rspm_repo_id}")
pkgnames = glue::glue_collapse(unique(pkgs), sep = "&pkgname=")
req_url = glue::glue(
"{rspm_repo_url}/sysreqs?all=false",
"&pkgname={pkgnames}&distribution=ubuntu&release=22.04"
)
res = curl::curl_fetch_memory(req_url)
sys_reqs = jsonlite::fromJSON(rawToChar(res$content), simplifyVector = FALSE)
if (!is.null(sys_reqs$error)) rlang::abort(sys_reqs$error)
sys_reqs = purrr::map(sys_reqs$requirements, purrr::pluck, "requirements", "packages")
sys_reqs = sort(unique(unlist(sys_reqs)))
sys_reqs = glue::glue_collapse(sys_reqs, sep = " \\\n ")
glue::glue(
"RUN apt-get update -qq && \\ \n",
" apt-get install -y --no-install-recommends \\\n ",
sys_reqs,
"\ && \\\n",
" apt-get clean && \\ \n",
" rm -rf /var/lib/apt/lists/*",
.trim = FALSE
)
}
Trying that out on a vector of packages we get something like
glue_sys_reqs(c("shiny", "dplyr"))
#> RUN apt-get update -qq && \
#> apt-get install -y --no-install-recommends \
#> make \
#> zlib1g-dev && \
#> apt-get clean && \
#> rm -rf /var/lib/apt/lists/*
To grab the system requirements for all packages that are used by a
{shiny} app then we could use renv::dependencies()
to scan our code
and list the used packages, then feed then to this function.
appdir = "app/"
pkgs = renv::dependencies(appdir)$Package
sys_reqs = glue_sys_reqs(pkgs)
Building out the rest of the Dockerfile
In order to reproduce the application that works on our system with a particular R version and the versions of packages that we have we want to build a Docker image that has that same version of R and packages. The rocker project provides a collection of Docker images for different purposes tagged for different R versions which makes this substantially easier so it’s really a case of ensuring that we match everything up.
We can write the line that will give me the rocker/shiny image for my R version fairly easily
(from_shiny_version = glue::glue("FROM rocker/shiny:{getRversion()}"))
#> FROM rocker/shiny:4.2.1
and {renv} makes it trivial to snapshot the versions of packages that we have installed and required for my project.
appdir = "app"
lockfile = "shiny_renv.lock"
renv::snapshot(
project = appdir,
lockfile = lockfile,
prompt = FALSE,
force = TRUE
)
We then want to get this lock file into the Docker image and
renv::restore()
the state of the library.
copy_renv = glue::glue("COPY {lockfile} renv.lock")
renv_install = 'RUN Rscript -e "install.packages(\'renv\')"'
renv_restore = 'RUN Rscript -e "renv::restore()"'
Finally we want to include the app in the image, let others know on which port the application is going to communicate (shiny-server defaults to 3838) and launch the {shiny} server on running the image.
copy_app = glue::glue("COPY {appdir} /srv/shiny-server/")
expose = ifelse(expose, glue::glue("EXPOSE {port}"), "")
cmd = 'CMD ["/usr/bin/shiny-server"]'
Combining all those steps into a single list and writing to file gives us a final Dockerfile. We can wrap this in a function to make it nicer to use:
shiny_write_docker = function(
path = ".", appdir = "app", lockfile = "shiny_renv.lock",
port = 3838, expose = TRUE, rspm = TRUE
) {
rspm_env = ifelse(
rspm,
"ENV RENV_CONFIG_REPOS_OVERRIDE https://packagemanager.rstudio.com/cran/latest\n",
""
)
from_shiny_version = glue::glue("FROM rocker/shiny:{getRversion()}")
renv::snapshot(
project = path,
lockfile = lockfile,
prompt = FALSE,
force = TRUE
)
pkgs = renv::dependencies(appdir)$Package
sys_reqs = glue_sys_reqs(pkgs)
copy_renv = glue::glue("COPY {lockfile} renv.lock")
renv_install = 'RUN Rscript -e "install.packages(\'renv\')"'
renv_restore = 'RUN Rscript -e "renv::restore()"'
copy_app = glue::glue("COPY {appdir} /srv/shiny-server/")
expose = ifelse(expose, glue::glue("EXPOSE {port}"), "")
cmd = 'CMD ["/usr/bin/shiny-server"]'
ret = purrr::compact(list(
from_shiny_version,
rspm_env,
sys_reqs,
copy_renv,
renv_install,
renv_restore,
copy_app,
expose,
cmd
))
readr::write_lines(ret, file = file.path(path, "Dockerfile"))
}
Taking the old faithful example shiny app template as my app in a
directory called app\
shiny_write_docker(path = ".", appdir = "app")
#> * Lockfile written to 'shiny_renv.lock'.
#> Finding R package dependencies ... Done!
produces the following Dockerfile
FROM rocker/shiny:4.2.0
ENV RENV_CONFIG_REPOS_OVERRIDE https://packagemanager.rstudio.com/cran/latest
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
make \
zlib1g-dev\
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY shiny_renv.lock renv.lock
RUN Rscript -e "install.packages('renv')"
RUN Rscript -e "renv::restore()"
COPY app /srv/shiny-server/
EXPOSE 3838
CMD ["/usr/bin/shiny-server"]
Running the app
From our Dockerfile we can build the image
docker build --tag auto_shiny_docker .
and run a container using that image mapping the shiny server port to the same port on localhost.
docker run --rm --publish 3838:3838 auto_shiny_docker
If we navigate in our browser to
http://localhost:3838
we should see the running application.
See also
- There’s a nice blog post by Mark Sellors, which focuses on a particular app, whereas the above is the general case. Definitely worth a look.
- Our amazing Docker courses