Node ohne Installation aus einem Docker Container

Die Installation von einer aktuellen Version von nodejs gestalltet sich gerade unter Ubuntu meist umständlich und wenig intuitiv.
Nun gibt es ja aber immer recht aktuelle Docker Images mit nodejs drinnen.

Jetzt liegt die Idee nahe einfach das Node aus einem Docker Container so im System nutzbar zu machen als währe die aktuellste Version installiert.
Den entscheidenden Anstoß gab dann dieser Blogeintrag.

Es wurde also flink eine VM eingerichtet mit Ubuntu Server 19.10 und Docker 19.03.3. und schon konnte es los gehen.

Vorbereitung

Um nicht bei jedem Aufruf von node komplett bei 0 anfangen zu müssen bietet es sich an Ordner bereit zu stellen welche später als Volumes für den Container verwendet werden können.

In meinem Fall entschied ich mich für folgendes:

mkdir ~/.npm
mkdir ~/.npm/global

Um die Möglichkeit einzuräumen global Installierte node Tools zu nutzen muss $PATH noch um den Ordner ~/.npm/global/bin erweitert werden.

Da wir später noch ausührbare Dateien anlegen wollen erstellen wir, falls nicht schon vorhanden, auch hierfür einen Ordner:

mkdir ~/bin

Dieser muss auch noch in $PATH eingetragen werden und dieses dann auch nach belieben persistiert werden. Zum Beispiel in der .bashrc oder .profile.

Der erste Versuch

Wir bauen uns eine eigene Funktion welche den container startet und etwas ausführt. In den Container werden die Ordner eingebunden welche als Cache dienen sowie der Ordner aus welchem die Funktion aufgerufen wird. Dieser wird als Arbeitsordner verwendet.

node() {
    docker run --rm -it \
        -e NPM_CONFIG_PREFIX='/.npm/global' \
        -v $(pwd):/$workfolder \
        -v ~/.npm:/root/.npm \
        -v ~/.npm/global:/.npm/global \
        -w /$workfolder \
        node:latest node $*
}

Das Ganze legen wir dann zum Beispiel in der .profile Datei ab und lesen diese mit source ~/.profile neu ein.

Der Aufruf von node -v sollte dann gegebenen Falls das Herunterladen des Images bewirken und uns eine Ausgabe beschehren ähnlich dieser: v13.5.0.

node, npm, npx, ...

NodeJS stellt neben node noch weitere Programme bereit welche nicht außer Acht gelassen werden sollten.
Da es erst einmal nur drei sind liegt der Copy & Paste Ansatz nahe. Hilfreich an dieser Stelle ist die Variable ${FUNCNAME[0]} welche immer den Funktionsnamen beinhaltet.
Das Script schnell angepasst, vervielfältigt und umbenannt:

npm() {
    docker run --rm -it \
        -e NPM_CONFIG_PREFIX='/.npm/global' \
        -v $(pwd):/$workfolder \
        -v ~/.npm:/root/.npm \
        -v ~/.npm/global:/.npm/global \
        -w /$workfolder \
        node:latest ${FUNCNAME[0]} $*
}

Zum Test einen Ordner angelegt und npm init -y aufgerufen.
Siehe da, es klappt!

So viel gleicher Code?

Da sich die Funktionen nur durch den Namen unterscheiden gibt man der Versuchung nach das Ganze etwas zusammen zu fassen.

Wir lagern den Aufruf von Docker in eine eigene Funktion aus und rufen diese dann mit entsprechenden Parametern auf.

Das sieht dann in Etwa so aus:

dockerexec() {
    image=
    executable=
    workfolder=$(basename $(pwd))
    arguments=()

    while [[ $# -gt 0 ]]; do
        key="$1"

        case $key in
            -i|--image)
                if [ "$2" ]; then
                    image=$2
                    shift
                else
                    die 'ERROR: "--image" requires a non-empty option argument.'
                fi
                ;;
            -e|--executable)
                if [ "$2" ]; then
                    executable=$2
                    shift
                else
                    die 'ERROR: "--executable" requires a non-empty option argument.'
                fi
                ;;
            -w|--workfolder)
                if [ "$2" ]; then
                    workfolder=$2
                    shift
                else
                    die 'ERROR: "--workfolder" requires a non-empty option argument.'
                fi
                ;;
            --)                     # End of all options.
                shift
                break
                ;;
            *)                      # unknown option
                arguments+=("$@")  # save it in an array for later
                shift               # past argument
                ;;
        esac

        shift
    done

    docker run --rm -it \
        -e NPM_CONFIG_PREFIX='/.npm/global' \
        -v $(pwd):/$workfolder \
        -v ~/.npm:/root/.npm \
        -v ~/.npm/global:/.npm/global \
        -w /$workfolder \
        $image $executable ${arguments[@]}

    unset -v image executable workfolder arguments
}

node() {
    dockerexec -i node -e ${FUNCNAME[0]} $*
}
npm() {
    dockerexec -i node -e ${FUNCNAME[0]} $*
}
npx() {
    dockerexec -i node -e ${FUNCNAME[0]} $*
}

Gleich viel weniger Code 🤣
Aber zumindest besser zu verwenden falls es mal noch mehr Programme werden.

npm istall -g

Voller Freude über das Erreichte weden alle Funktionen von node kreutz und quer getestet bis man bei einem Tool ankommt welches dann gern global installiert werden möchte.

npm install --global david

Die Installation klappt und ein Blick in den ~/.npm/global Ordner macht Hoffnung.
Die Blase platzt in dem Moment in dem man versucht david zu benutzen. Es folgt der Fehler, dass im Pfad keine ausführbare Datei zu finden ist mit dem Namen node.

