Skip to content

Commit 7447758

Browse files
feat(dockerfile)!: remove venv, install pip packages into base Python (#59)
* feat(dockerfile)!: remove venv, install pip packages into base Python Pip packages used to be installed into /opt/venv, which was created with --system-site-packages. On base images that already shipped a Python environment (pytorch/pytorch, python:3.11-slim, etc.) pip's dependency resolver still re-downloaded packages like torch into the venv, duplicating gigabytes of content. The --system-site-packages flag only helps at import time, not at install time. Install pip packages directly into whatever Python is on PATH. On images with a pre-existing Python environment (conda, python images) pip sees existing packages and skips them. On Debian/Ubuntu images where pip would otherwise be blocked by PEP 668, the marker file at /usr/lib/python*/EXTERNALLY-MANAGED is removed once before the first pip step. PEP 668 is a host-system protection against apt/pip conflicts; it does not apply inside containers. BREAKING CHANGE: /opt/venv no longer exists in generated Dockerfiles. ENV VIRTUAL_ENV and the /opt/venv/bin PATH prefix are gone. Scripts that explicitly reference /opt/venv/bin/python or similar paths must be updated to use /usr/bin/python3, /opt/conda/bin/python, or whichever Python the chosen base image provides. The runtime user no longer has write access to pip-installed packages by default. Interactive 'pip install' inside a running container now requires either 'pip install --user foo' (installs to the user's home) or running the container with '--user 0' to temporarily run as root. * test(dockerfile): cover pip marker removal and no-venv behaviour * docs: remove venv mention from features list * fix(dockerfile): flatten create_user shell structure for shellcheck Replace nested 'else; if' patterns with 'elif' and '||' short-circuits to eliminate hadolint SC1075 errors on generated Dockerfiles. Also suppress DL4006 on the user-creation RUN: the pipe's failure semantics are intentional (getent failure means 'no such user', which is the branch we want to take) and pipefail would be a no-op. Resolves shellcheck-flagged errors in generated Dockerfiles across all projects. No behavioural change.
1 parent d7bbc07 commit 7447758

5 files changed

Lines changed: 106 additions & 234 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ Build and run the production image:
6868
* **Automatic user handling** - host user identity in dev, dedicated user in prod, no manual setup
6969
* **GPU, display, and audio** - NVIDIA GPU passthrough, X11/Wayland forwarding, PulseAudio/PipeWire
7070
* **Custom commands** - define once, use in both dev and prod with port publishing and environment variables
71-
* **Multi-stage builds** - share steps between stages, automatic virtual environments for pip
71+
* **Multi-stage builds** - share steps between stages, pip packages install into the base image's Python (no venv duplication)
7272
* **Transparent execution** - run commands from anywhere in your repo with automatic path translation
7373
* **Data volumes** - shorthand for sibling folders (`outputs`, `cache`) that persist across runs without entering the image
7474
* **AWS credential forwarding** - mount host AWS config into the container

