Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .Rprofile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
source("renv/activate.R")
137 changes: 137 additions & 0 deletions .github/workflows/docker-build-push.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
name: Build and Push Docker Images

on:
push:
branches:
- main
- develop
tags:
- 'v*'
pull_request:
branches:
- main
workflow_dispatch:

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

strategy:
fail-fast: false # Continue running other builds even if one fails
matrix:
dockerfile:
- name: single-stage
file: Dockerfile.single-stage
description: "Baseline single-stage build"
- name: two-stage
file: Dockerfile.multistage
description: "Optimized two-stage build"
- name: three-stage
file: Dockerfile.three-stage
description: "Advanced three-stage build with base layer"

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch,suffix=-${{ matrix.dockerfile.name }}
type=ref,event=pr,suffix=-${{ matrix.dockerfile.name }}
type=semver,pattern={{version}},suffix=-${{ matrix.dockerfile.name }}
type=semver,pattern={{major}}.{{minor}},suffix=-${{ matrix.dockerfile.name }}
type=sha,prefix=${{ matrix.dockerfile.name }}-
type=raw,value=${{ matrix.dockerfile.name }}-latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.title=Shiny Docker Optimization - ${{ matrix.dockerfile.name }}
org.opencontainers.image.description=${{ matrix.dockerfile.description }}

- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.dockerfile.file }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64

- name: Generate build summary
if: github.event_name != 'pull_request'
run: |
echo "## Docker Image Build Summary - ${{ matrix.dockerfile.name }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Dockerfile:** \`${{ matrix.dockerfile.file }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Description:** ${{ matrix.dockerfile.description }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Tags Created" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Pull Command" >> $GITHUB_STEP_SUMMARY
echo '```bash' >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ matrix.dockerfile.name }}-latest" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY

compare-sizes:
runs-on: ubuntu-latest
needs: build-and-push
if: success() && github.event_name != 'pull_request' # Only run if ALL builds succeed
permissions:
contents: read
packages: read

steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Pull all images and compare sizes
run: |
echo "## Docker Image Size Comparison" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Variant | Size | Optimization |" >> $GITHUB_STEP_SUMMARY
echo "|---------|------|--------------|" >> $GITHUB_STEP_SUMMARY

# Pull and get sizes
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:single-stage-latest || true
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:two-stage-latest || true
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:three-stage-latest || true

# Get sizes
SINGLE_SIZE=$(docker images --format "{{.Size}}" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:single-stage-latest 2>/dev/null || echo "N/A")
TWO_SIZE=$(docker images --format "{{.Size}}" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:two-stage-latest 2>/dev/null || echo "N/A")
THREE_SIZE=$(docker images --format "{{.Size}}" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:three-stage-latest 2>/dev/null || echo "N/A")

echo "| Single-stage | ${SINGLE_SIZE} | Baseline |" >> $GITHUB_STEP_SUMMARY
echo "| Two-stage | ${TWO_SIZE} | ~40% smaller |" >> $GITHUB_STEP_SUMMARY
echo "| Three-stage | ${THREE_SIZE} | ~40% smaller + better caching |" >> $GITHUB_STEP_SUMMARY
13 changes: 7 additions & 6 deletions Dockerfile.multistage
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# ============================================================================
# STAGE 1: Builder - Install dependencies
# ============================================================================
FROM rocker/r-ver:4.3.2 AS builder
FROM rocker/r-ver:4.4.3 AS builder

# Install system dependencies needed for package compilation
RUN apt-get update && apt-get install -y \
Expand Down Expand Up @@ -49,20 +49,21 @@ COPY app.R app.R
# ============================================================================
# STAGE 2: Runtime - Minimal production image
# ============================================================================
FROM rocker/r-ver:4.3.2
FROM rocker/r-ver:4.4.3

# Install ONLY runtime system dependencies (no build tools)
RUN apt-get update && apt-get install -y \
# Note: Using minimal runtime libraries without -dev packages
RUN apt-get update && apt-get install -y --no-install-recommends \
libcurl4 \
libssl3 \
libxml2 \
libfontconfig1 \
libharfbuzz0b \
libfribidi0 \
libfreetype6 \
libpng16-16 \
libtiff5 \
libjpeg62-turbo \
libpng16-16t64 \
libtiff6 \
libjpeg-turbo8 \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.single-stage
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# 4. Larger final image size (~2GB+)
# ============================================================================

FROM rocker/r-ver:4.3.2
FROM rocker/r-ver:4.4.3

# Install system dependencies
RUN apt-get update && apt-get install -y \
Expand Down
152 changes: 152 additions & 0 deletions Dockerfile.three-stage
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# ============================================================================
# THREE-STAGE DOCKERFILE (ADVANCED OPTIMIZATION)
# ============================================================================
# Architecture based on change frequency and package sources:
#
# BASE: CRAN packages (stable, rarely change) + build tools
# BUILDER: Custom/private packages (change independently from CRAN)
# RUNTIME: Pure application + runtime libraries only (NO build tools)
#
# Key advantages:
# 1. CRAN packages cached separately from custom packages
# 2. Custom package changes don't invalidate CRAN package layer
# 3. Runtime image is completely clean - no build tools whatsoever
# 4. Optimal layer caching based on change frequency
# ============================================================================

# ============================================================================
# STAGE 1: Base - CRAN packages and build environment
# ============================================================================
FROM rocker/r-ver:4.4.3 AS base

