Repository: railwayapp/nixpacks
Pull request #106 (2022-05-11 22:22:14)

Minimal go images
- **Build plan update**: Base image is now specified in the setup phase
- **Provider API update**: Providers can specify a `run image` in the start phase. If this image is provided then we will add the following to the generated Dockerfile
```
FROM {run_image}
WORKDIR /app/
COPY --from=0 /app /app/
```

so that only the app source and any build artifacts will be present in the final build. This is useful for compiled langauges that can produce a single staticically linked binary.

- Compile Go with `CGO_ENABLED=0`

The result is that the go image produced for `examples/go-mod` drops from 1.15GB to 76MB

The run image can be overridden by setting the `NIXPACKS_RUN_IMAGE` environment variable.


Patch:
diff --git a/src/nixpacks/mod.rs b/src/nixpacks/mod.rs
index 386dd81..83d7ea9 100644
--- a/src/nixpacks/mod.rs
+++ b/src/nixpacks/mod.rs
@@ -32,9 +32,6 @@ const NIX_PACKS_VERSION: &str = env!("CARGO_PKG_VERSION");
 // https://status.nixos.org/
 static NIXPKGS_ARCHIVE: &str = "934e076a441e318897aa17540f6cf7caadc69028";
 
-// Debian 11
-static BASE_IMAGE: &str = "ghcr.io/railwayapp/nixpacks:debian";
-
 #[derive(Debug)]
 pub struct AppBuilderOptions {
     pub custom_build_cmd: Option<String>,
@@ -306,6 +303,16 @@ impl<'a> AppBuilder<'a> {
             .clone()
             .or_else(|| env_start_cmd.or_else(|| procfile_cmd.or(start_phase.cmd)));
 
+        // Allow the user to override the run image with an environment variable
+        if let Some(env_run_image) = self.environment.get_config_variable("RUN_IMAGE") {
+            // If the env var is "falsy", then unset the run image on the start phase
+            start_phase.run_image = match env_run_image.as_str() {
+                "0" | "false" => None,
+                img if img.is_empty() => None,
+                img => Some(img.to_owned()),
+            };
+        }
+
         Ok(start_phase)
     }
 
@@ -511,9 +518,21 @@ impl<'a> AppBuilder<'a> {
             start_files.push(".".to_string());
         }
 
+        let run_image_setup = start_phase.run_image.map(|run_image| {
+            formatdoc! {"
+                FROM {run_image}
+                WORKDIR {app_dir}
+                COPY --from=0 /etc/ssl/certs /etc/ssl/certs
+                RUN true
+                COPY --from=0 {app_dir} {app_dir}
+            ",
+            run_image=run_image,
+            app_dir=app_dir}
+        });
         let dockerfile = formatdoc! {"
           FROM {base_image}
-          WORKDIR /app/
+
+          WORKDIR {app_dir}
 
           # Setup
           {setup_copy_cmd}
@@ -533,9 +552,10 @@ impl<'a> AppBuilder<'a> {
 
           # Start
           {start_copy_cmd}
+          {run_image_setup}
           {start_cmd}
         ",
-        base_image=BASE_IMAGE,
+        base_image=setup_phase.base_image,
         setup_copy_cmd=setup_copy_cmd,
         args_string=args_string,
         install_copy_cmd=get_copy_command(&install_files, app_dir),
@@ -544,6 +564,7 @@ impl<'a> AppBuilder<'a> {
         build_copy_cmd=get_copy_command(&build_files, app_dir),
         build_cmd=build_cmd,
         start_copy_cmd=get_copy_command(&start_files, app_dir),
+        run_image_setup=run_image_setup.unwrap_or_default(),
         start_cmd=start_cmd};
 
         Ok(dockerfile)
diff --git a/src/nixpacks/phase.rs b/src/nixpacks/phase.rs
index 0520a87..11dbcc8 100644
--- a/src/nixpacks/phase.rs
+++ b/src/nixpacks/phase.rs
@@ -2,14 +2,20 @@ use serde::{Deserialize, Serialize};
 
 use super::nix::Pkg;
 
+// Debian 11
+static DEFAULT_BASE_IMAGE: &str = "ghcr.io/railwayapp/nixpacks:debian-1652414952";
+
 #[serde_with::skip_serializing_none]
-#[derive(Serialize, Deserialize, Default, Clone, Debug)]
+#[derive(Serialize, Deserialize, Clone, Debug)]
 pub struct SetupPhase {
     pub pkgs: Vec<Pkg>,
     pub archive: Option<String>,
 
     #[serde(rename = "onlyIncludeFiles")]
     pub only_include_files: Option<Vec<String>>,
+
+    #[serde(rename = "baseImage")]
+    pub base_image: String,
 }
 
 impl SetupPhase {
@@ -18,6 +24,7 @@ impl SetupPhase {
             pkgs,
             archive: None,
             only_include_files: None,
+            base_image: DEFAULT_BASE_IMAGE.to_string(),
         }
     }
 
@@ -39,6 +46,17 @@ impl SetupPhase {
     }
 }
 
+impl Default for SetupPhase {
+    fn default() -> Self {
+        Self {
+            pkgs: Default::default(),
+            archive: Default::default(),
+            only_include_files: Default::default(),
+            base_image: DEFAULT_BASE_IMAGE.to_string(),
+        }
+    }
+}
+
 #[serde_with::skip_serializing_none]
 #[derive(Serialize, Deserialize, Default, Clone, Debug)]
 pub struct InstallPhase {
@@ -109,10 +127,24 @@ impl BuildPhase {
 #[derive(Serialize, Deserialize, Default, Clone, Debug)]
 pub struct StartPhase {
     pub cmd: Option<String>,
+
+    #[serde(rename = "runImage")]
+    pub run_image: Option<String>,
 }
 
 impl StartPhase {
     pub fn new(cmd: String) -> Self {
-        Self { cmd: Some(cmd) }
+        Self {
+            cmd: Some(cmd),
+            run_image: None,
+        }
+    }
+
+    pub fn run_in_image(&mut self, image_name: String) {
+        self.run_image = Some(image_name);
+    }
+
+    pub fn run_in_default_image(&mut self) {
+        self.run_image = Some(DEFAULT_BASE_IMAGE.to_string());
     }
 }
diff --git a/src/providers/go.rs b/src/providers/go.rs
index cf23289..2eb61b3 100644
--- a/src/providers/go.rs
+++ b/src/providers/go.rs
@@ -1,7 +1,7 @@
 use super::Provider;
 use crate::nixpacks::{
     app::App,
-    environment::Environment,
+    environment::{Environment, EnvironmentVariables},
     nix::Pkg,
     phase::{BuildPhase, InstallPhase, SetupPhase, StartPhase},
 };
@@ -45,7 +45,30 @@ impl Provider for GolangProvider {
         }
     }
 
-    fn start(&self, _app: &App, _env: &Environment) -> Result<Option<StartPhase>> {
-        Ok(Some(StartPhase::new(format!("./{}", BINARY_NAME))))
+    fn start(&self, _app: &App, env: &Environment) -> Result<Option<StartPhase>> {
+        let mut start_phase = StartPhase::new(format!("./{}", BINARY_NAME));
+
+        let cgo = env
+            .get_variable("CGO_ENABLED")
+            .cloned()
+            .unwrap_or_else(|| "0".to_string());
+
+        // Only run in a new image if CGO_ENABLED=0 (default)
+        if cgo != "1" {
+            start_phase.run_in_default_image();
+        }
+
+        Ok(Some(start_phase))
+    }
+
+    fn environment_variables(
+        &self,
+        _app: &App,
+        _env: &Environment,
+    ) -> Result<Option<EnvironmentVariables>> {
+        Ok(Some(EnvironmentVariables::from([(
+            "CGO_ENABLED".to_string(),
+            "0".to_string(),
+        )])))
     }
 }


Test patch:
diff --git a/tests/generate_plan_tests.rs b/tests/generate_plan_tests.rs
index 0b1225a..6fc6de8 100644
--- a/tests/generate_plan_tests.rs
+++ b/tests/generate_plan_tests.rs
@@ -176,7 +176,28 @@ fn test_go() -> Result<()> {
         plan.build.unwrap().cmd,
         Some("go build -o out main.go".to_string())
     );
-    assert_eq!(plan.start.unwrap().cmd, Some("./out".to_string()));
+    assert_eq!(plan.start.clone().unwrap().cmd, Some("./out".to_string()));
+    assert!(plan.start.unwrap().run_image.is_some());
+
+    Ok(())
+}
+
+#[test]
+fn test_go_cgo_enabled() -> Result<()> {
+    let plan = gen_plan(
+        "./examples/go",
+        Vec::new(),
+        None,
+        None,
+        vec!["CGO_ENABLED=1"],
+        false,
+    )?;
+    assert_eq!(
+        plan.build.unwrap().cmd,
+        Some("go build -o out main.go".to_string())
+    );
+    assert_eq!(plan.start.clone().unwrap().cmd, Some("./out".to_string()));
+    assert!(plan.start.unwrap().run_image.is_none());
 
     Ok(())
 }
@@ -383,6 +404,22 @@ fn test_node_main_file_doesnt_exist() -> Result<()> {
     Ok(())
 }
 
+#[test]
+fn test_haskell_stack() -> Result<()> {
+    let plan = gen_plan(
+        "./examples/haskell-stack",
+        Vec::new(),
+        None,
+        None,
+        Vec::new(),
+        false,
+    )?;
+    assert_eq!(plan.build.unwrap().cmd, Some("stack build".to_string()));
+    assert!(plan.start.unwrap().cmd.unwrap().contains("stack exec"));
+    assert!(plan.install.unwrap().cmd.unwrap().contains("stack setup"));
+    Ok(())
+}
+
 #[test]
 fn test_overriding_environment_variables() -> Result<()> {
     let plan = gen_plan(
@@ -412,31 +449,17 @@ fn test_config_from_environment_variables() -> Result<()> {
             "NIXPACKS_PKGS=cowsay ripgrep",
             "NIXPACKS_BUILD_CMD=build",
             "NIXPACKS_START_CMD=start",
+            "NIXPACKS_RUN_IMAGE=alpine",
         ],
         false,
     )?;
     assert_eq!(plan.build.unwrap().cmd, Some("build".to_string()));
-    assert_eq!(plan.start.unwrap().cmd, Some("start".to_string()));
+    assert_eq!(plan.start.clone().unwrap().cmd, Some("start".to_string()));
     assert_eq!(
         plan.setup.unwrap().pkgs,
         vec![Pkg::new("cowsay"), Pkg::new("ripgrep")]
     );
+    assert_eq!(plan.start.unwrap().run_image, Some("alpine".to_string()));
 
     Ok(())
 }
-
-#[test]
-fn test_haskell_stack() -> Result<()> {
-    let plan = gen_plan(
-        "./examples/haskell-stack",
-        Vec::new(),
-        None,
-        None,
-        Vec::new(),
-        false,
-    )?;
-    assert_eq!(plan.build.unwrap().cmd, Some("stack build".to_string()));
-    assert!(plan.start.unwrap().cmd.unwrap().contains("stack exec"));
-    assert!(plan.install.unwrap().cmd.unwrap().contains("stack setup"));
-    Ok(())
-}