src/container_magic/generators/dockerfile.py

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ def process_stage_steps(
178178
registry: Dict = None,
179179
asset_map: Dict[str, str] = None,
180180
workspace_symlinks: List[tuple] = None,
181-
venv_active: bool = False,
181+
pip_prepared: bool = False,
182182
implicit_user: bool = False,
183183
) -> tuple:
184184
"""Process build steps for a stage.
@@ -190,7 +190,7 @@ def process_stage_steps(
190190
symlink overlay data for the Dockerfile template.
191191
192192
Returns:
193-
(ordered_steps, venv_active) tuple
193+
(ordered_steps, pip_prepared) tuple
194194
"""
195195
if registry is None:
196196
registry = load_registry()
@@ -202,7 +202,7 @@ def process_stage_steps(
202202
# Default build order if not specified
203203
if stage.steps is None:
204204
if ":" in stage.frm or "/" in stage.frm:
205-
return [], venv_active
205+
return [], pip_prepared
206206
else:
207207
steps: List[Union[str, Dict[str, Any]]] = []
208208
if stage_name == "production":
@@ -235,13 +235,13 @@ def _resolve_user_ref(name: str) -> str:
235235
ordered_steps = []
236236

237237
for step in steps:
238-
if _step_is_pip(step) and not venv_active:
238+
if _step_is_pip(step) and not pip_prepared:
239239
is_root = current_user is None
240-
venv_step = {"type": "venv_setup", "is_root": is_root}
240+
prepare_step = {"type": "prepare_pip", "is_root": is_root}
241241
if not is_root:
242-
venv_step["restore_user"] = current_user
243-
ordered_steps.append(venv_step)
244-
venv_active = True
242+
prepare_step["restore_user"] = current_user
243+
ordered_steps.append(prepare_step)
244+
pip_prepared = True
245245

246246
parsed = parse_step(step, registry)
247247

@@ -307,7 +307,7 @@ def _resolve_user_ref(name: str) -> str:
307307
elif step_type == "passthrough":
308308
ordered_steps.append({"type": "custom", "command": parsed["command"]})
309309

310-
return ordered_steps, venv_active
310+
return ordered_steps, pip_prepared
311311

312312

313313
def generate_dockerfile(
@@ -369,7 +369,7 @@ def generate_dockerfile(
369369
leaf_stages = set(stages.keys()) - inherited_stages
370370

371371
stages_data = []
372-
venv_state: Dict[str, bool] = {}
372+
pip_prepared_state: Dict[str, bool] = {}
373373
user_created_state: Dict[str, bool] = {}
374374
for stage_name, stage_config in stages.items():
375375
base_image = stage_config.frm
@@ -392,13 +392,13 @@ def generate_dockerfile(
392392
user_creation_style = distro_ucs or detect_user_creation_style(resolved_image)
393393

394394
if from_is_image:
395-
inherited_venv = False
395+
inherited_pip_prepared = False
396396
inherited_user_created = False
397397
else:
398-
inherited_venv = venv_state.get(base_image, False)
398+
inherited_pip_prepared = pip_prepared_state.get(base_image, False)
399399
inherited_user_created = user_created_state.get(base_image, False)
400400

401-
ordered_steps, venv_active = process_stage_steps(
401+
ordered_steps, pip_prepared = process_stage_steps(
402402
stage_config,
403403
stage_name,
404404
project_dir,
@@ -408,10 +408,10 @@ def generate_dockerfile(
408408
registry,
409409
asset_map,
410410
workspace_symlinks,
411-
venv_active=inherited_venv,
411+
pip_prepared=inherited_pip_prepared,
412412
implicit_user=implicit_user,
413413
)
414-
venv_state[stage_name] = venv_active
414+
pip_prepared_state[stage_name] = pip_prepared
415415

416416
# Inject implicit create_user for from-image stages
417417
has_explicit_create_in_steps = any(
@@ -425,21 +425,6 @@ def generate_dockerfile(
425425
else:
426426
user_created_state[stage_name] = inherited_user_created
427427

428-
# Chown venv to runtime user in leaf stages so it's writable in development
429-
if has_user and stage_name in leaf_stages and venv_active:
430-
# Determine if we're in a non-root context (inherited from parent)
431-
last_become_user = None
432-
for s in reversed(ordered_steps):
433-
if s.get("type") == "become":
434-
last_become_user = s.get("name")
435-
break
436-
inherited_context = _get_parent_user_context(stage_name, stages, user_name)
437-
is_root = last_become_user is None and inherited_context is None
438-
chown_step = {"type": "venv_chown", "is_root": is_root}
439-
if not is_root:
440-
chown_step["restore_user"] = last_become_user or inherited_context
441-
ordered_steps.append(chown_step)
442-
443428
# Inject implicit become at end of leaf stages only
444429
# Intermediate stages stay as root so child stages inherit root context
445430
if has_user and stage_name in leaf_stages:

src/container_magic/templates/Dockerfile.j2

Lines changed: 27 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -34,53 +34,38 @@ ARG USER_GID={{ stage.user_gid }} \
3434
{% for step in stage.ordered_steps %}
3535
{% if step.type == "create_user" %}
3636
# Create user account (handles renaming if UID exists with different name)
37+
# hadolint ignore=DL4006
3738
{% if stage.user_creation_style == "alpine" %}
3839
RUN if [ "${USER_NAME}" != "root" ]; then \
39-
# Check if the user already exists with exact match (name + uid + gid)
40-
if getent passwd "${USER_UID}" | grep -q "^${USER_NAME}:"; then \
40+
EXISTING_USER=$(getent passwd "${USER_UID}" | cut -d: -f1); \
41+
if [ "${EXISTING_USER}" = "${USER_NAME}" ]; then \
4142
echo "User ${USER_NAME} with UID ${USER_UID} already exists, skipping creation"; \
43+
elif [ -n "${EXISTING_USER}" ]; then \
44+
echo "Renaming user ${EXISTING_USER} to ${USER_NAME}"; \
45+
deluser "${EXISTING_USER}" && \
46+
addgroup -g "${USER_GID}" "${USER_NAME}" 2>/dev/null; \
47+
adduser -D -u "${USER_UID}" -G "${USER_NAME}" -h "${USER_HOME}" "${USER_NAME}"; \
4248
else \
43-
# Check if UID exists with a different name
44-
EXISTING_USER=$(getent passwd "${USER_UID}" | cut -d: -f1 || echo ""); \
45-
if [ -n "${EXISTING_USER}" ] && [ "${EXISTING_USER}" != "${USER_NAME}" ]; then \
46-
# Rename the existing user to our desired name
47-
echo "Renaming user ${EXISTING_USER} to ${USER_NAME}"; \
48-
deluser "${EXISTING_USER}"; \
49-
addgroup -g "${USER_GID}" "${USER_NAME}" 2>/dev/null || true; \
50-
adduser -D -u "${USER_UID}" -G "${USER_NAME}" -h "${USER_HOME}" "${USER_NAME}"; \
51-
else \
52-
# Create new user
53-
if ! getent group "${USER_GID}" >/dev/null 2>&1; then \
54-
addgroup -g "${USER_GID}" "${USER_NAME}"; \
55-
fi && \
56-
adduser -D -u "${USER_UID}" -G "${USER_NAME}" -h "${USER_HOME}" "${USER_NAME}"; \
57-
fi \
49+
getent group "${USER_GID}" >/dev/null 2>&1 || \
50+
addgroup -g "${USER_GID}" "${USER_NAME}"; \
51+
adduser -D -u "${USER_UID}" -G "${USER_NAME}" -h "${USER_HOME}" "${USER_NAME}"; \
5852
fi && \
59-
# Ensure home directory ownership (may have been created by WORKDIR)
6053
chown "${USER_UID}:${USER_GID}" "${USER_HOME}"; \
6154
fi
6255
{% else %}
6356
RUN if [ "${USER_NAME}" != "root" ]; then \
64-
# Check if the user already exists with exact match (name + uid + gid)
65-
if getent passwd "${USER_UID}" | grep -q "^${USER_NAME}:"; then \
57+
EXISTING_USER=$(getent passwd "${USER_UID}" | cut -d: -f1); \
58+
if [ "${EXISTING_USER}" = "${USER_NAME}" ]; then \
6659
echo "User ${USER_NAME} with UID ${USER_UID} already exists, skipping creation"; \
60+
elif [ -n "${EXISTING_USER}" ]; then \
61+
echo "Renaming user ${EXISTING_USER} to ${USER_NAME}"; \
62+
usermod -l "${USER_NAME}" "${EXISTING_USER}" && \
63+
usermod -d "${USER_HOME}" -m "${USER_NAME}" || true; \
6764
else \
68-
# Check if UID exists with a different name
69-
EXISTING_USER=$(getent passwd "${USER_UID}" | cut -d: -f1 || echo ""); \
70-
if [ -n "${EXISTING_USER}" ] && [ "${EXISTING_USER}" != "${USER_NAME}" ]; then \
71-
# Rename the existing user to our desired name
72-
echo "Renaming user ${EXISTING_USER} to ${USER_NAME}"; \
73-
usermod -l "${USER_NAME}" "${EXISTING_USER}" && \
74-
usermod -d "${USER_HOME}" -m "${USER_NAME}" || true; \
75-
else \
76-
# Create new user
77-
if ! getent group "${USER_GID}" >/dev/null 2>&1; then \
78-
groupadd --gid "${USER_GID}" "${USER_NAME}"; \
79-
fi && \
80-
useradd --uid "${USER_UID}" --gid "${USER_GID}" --home-dir "${USER_HOME}" --create-home "${USER_NAME}"; \
81-
fi \
65+
getent group "${USER_GID}" >/dev/null 2>&1 || \
66+
groupadd --gid "${USER_GID}" "${USER_NAME}"; \
67+
useradd --uid "${USER_UID}" --gid "${USER_GID}" --home-dir "${USER_HOME}" --create-home "${USER_NAME}"; \
8268
fi && \
83-
# Ensure home directory ownership (may have been created by WORKDIR)
8469
chown "${USER_UID}:${USER_GID}" "${USER_HOME}"; \
8570
fi
8671
{% endif %}
@@ -122,21 +107,16 @@ ENV {% for key, value in step.vars.items() %}{% if not loop.first %} {% endif
122107
{% endif %}{% endfor %}
123108

124109
{% endif %}
125-
{% elif step.type == "venv_setup" %}
110+
{% elif step.type == "prepare_pip" %}
126111
{% if not step.is_root %}
127112
USER root
128113
{% endif %}
129-
RUN test -f /opt/venv/pyvenv.cfg || python3 -m venv --system-site-packages /opt/venv
130-
ENV VIRTUAL_ENV=/opt/venv
131-
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
132-
{% if not step.is_root %}
133-
USER {{ step.restore_user }}
134-
{% endif %}
135-
{% elif step.type == "venv_chown" %}
136-
{% if not step.is_root %}
137-
USER root
138-
{% endif %}
139-
RUN chown -R "${USER_UID}:${USER_GID}" /opt/venv
114+
# Disable PEP 668 (externally-managed-environment): host-system protection
115+
# against apt/pip conflicts, not applicable inside containers where the whole
116+
# environment is disposable.
117+
RUN rm -f /usr/lib/python*/EXTERNALLY-MANAGED \
118+
/usr/local/lib/python*/EXTERNALLY-MANAGED \
119+
/opt/conda/lib/python*/EXTERNALLY-MANAGED
140120
{% if not step.is_root %}
141121
USER {{ step.restore_user }}
142122
{% endif %}

tests/unit/test_implicit_user.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ def test_child_distro_overrides_parent(self):
308308
assert "useradd" in other
309309

310310

311-
class TestImplicitUserWithVenv:
311+
class TestImplicitUserWithPip:
312312
def test_implicit_user_with_pip_step(self):
313313
"""Pip step with implicit user creation should work correctly."""
314314
config = {
@@ -324,8 +324,8 @@ def test_implicit_user_with_pip_step(self):
324324
}
325325
content = _generate(config)
326326
assert "useradd" in content
327-
assert "python3 -m venv" in content
327+
assert "# Disable PEP 668" in content
328328
lines = content.splitlines()
329329
create_idx = next(i for i, ln in enumerate(lines) if "useradd" in ln)
330-
venv_idx = next(i for i, ln in enumerate(lines) if "venv" in ln and "RUN" in ln)
331-
assert create_idx < venv_idx
330+
marker_idx = next(i for i, ln in enumerate(lines) if "# Disable PEP 668" in ln)
331+
assert create_idx < marker_idx

0 commit comments

Comments
 (0)