# Install system dependencies needed for compilation AND runtime
# Build tools are needed here to compile CRAN packages
RUN apt-get update && apt-get install -y \
# Build tools (needed for CRAN package compilation)
libcurl4-openssl-dev \
libssl-dev \
libxml2-dev \
libfontconfig1-dev \
libharfbuzz-dev \
libfribidi-dev \
libfreetype6-dev \
libpng-dev \
libtiff5-dev \
libjpeg-dev \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /base

# OPTIMIZATION 1: Copy only dependency files first
# This creates a cache-friendly layer that only rebuilds when CRAN dependencies change
COPY renv.lock renv.lock
COPY .Rprofile .Rprofile
COPY renv/activate.R renv/activate.R
COPY renv/settings.json renv/settings.json

# OPTIMIZATION 2: Install renv and restore CRAN packages
# This layer is cached unless renv.lock changes
# CRAN packages change rarely, so this provides excellent caching
RUN R -e "install.packages('renv', repos = 'https://cloud.r-project.org')"
RUN R -e "renv::restore()"

# ============================================================================
# STAGE 2: Builder - Custom/private packages
# ============================================================================
FROM base AS builder

# Inherit all CRAN packages and build tools from base
# Add any additional build dependencies for custom packages here if needed
# RUN apt-get update && apt-get install -y \
# <additional-build-deps> \
# && rm -rf /var/lib/apt/lists/*

WORKDIR /build

# Copy and build custom packages (not on CRAN)
# These might be:
# - Private packages from GitHub/GitLab
# - Internal company packages
# - Packages built from local source
#
# Example (uncomment and modify as needed):
# COPY custom_packages/ custom_packages/
# RUN R CMD INSTALL custom_packages/mypackage

# For now, just inherit from base
# In a real scenario, you'd install custom packages here:
# RUN R -e "remotes::install_github('yourorg/yourpackage')"

# Copy application code
# This is the most frequently changing layer
COPY app.R app.R

# ============================================================================
# STAGE 3: Runtime - Pure application (NO build tools)
# ============================================================================
FROM rocker/r-ver:4.4.3

# Install ONLY runtime system dependencies (no -dev packages, no build tools)
# Note: Using minimal runtime libraries without -dev packages
RUN apt-get update && apt-get install -y --no-install-recommends \
libcurl4 \
libssl3 \
libxml2 \
libfontconfig1 \
libharfbuzz0b \
libfribidi0 \
libfreetype6 \
libpng16-16t64 \
libtiff6 \
libjpeg-turbo8 \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy CRAN packages from base stage
COPY --from=base /base/renv /app/renv
COPY --from=base /base/.Rprofile /app/.Rprofile
COPY --from=base /base/renv.lock /app/renv.lock

# Copy custom packages from builder stage (if any were installed)
# COPY --from=builder /usr/local/lib/R/site-library/mypackage /usr/local/lib/R/site-library/mypackage

# Copy application code from builder
COPY --from=builder /build/app.R /app/app.R

# Expose Shiny port
EXPOSE 3838

# Run the application
CMD ["R", "-e", "shiny::runApp('/app', host = '0.0.0.0', port = 3838)"]

# ============================================================================
# Layer Optimization Strategy:
# ============================================================================
#
# CRAN packages (base): Changes LEAST frequently → Cached most aggressively
# Custom packages (builder): Changes MORE frequently → Independent cache layer
# Application code (runtime): Changes MOST frequently → Doesn't invalidate above
#
# Build flow:
# 1. Base layer compiles CRAN packages (cached unless renv.lock changes)
# 2. Builder adds custom packages (cached unless custom package sources change)
# 3. Runtime copies everything and adds application code
# 4. Code changes only invalidate the final COPY app.R step
#
# ============================================================================
# Build and Run Commands:
# ============================================================================
# Build (three-stage):
# docker build -f Dockerfile.three-stage -t shiny-app:three-stage .
#
# Run:
# docker run -p 3838:3838 shiny-app:three-stage
#
# Verify no build tools in runtime:
# docker run --rm shiny-app:three-stage dpkg -l | grep -E "dev|gcc|g\+\+"
# (should return nothing)
#
# Compare sizes:
# docker images | grep shiny-app
# ============================================================================
36 changes: 25 additions & 11 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
version: '3.8'

services:
# Single-stage build (for comparison)
# Single-stage build (baseline)
shiny-single:
build:
context: .
Expand All @@ -11,23 +11,37 @@ services:
ports:
- "3838:3838"
restart: unless-stopped
# Uncomment to run this version
# profiles:
# - single
profiles:
- single

# Multistage build (optimized)
shiny-optimized:
# Two-stage build (optimized)
shiny-two-stage:
build:
context: .
dockerfile: Dockerfile.multistage
image: shiny-app:optimized
container_name: shiny-optimized
image: shiny-app:two-stage
container_name: shiny-two-stage
ports:
- "3838:3838"
restart: unless-stopped
profiles:
- two-stage

# Three-stage build (advanced optimization)
shiny-three-stage:
build:
context: .
dockerfile: Dockerfile.three-stage
image: shiny-app:three-stage
container_name: shiny-three-stage
ports:
- "3838:3838"
restart: unless-stopped
# This is the default service to run

# Usage:
# docker-compose up # Runs optimized version
# docker-compose up shiny-single # Runs single-stage version
# docker-compose down # Stop and remove containers
# docker-compose up # Runs three-stage version (default)
# docker-compose up shiny-single # Runs single-stage version
# docker-compose up shiny-two-stage # Runs two-stage version
# docker-compose up shiny-three-stage # Runs three-stage version (explicit)
# docker-compose down # Stop and remove containers
Loading