🤔

Damit könnte er recht haben. Unsere Funktion kann meines Wissens nicht so tun als sei sie eine ausführbare Datei im Pfad.
So kommen wir an der Stelle nicht weiter. Ein neuer Ansatz muss her.

Jetzt aber richtig

Jetzt kommt der in der Vorbereitung angelegte bin Ordner ins Spiel.
Die Funktion dockerexec wird kurzerhand in eine eigene Datei ~/bin/dockerexec ausgelagert und etwas angepasst:

#!/bin/bash

image=
executable=
workfolder=$(basename $(pwd))
arguments=()

while [[ $# -gt 0 ]]; do
    key="$1"

    case $key in
        -i|--image)
            if [ "$2" ]; then
                image=$2
                shift
            else
                die 'ERROR: "--image" requires a non-empty option argument.'
            fi
            ;;
        -e|--executable)
            if [ "$2" ]; then
                executable=$2
                shift
            else
                die 'ERROR: "--executable" requires a non-empty option argument.'
            fi
            ;;
        -w|--workfolder)
            if [ "$2" ]; then
                workfolder=$2
                shift
            else
                die 'ERROR: "--workfolder" requires a non-empty option argument.'
            fi
            ;;
        --)                     # End of all options.
            shift
            break
            ;;
        *)                      # unknown option
            arguments+=("$@")  # save it in an array for later
            shift               # past argument
            ;;
    esac

    shift
done

docker run --rm -it \
    -e NPM_CONFIG_PREFIX="/home/${USER}/.npm/global" \
    -e PATH="/home/${USER}/.npm/global/bin:${PATH}" \
    -v $(pwd):/$workfolder \
    -v ~/.npm:/root/.npm \
    -v ~/.npm/global:/home/${USER}/.npm/global \
    -w /$workfolder \
    $image $executable ${arguments[@]}

unset -v image executable workfolder arguments

Info: Offensichtlich kann man auch Shell Skripte ohne Dateiendung ausführen. Wichtig ist nur die Datei auch ausführbar zu machen: chmod +x ~/bin/dockerexec.

Jetzt noch eine weitere Datei ~/bin/node anlegen und mit folgendem Inhalt befüllen:

#!/bin/bash

image="node"
tag="latest"
rguments=()

while [[ $# -gt 0 ]]; do
    key="$1"

    case $key in
        --tag)
            if [ "$2" ]; then
                tag="$2"
                shift
            else
                die 'ERROR: "--tag" requires a non-empty option argument.'
            fi
            ;;
        --)                     # End of all options.
            shift
            break
            ;;
        *)                      # unknown option
            arguments+=("$@")   # save it in an array for later
            shift               # past argument
            ;;
    esac

    shift
done

dockerexec -i $image:$tag -e ${0##*/} ${arguments[@]}

Info: Die Variable ${0##*/} enhält immer den Namen des Skriptes welches aufgerufen wurde.

Bei der Gelegenheit wurde gleich noch eine neue Funktion mit eingebaut welche es erlaubt den Tag des Docker Images mit anzugeben wenn man etwas ausführt. Im Prinzip kann da alles verwendet werden was hier zu finden ist. Lässt man den Paramenter weg wird latest verwendet.

Aus der Magie von ${0##*/} ergibt sich jetzt die Option die anderen Programmaufrufe über die selbe Datei zu abzuwickeln. Dazu legen wir in dem ~/bin Ordner Symbolic Links an:

ln -s ./node ./npm
ln -s ./node ./npx

Die Anwendung

Hat bis hier hin alles funktioniert kann man jetzt bei folgenden Aufrufen mit Versionsnummern rechnen:

npm -v
node -v
npx -v

Möchte man die zu verwenmdende node Version mit angeben sieht es wie folgt aus:

npm --tag 12 -v
node --tag 13 -v
npx --tag 10 -v

Da immer der aktuelle Ordner in dem der Befehl ausgeführt wird das Abeitsverzeichnis für node ist sollte sich die nutzung jetzt genau so anfühlen als währe node im System installiert.

Hier noch ein kurzes Beispiel:

kirk@dev-ubuntu:~/basteln/node/dockertest$ npm init -y
Wrote to /dockertest/package.json:

{
  ...
}

kirk@dev-ubuntu:~/basteln/node/dockertest$ npm install --save express
npm notice created a lockfile as package-lock.json. You should commit this file.
...
+ express@4.17.1
added 50 packages from 37 contributors and audited 126 packages in 5.147s
found 0 vulnerabilities

kirk@dev-ubuntu:~/basteln/node/dockertest$ npm install
npm WARN dockertest@1.0.0 No description
npm WARN dockertest@1.0.0 No repository field.

audited 126 packages in 1.001s
found 0 vulnerabilities

kirk@dev-ubuntu:~/basteln/node/dockertest$ david
All dependencies up to date

Der node_modules Ordner ist da wo er sein soll und auch die Aufrufe von globalen Tools ist erfolgreich.

Schluss

Dieses Konstrukt war als Machbarkeitsstudie gedacht und ich kann zur Zeit noch nicht richtig einschätzen wie praktikabel diese Lösung im Alltag wirklich sein kann. Ich habe aber mal wieder über Linux, die Bash und Docker gelernt und hoffe darauf, dass auch der Leser etwas aus diesem Artikel mitnehmen konnte.

Im Grunde sollte man diese Herangehensweise auch auf andere Tools wie zum Beispiel dotnet anwenden können. Wenn es ein Docker Image gibt kommt es nur auf einen Versuch an.

Quellen

infoq - Docker Executable Images