-
Notifications
You must be signed in to change notification settings - Fork 0
/
docker.qmd
608 lines (454 loc) · 20.8 KB
/
docker.qmd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
# Docker {#sec-docker}
```{r}
#| eval: true
#| echo: false
#| include: false
source("_common.R")
library(testthat)
```
```{r}
#| label: co_box_tldr
#| echo: false
#| results: asis
#| eval: true
co_box(
color = "b",
look = "default", hsize = "1.10", size = "1.05",
header = "TLDR   ![](images/docker-icon.png){width='50' height='55'}",
fold = TRUE,
contents = "
<br>
**Docker**
"
)
```
---
[Docker](https://www.docker.com/) containers ensure your Shiny applications are deployed with the necessary tools and resources (dependencies, libraries, etc.) without significantly interfering with the native operating system or hardware.
:::: {.callout-tip collapse='true' appearance='default'}
## [Accessing the code examples]{style='font-weight: bold; font-size: 1.15em;'}
::: {style='font-size: 0.95em; color: #282b2d;'}
I've created the [`shinypak` R package](https://mjfrigaard.github.io/shinypak/) In an effort to make each section accessible and easy to follow:
Install `shinypak` using `pak` (or `remotes`):
```{r}
#| code-fold: false
#| message: false
#| warning: false
#| eval: false
# install.packages('pak')
pak::pak('mjfrigaard/shinypak')
```
Review the chapters in each section:
```{r}
#| code-fold: false
#| message: false
#| warning: false
#| collapse: true
library(shinypak)
list_apps(regex = '^15')
```
Launch an app:
```{r}
#| code-fold: false
#| eval: false
launch(app = "15_docker")
```
:::
::::
::: {layout="[50,-10,40]" layout-valign="center"}
![Common problem when shipping apps](images/it-works-on-my-machine-meme.png){width=60% fig-align='right'}
![Docker Whale](images/docker-icon.png){width=60% fig-align='right'}
:::
With Docker, each Shiny application runs from an *image* in an isolated *container*, which ensures your app works consistently, regardless of where it's deployed.[^docker-documentation]
[^docker-documentation]: Read more about Docker in the [official documentation.](https://docs.docker.com/)
## What is Docker? {#sec-docker-what-is-docker}
Three terms to know when working with Docker are **`Dockerfile`**, **image**, and **container**. Containers and images work together but they serve different purposes in the Docker ecosystem.
* `Dockerfile`: The file containing instructions on how to build the Docker image.
* **Docker Image**: A snapshot of the Shiny application and its dependencies, built based on the `Dockerfile`.
* **Docker Container**: A runtime instance of the Docker image, isolated from the host system, ensuring consistent behavior across environments. When we run an image, Docker creates a container from that image. A Docker image can exist without a container, but all containers must be instantiated from an image.
### How Docker works
Imagine your computer as a building. In this building, your operating system (OS) is like a big kitchen where everyone cooks. If someone (a software application) needs the oven at a specific temperature or requires a particular ingredient, this can interfere with what someone else (i.e., other applications) wants to cook. This situation is somewhat analogous to the competition for resources software applications can have while running on a particular machine.
Docker containers are like individual, self-contained mini-kitchens containing appliances, ingredients, and utensils. Each mini-kitchen can operate independently, regardless of what's happening in the others. Docker images can contain different 'recipes' for software applications, and each application can have different requirements (dependencies, libraries, versions, etc.).[^docker-not-vm]
<!--![Docker + Shiny app workflow](images/docker-flow.png){width='100%'}-->
[^docker-not-vm]: Docker containers are similar to virtual machine environments (like [VMware](https://www.vmware.com/)), but don't use a significant portion of the hardware system’s resources.
Inside our app-packages, we can write a `Dockerfile` (a text file containing a series of instructions) that defines how to build the Docker image. The image is a snapshot representing our Shiny app and its dependencies. Docker containers are instantiated with the `build` command, which reads and executes the instructions inside the `Dockerfile` and creates a runtime instance of the image.
```{mermaid}
%%| fig-width: 6.5
%%| fig-align: center
%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'Inconsolata'}}}%%
flowchart TB
subgraph App-Package
subgraph Dockerfile
A["Instructions"] --> B["Docker Image"]
end
subgraph Container
B --> C["Docker Container"]
C --> D["Image & dependencies are safely stored"]
C --> E["Ensures consistency across environments"]
end
end
F["Host System"] -->|Isolated from...| App-Package
style A fill:#8dd38d,color:#000000;
style B fill:#89D6FB,color:#000000,stroke:none,rx:10,ry:10;
style C fill:#89D6FB,color:#000000,stroke:none,rx:10,ry:10;
style F fill:#5c6192,color:#FFFFFF,stroke:none,rx:10,ry:10;
```
The image containing our applications' dependencies is safely stored inside the container, which ensures consistency across environments. The container also isolates the image (and the application) from the host system.
## Installing Docker {#sec-docker-install}
Follow the instructions found on the [Docker website](https://docs.docker.com/get-docker/) to install Docker Desktop. I'll be demonstrating how to use Docker on a macOS (but it's available on most operating systems).
After installation, Docker Desktop will initially show no active images/containers:[^initial-docker]
[^initial-docker]: If you follow the Docker 'Walkthroughs' in the Learning Center, you might see the `welcome-to-docker` container listed.
![Docker Desktop Containers (Mac)](images/docker_desktop.png){width=100% fig-align='center'}
The development process for your Shiny app-package doesn't change until you're ready to deploy your application.
## Writing a `Dockerfile` {#sec-docker-dockerfile}
We'll be using the `sap` example from the previous chapter as an example (the folder/file structure is below):
```{r}
#| label: git_box_15_docker
#| echo: false
#| results: asis
#| eval: true
git_margin_box(
contents = "launch",
fig_pw = '75%',
branch = "15_docker",
repo = 'sap')
```
```{bash}
#| eval: false
#| code-fold: true
#| code-summary: 'view app-package folder tree'
├── DESCRIPTION
├── Dockerfile
├── NAMESPACE
├── R
│ ├── data.R
│ ├── display_type.R
│ ├── ggp2_movies_app.R
│ ├── mod_scatter_display.R
│ ├── mod_var_input.R
│ ├── launch_app.R
│ ├── movies_server.R
│ ├── movies_ui.R
│ ├── scatter_plot.R
│ └── testthat.R
├── README.md
├── app.R
├── data
│ ├── movies.RData
│ └── movies.rda
├── data-raw
│ └── tidy_movies.R
├── inst
│ ├── dev
│ │ ├── R
│ │ │ ├── devServer.R
│ │ │ ├── devUI.R
│ │ │ ├── dev_mod_scatter.R
│ │ │ └── dev_mod_vars.R
│ │ ├── app.R
│ │ ├── imdb.png
│ │ └── tidy_movies.fst
│ ├── extdata
│ │ └── movies.fst
│ ├── prod
│ │ └── app
│ │ └── app.R
│ └── www
│ ├── bootstrap.png
│ └── shiny.png
├── man
│ ├── display_type.Rd
│ ├── ggp2_movies_app.Rd
│ ├── mod_scatter_display_server.Rd
│ ├── mod_scatter_display_ui.Rd
│ ├── mod_var_input_server.Rd
│ ├── mod_var_input_ui.Rd
│ ├── movies.Rd
│ ├── launch_app.Rd
│ ├── movies_server.Rd
│ ├── movies_ui.Rd
│ ├── scatter_plot.Rd
│ └── test_logger.Rd
├── sap.Rproj
├── tests
│ ├── testthat
│ │ ├── fixtures
│ │ │ ├── make-tidy_ggp2_movies.R
│ │ │ └── tidy_ggp2_movies.rds
│ │ ├── helper.R
│ │ ├── setup-shinytest2.R
│ │ ├── test-app-feature-01.R
│ │ ├── test-ggp2_app-feature-01.R
│ │ ├── test-mod_scatter_display.R
│ │ ├── test-mod_var_input.R
│ │ ├── test-scatter_plot.R
│ │ └── test-shinytest2.R
│ └── testthat.R
└── vignettes
└── test-specs.Rmd
16 directories, 54 files
```
When our application is ready to deploy, we'll create a `Dockerfile`, which is a plain-text file (no extension). `Dockerfile`s are a blend of commands, numeric values, and character strings with the following general conventions:
1. **Each line in the `Dockerfile` starts with an instruction**. These aren't case-sensitive, but it's common practice to capitalize each command.
2. **Comments or explanatory notes begin with `#`**. These will be ignored by the Docker engine.
Below are two lines from the `Dockerfile` found in the excellent post titled, [R Shiny Docker: How To Run Shiny Apps in a Docker Container](https://appsilon.com/r-shiny-docker-getting-started/). This is a great place to familiarize yourself with deploying a non-package Shiny application with Docker.[^appsilon-docker-intro]
```{bash}
#| eval: false
#| code-fold: false
# build image
FROM rocker/shiny
# create location for app
RUN mkdir /home/shiny-app
```
[^appsilon-docker-intro]: I've altered the comments of the original `Dockerfile`, so be sure to read the entire blog post before copying + pasting this into your workflow.
As you can see, the `Dockerfile` combines instructions (`FROM`, `RUN`) with command-line arguments (`mkdir`). You don't have to be a command-line expert to write a Dockerfile (but knowing a few can get you out of a jam [^linuxize-resource]).
[^linuxize-resource]: I love the help files on [https://linuxize.com/](https://linuxize.com/), and [this tutorial](https://command-line-tutorial.readthedocs.io/index.html) is a nice introduction to the command-line.
### Create image
Docker files start by building an image. In our case, we want an image configured to run R, which has been provided for us by the generous folks at the [`rocker` project](https://rocker-project.org/).
The Shiny `rocker` image is for Shiny apps:
```{bash}
#| eval: false
#| code-fold: false
FROM rocker/shiny
```
### Install dependencies
Use `RUN` and `R -e` to install dependencies for our app in the container from CRAN.[^remotes-install-local]
```{bash}
#| eval: false
#| code-fold: false
#| code-overflow: scroll
RUN R -e 'install.packages(c("rlang", "stringr", "shiny", "ggplot2", "remotes"))'
```
[^remotes-install-local]: We'll need remotes to install our app-package locally (i.e., with `remotes::install_local(upgrade="never")`).
### Create location for app
1. `RUN mkdir` creates a new `/deploy` directory in the container
2. `ADD . /deploy` copies the files from the current directory (on the host machine) into the `/deploy` directory inside the container
3. `WORKDIR /deploy` sets the `/deploy` directory as the working directory for any subsequent instructions.
```{bash}
#| eval: false
#| code-fold: false
RUN mkdir /deploy
ADD . /deploy
WORKDIR /deploy
```
### Install app-package
`remotes::install_local()` will the R package specified in the local directory (where our Shiny app-package lives), without upgrading dependencies.
```{bash}
#| eval: false
#| code-fold: false
RUN R -e 'remotes::install_local(upgrade="never")'
```
### Clean up
`RUN rm -rf /deploy` cleans up and reduces the size of the container.
```{bash}
#| eval: false
#| code-fold: false
RUN rm -rf /deploy
```
### Expose port
Make our Shiny app available on `EXPOSE 8180`.
```{bash}
#| eval: false
#| code-fold: false
EXPOSE 8180
```
### Launch app
When the container starts, set Shiny to listen on port `8180`, then launch the Shiny app from our `sap` package. The port we exposed in the `Dockerfile` should match the `shiny.port` option.
```{bash}
#| eval: false
#| code-fold: false
#| code-overflow: scroll
CMD R -e "options('shiny.port'=8180,shiny.host='0.0.0.0');library(sap);sap::launch_app()"
```
## Build the Docker image {#sec-docker-build-image}
The final `Dockerfile` we'll use to launch our application is below:
```{bash}
#| eval: false
#| code-fold: false
#| code-overflow: scroll
FROM rocker/shiny
RUN R -e 'install.packages(c("rlang", "stringr", "shiny", "ggplot2", "remotes"))'
RUN mkdir /deploy
ADD . /deploy
WORKDIR /deploy
RUN R -e 'remotes::install_local(upgrade="never")'
RUN rm -rf /deploy
EXPOSE 8180
CMD R -e "options('shiny.port'=8180,shiny.host='0.0.0.0');library(sap);sap::launch_app()"
```
To build the Docker image and create a new container, run the `docker build` command in the Terminal with a tag (`-t`), a name for the image (`movies-app-docker-demo`), and the location (`.`):
```{bash}
#| eval: false
#| code-fold: false
docker build -t movies-app-docker-demo .
```
As the docker image builds you'll see the output in the Terminal:
![](images/docker_build.png){width=100% fig-align='center'}
After the image is built, we'll see a new image listed in Docker desktop:
![](images/docker_deployed_container.png){width=100% fig-align='center'}
### Running the container {#sec-docker-run-container}
After building the image, we can run the new container using `docker run`
```{bash}
#| label: docker_run
#| eval: false
#| code-fold: false
docker run -p 8180:8180 movies-app-docker-demo
```
In the Terminal, we'll see an R session initialize, and the R function calls from the last line of our `Dockerfile`:
``` sh
R version 4.4.1 (2024-06-14) -- "Race for Your Life"
Copyright (C) 2024 The R Foundation for Statistical Computing
Platform: x86_64-pc-linux-gnu
R is free software and comes with ABSOLUTELY NO WARRANTY.
You are welcome to redistribute it under certain conditions.
Type 'license()' or 'licence()' for distribution details.
Natural language support but running in an English locale
R is a collaborative project with many contributors.
Type 'contributors()' for more information and
'citation()' on how to cite R or R packages in publications.
Type 'demo()' for some demos, 'help()' for on-line help, or
'help.start()' for an HTML browser interface to help.
Type 'q()' to quit R.
> options('shiny.port'=8180,shiny.host='0.0.0.0');library(sap);sap::launch_app()
Loading required package: shiny
Listening on http://0.0.0.0:8180
```
Copy the hyperlink above and place it in the browser to view the application:
![](images/docker_app_port.png){width=100% fig-align='center'}
New Docker containers are named using a random combination of adjectives and famous scientists' surnames (unless the `--name` flag is added).
![](images/docker_initial_container.png){width=100% fig-align='center'}
You can change the name of a Docker image by stopping the container and passing `docker rename <old_name> <new_name>` to the Terminal:
```{bash}
#| eval: false
#| code-fold: false
docker rename unruffled_bhabha launch_app
```
Each Docker container is created from the image (which is specified in the `Dockerfile`). The image serves as a blueprint for the containers, and we could create multiple containers from the same image:
![Running and stopping Docker containers](images/docker_run_containers.png){width=100% fig-align='center'}
## Docker & `golem` {#sec-docker-golem}
The `golem` package has [multiple functions](https://thinkr-open.github.io/golem/reference/index.html#create-a-dockerfile) for building Docker files and images. There are a few notable points to make about some of the Docker images created with `golem`:
- `golem`'s Docker functions can produce multiple Docker files (`golem::add_dockerfile_with_renv()` creates a `tmp/deploy` folder and adds the following files)
```{bash}
#| eval: false
#| code-fold: show
#| code-summary: 'View deploy/ folder'
deploy/
├── Dockerfile
├── Dockerfile_base
├── README
├── gap_0.0.0.9000.tar.gz
└── renv.lock.prod
```
- `golem`'s Docker files typically use the R build from `rocker` (and include a version):
```{bash}
#| eval: false
#| code-fold: show
#| code-summary: 'View R version build'
FROM rocker/verse:4.3.2
```
- `golem` Docker files might also include additional commands for installing/updating command-line (linux) tools for downloading/exchanging data:
```{bash}
#| eval: false
#| code-fold: show
#| code-summary: 'View apt-get commands'
#| code-overflow: scroll
RUN apt-get update && apt-get install -y libcurl4-openssl-dev libicu-dev libssl-dev libxml2-dev make pandoc zlib1g-dev
```
- They also usually the `remotes` package to specify the `version` of each package and whether to `upgrade` (or not):
```{bash}
#| eval: false
#| code-fold: show
#| code-summary: 'View remotes::install_version()'
#| code-overflow: scroll
RUN R -e 'install.packages("remotes")'
RUN Rscript -e 'remotes::install_version("rlang",upgrade="never",version="1.1.2")'
RUN Rscript -e 'remotes::install_version("stringr",upgrade="never",version="1.5.1")'
RUN Rscript -e 'remotes::install_version("shiny",upgrade="never",version ="1.8.0")'
RUN Rscript -e 'remotes::install_version("ggplot2",upgrade="never",version="3.4.4")'
```
```{r}
#| label: co_box_run_e_rscript_comparison
#| echo: false
#| results: asis
#| eval: true
co_box(
color = "g",
look = "default", hsize = "1.10", size = "1.05",
fold = TRUE,
header = "**`RUN R -e`** vs **`RUN Rscript -e`**",
contents = "
Both `R -e` and `RUN Rscript -e` will execute R expressions from the command line.
- `R -e` is straightforward and typically used to evaluate a single expression (i.e., `install.packages()`)
- `RUN Rscript -e` is more commonly used for running scripts or more specialized commands:
\`\`\`r
pkgs <- c('glue', 'cli')
install.packages(pkgs)
\`\`\`
"
)
```
There are additional differences, but these are important if you want to include additional requirements or control the version of R (or a package dependency). `golem`'s Docker images are more (you guessed it) opinionated, but every time I've used one it works right "out of the box."
## Docker in app-packages {#sec-docker-app-packages}
You can include a `Dockerfile` in an app-package by adding it to the `.Rbuildignore` file.[^rbuildignore]
```{bash}
#| eval: false
#| code-fold: false
^.*\.Rproj$
^\.Rproj\.user$
^sap\.Rcheck$
^sap.*\.tar\.gz$
^sap.*\.tgz$
^Dockerfile$
```
This will ensure it won't interfere with your app-package builds. Docker also has it's own ignore file (`.dockerignore`), which can include similar contents to the `.gitignore`:
```{bash}
#| eval: false
#| code-fold: false
.RData
.Rhistory
.git
.gitignore
manifest.json
rsconnect/
.Rproj.user
```
Note that if you include a `.dockerignore` file, you should *also* include this pattern in the `.Rbuildignore`:
```{bash}
#| eval: false
#| code-fold: false
^.*\.Rproj$
^\.Rproj\.user$
^sap\.Rcheck$
^sap.*\.tar\.gz$
^sap.*\.tgz$
^Dockerfile$
^\.dockerignore$
```
[^rbuildignore]: `.Rbuildignore` includes files that we need to have in our app-package, but don't conform to the standard R package structure (and shouldn't be included when building our app-package from the source files).
## Recap {.unnumbered}
This has been a brief overview of using Docker to deploy your Shiny App-Package.
```{r}
#| label: co_box_recap
#| echo: false
#| results: asis
#| eval: true
co_box(
color = "g",
fold = FALSE,
look = "default", hsize = "1.10", size = "1.05",
header = "Recap: ![](images/docker-icon.png){width='50' height='55'}",
contents = "
\n
Docker files are used to build images, which defines an environment for the Shiny application to run. The container is the actual running instance of that environment.
- Docker images are immutable, meaning they do not change. Once an image is created, it remains unchanged inside a container.
- Docker containers can be started, stopped, moved, and deleted, but each time a container is run, it's created from an image.")
```
Docker is covered again in the [`golem` chapter](golem.qmd) and on the Shiny frameworks [supplemental website](https://mjfrigaard.github.io/sfw/golem.html#docker). If you'd like to learn more, Appsilon has a great introduction to Docker (mentioned above).[^appsilon-docker-guide] I also found the Dockerizing shiny applications post helpful. [^dockerizing-shiny-post]
```{r}
#| label: git_contrib_box
#| echo: false
#| results: asis
#| eval: true
git_contrib_box()
```
[^dockerizing-shiny-post]: This is the [second post](https://hosting.analythium.io/dockerizing-shiny-applications/) in a series (see the first post [here](https://hosting.analythium.io/docker-basics-for-data-apps/)).
[^appsilon-docker-guide]: Appsilon has a few posts on Docker: '[R Shiny Docker: How To Run Shiny Apps in a Docker Container](https://appsilon.com/r-shiny-docker-getting-started/)' and '[Renv with Docker: How to Dockerize a Shiny Application with an Reproducible Environment](https://appsilon.com/renv-with-docker/)'