🗸 Gitea Actions Teil2 - Jekyll-Dockerimage

11 Minuten Lesezeit

Die Vorgeschichte

Leider hatte ich im Teil1 mit einer leicht modifizierten Kopie der Github-Action zum Bauen von Jekyll kein Glück. Daher wollte ich nun probieren, Jekyll direkt als Dockerimage laufen zu lassen. Im Docker hub gab es nicht so furchtbar viel Auswahl. Trotzdem habe ich ein meiner Meinung nach aktiv gewartetes und gut dokumentiertes Image finden können: jvconseils jekyll-docker.

“Set up job”

Und so binde ich das Docker-Jekyllimage dann in meinen workflow ein:

# workflows/jekyll-build-action.yml
jobs:
  # Build job
  build:
    runs-on: ubuntu-latest # this is the "label" the runner will use and map to docker target OS
    container: jvconseil/jekyll-docker
# [...]

Hier wird dem gitea act_runner gesagt, dass er mit dem ubuntu-latest label laufen soll, was in meinem Fall auf eine sehr kleine und schnell hochzufahrende node16:bullseye Maschine (abgeleitet vom Betriebssystem Debian11) gemappt wird. Zusätzlich wird Docker mitgeteilt, dass bitte das jekyll-docker image zu laden ist, wo die von mir benötigten Abhängigkeiten (Ruby, Sass, Jekyll…) bereits berücksichtigt wurden.

bundle install

Das Bauen schlägt hier allerdings fehl wegen in der Gemfile angegebenen, aber nicht installierten Abhängigkeiten. Das kann dann so ählich aussehen wie hier:

bundler: failed to load command: jekyll (/usr/gem/bin/jekyll)
/usr/local/lib/ruby/gems/3.2.0/gems/bundler-2.4.22/lib/bundler/resolver.rb:332:in `raise_not_found!': Could not find gem 'github-pages' in locally installed gems. (Bundler::GemNotFound)
	from /usr/local/lib/ruby/gems/3.2.0/gems/bundler-2.4.22/lib/bundler/resolver.rb:392:in `block in prepare_dependencies'

Bundler ist der Paketmanager für in Ruby geschriebene Software. Ähnlich wie npm für Javascript oder pip für Python ist bundler in der Lage, von einem Ruby-Programm genutzte Bibliotheken und andere Abhängigkeiten herunterzuladen und so einzubinden, dass sie ohne weitere Konfiguration auf dem Zielsystem verwendet werden können. Damit bundler weiß, was zu installieren ist, wird eine sogenannte Gemfile angelegt.

Also dem Script schnell bundle install hinzufügen, etwa so:

# workflows/jekyll-build-action.yml
# [...]
    steps:
      - name: --- CHECKOUT ---
        uses: actions/checkout@v3
      - name: --- INSTALL GEMS ---
        run: bundle install # will fail with permissions rights to write to Gemfile.lock but anyways installs required dependencies.
      - name: --- BUILD WITH JEKYLL ---
        # Outputs to the './_site' directory by default
        run: bundle exec jekyll build --destination /opt/blog_staging
        env:
          JEKYLL_ENV: production

act_runner: Kein Schreibzugriff auf Gemfile.lock

Dies schlägt wieder fehl - wegen fehlender Schreibrechte des act_runner:

gitea-runner-1  | [Deploy Jekyll site/build]   | `/workspace/schallbert/blog/Gemfile.lock`. It is likely that you need to grant
gitea-runner-1  | [Deploy Jekyll site/build]   | write permissions for that path.
gitea-runner-1  | [Deploy Jekyll site/build]   ❌  Failure - Main ---INSTALL GEMS ---
gitea-runner-1  | [Deploy Jekyll site/build] exitcode '23': failure

Nach langer Suche muss ich feststellen, dass Gemfile.lock auf meinem Rechner nicht der Versionskontrolle unterliegt, da es in der .gitignore auftaucht. Der Bundler des act_runner versucht nun also, diese Datei selbst aus der Gemfile zu erzeugen und scheitert, weil keine Schreibrechte auf dem Repository bzw. der temporären Kopie vorliegen.

Dies lässt sich am besten beheben, indem die Gemfile.lock in die Versionskontrolle aufgenommen wird, d.h. aus der .gitignore verschwindet. Somit stelle ich sicher, dass ich für das lokale Bauen auf meinem Laptop dieselben Umgebungsbedingungen habe wie auf dem Server.

Ist dies nicht gewünscht, so kann man dem zugreifenden User Bundler auch den Besitz der Datei übergeben (chown Bundler Gemfile.lock), Hintergrundinformationen dazu sind auf der Bundler Website zu finden.

Abhängigkeiten: sass-embedded

Nach weniger als 20 Sekunden die nächste Fehlermeldung:

Resolving dependencies...
Could not find gem 'sass-embedded (= 1.69.5)' with platform 'x86_64-linux' in
rubygems repository https://rubygems.org/ or installed locally.

Nach weiteren Minuten der Recherche die Lösung: In der Gemfile eine bestimmte Version des jekyll-sass-converter spezifizieren oder alternativ das Gem “github-pages” in der Gemfile belassen. Dieses kümmert sich ebenfalls darum, dass der CascadingStyleSheets (CSS) Präprozessor Sass vorhanden ist.

Nun endlich läuft bundle install erfolgreich durch:

✅ Bundle complete! 5 Gemfile dependencies, 43 gems now installed.

jekyll build

Damit Jekyll die gebauten Dateien auch irgendwo abspeichern kann, habe ich dem act_runner in der docker-compose.yml ein weiteres Volume gegönnt:

# gitea/docker-compose.yml
# [...]
volumes:
      - ./runner/blog_staging:/opt/blog_staging
      - ./runner/data:/data
      - /var/run/docker.sock:/var/run/docker.sock

Und jetzt direkt nochmal probieren, ob Jekyll erfolgreich baut:

       Destination: /opt/blog_staging
      Generating... 
       Jekyll Feed: Generating feed for posts
jekyll 3.9.3 | Error:  Permission denied @ dir_s_mkdir - /opt/blog_staging
/usr/local/lib/ruby/3.2.0/fileutils.rb:406:in `mkdir': Permission denied @ dir_s_mkdir - /opt/blog_staging (Errno::EACCES)
	from /usr/local/lib/ruby/3.2.0/fileutils.rb:406:in `fu_mkdir'

Auch dieses Problem lässt sich leicht lösen, indem man entweder nach /tmp baut, wo der user jekyll während des Bauens ebenfalls Zugriff hat, oder indem man der Action vor dem jekyll build-Befehl einen Nutzerwechsel im Build-Arbeitsverzeichnis vornimmt: chown -R jekyll /opt/blog_staging.

Mit dieser Änderung läuft die Action endlich durch. Image: Gitea snapshot showing a successful build

Artefakte transferieren

Doch wo finde ich jetzt die in der Action gebauten Dateien, welche mein Webserver dann veröffentlichen soll? Der _site-Ordner ist nirgendwo zu finden - weder auf dem Volume der Hostmaschine, noch im Gitea-Dockercontainer oder im Runner!

Docker volumes für act_runner können Artefakte nicht verteilen

Dass ich über Docker volumes nicht dort herankomme erscheint logisch wenn man weiß, wie act-runner funktioniert: Die Action erzeugt ein eigenes Dockerimage mit wiederum per Task-ID assoziierten Volumes, welches nur für dessen Laufzeit existiert.

DRIVER    VOLUME NAME
--> local     GITEA-ACTIONS-TASK-84_WORKFLOW-Deploy-Jekyll-site_JOB-build <--
--> local     GITEA-ACTIONS-TASK-84_WORKFLOW-Deploy-Jekyll-site_JOB-build-env <--
local     act-toolcache
local     blog_staging

Legt man die Artefakte also nirgendwo extern ab, so werden sie nach Durchlauf der Aktionen zusammen mit dem Image demontiert und sind futsch. Das oben angelegte Volume des Runners hat gar keine Auswirkung auf die Action, weil sie wieder in einem isolierten Image läuft.

Dies zu verstehen hat mich einige Zeit und Fehlversuche gekostet, dabei ist es in einem Abschnitt der Dokumentation beschrieben.

Das Ganze gestaltet sich ja deutlich komplizierter als ich dachte. Mit Kanonen auf Spatzen schießen angesichts meiner kleinen Website und als Einziger, der sie pflegt. Egal, ich ziehe das jetzt durch.

Aber man kann doch upload-artifact verwenden!

Na ja, das dachte ich jedenfalls. Also habe ich mein Action-Skript entsprechend erweitert und die von Github Actions zur Verfügung gestellte Funktion eingebaut:

# workflows/jekyll-build-action.yml
# [...]
# Automatically upload the build folder to Giteas blog repo folder
      - name: --- UPLOAD ARTIFACT ---
        uses: actions/upload-artifact@v3
        with:
          path: /workspace/schallbert/blog/
          name: Blog_Staging
          retention-days: 2

Super, jetzt erscheint ein herunterladbares Zip-file auf der Weboberfläche von Gitea:

Image: Gitea snapshot showing a successful artifact upload

Nur: Wie bekomme ich das Ding jetzt automatisiert auf meine Serverinstanz? Schließlich kennt die Action diese nicht - sie hat ja nicht einmal einen eigenen Docker Daemon.

Also schaue ich mal im Gitea-Container selbst nach, wo die Artefakte unter /data/gitea/actions_artifacts/BUILD_ID abgelegt sind. Allerdings als ein Haufen (hunderte) .chunk.gz Dateien mit kryptischen Nummernfolgen als Name, bei denen ich wiederum nicht weiß wie ich sie zu einem Archiv zusammenfügen soll.

Und noch etwas gefällt mir an upload-artifact nicht: Da meine Website inzwischen eine Menge Bilder, Audiodateien und Videos enthält, ist sie recht groß. Daher benötigt der uploader für’s Verpacken und Versenden schon jetzt fast zwei Minuten - Tendenz linear steigend mit der Anzahl meiner Medieninhalte.

Sackgasse.

Auch Docker cp funktioniert in diesem Falle nicht

Dann versuche ich mit Hilfe des Kopierbefehls von Docker, die Artefakte auf meinen Host zu transferieren:

# workflows/jekyll-build-action.yml
# [...]
- name: --- COPY ARTIFACT ---
        run: docker cp gitea-runner-1:/workspace/schallbert/blog /tmp/blog_staging

Dies schlägt wieder fehl:

gitea-runner-1  | docker cp gitea-runner-1:/workspace/schallbert/blog /tmp/blog_staging
[...]
gitea-runner-1  | [Deploy Jekyll site/build]   | /var/run/act/workflow/3.sh: line 2: docker: not found
gitea-runner-1  | [Deploy Jekyll site/build]   ❌  Failure - Main --- COPY ARTIFACT ---

Auch erwartbar, denn der Action-Container selbst kennt kein Docker. Es ist also nicht möglich, etwas von “innerhalb” des Containers per Docker heraus zu bekommen. Dies ist für mich aber erforderlich, da die Action ja den ganzen Prozess vollständig automatisieren soll.

Oder geht es doch irgendwie? Der Daemon der Hostmaschine docker.sock steht laut Giteas Dokumentation zumindest dem act_runner per Volume zur Verfügung - wird aber scheinbar nicht in die Action weitergeleitet. Vielleicht aus gutem Grund, sonst wäre die Kapselung futsch und eine schadhafte Action könnte praktisch meinen Docker Daemon auf dem Host kapern 😰.

Transferieren per SFTP würde klappen

So langsam gehen mir die Alternativen aus. Ich überlege nun, die Artefakte per SFTP aus dem vom act_runner erzeugten Action-Dockerimage heraus in den Webserver-Container zu schieben1.

Image: Machine architecture if I used SFTP for artifact share between Gitea and webserver

Glücklicherweise gibt es hierfür wieder eine vorgefertigte Action: scp-files. Nun muss ich aber den Container des Webservers so umbauen, dass er per SCP erreichbar ist und den SSH-Schlüssel der Action akzeptiert.

Andererseits wird davor in vielen Foren gewarnt - Ein Container sollte nur eine Applikation beherbergen. Aber auch hier gibt es eine Lösung: ein eigener Docker-Container mit SSH-Daemon. Diesem könnte ich dann ein Volume geben, welches er mit dem Webserver teilt.

Sieht leider nach einigem Aufwand aus. Und ich möchte als fauler Entwickler mein System so einfach wie möglich halten - also recherchiere ich weiter.

Dem Action-Container ein Volume zur Verfügung stellen

Image: Docker-Gitea infrastructure overview

Diese ganzen Fehlschläge und Sackgassen begleiten mich jetzt schon seit über einem Monat. Ich will es trotzdem unbedingt schaffen, vollautomatisch bauen, testen und veröffentlichen zu können. Auf dem Weg hierhin habe ich eine Menge gelernt und jetzt hoffe ich, dass dieser Ansatz mich endlich zur Lösung bringt.

Bei meiner Suche stieß ich auf die Möglichkeit, die Konfiguration des Runners zu verändern. So könnte ich am Ende vielleicht doch noch ein Volume einbinden, welches ich zwischen der Action und meinem Server über den Daemon auf dem Host teilen kann.

Wenn das klappt, hätte ich kein zusätzliches Sicherheitsrisiko wie ein öffentlich verfügbarer file transfer container oder die langen Wartezeiten durch upload-artifact. Also frisch ans Werk! Es gibt noch weitere Gründe, weswegen Volumes in der jeweiligen Action Sinn machen können - es muss also bereits Leute da draußen geben, die das hinbekommen haben.

Ich versuche also, die Volumes im Action-Skript anzulegen:

# workflows/jekyll-build-action.yml
# [...]
container: 
      image: jvconseil/jekyll-docker:latest
      volumes: 
        - /tmp/blog_staging:/blog_staging
        - /opt/cache:/opt/hostedtoolcache

Doch irgendwie landen die Artefakte noch immer nicht auf der Platte meines Hosts. Ich vereinfache die Action soweit, dass ich nur noch prüfe, ob die Erzeugung des Volumes auch einen entsprechenden Ordner anlegt:

# workflows/jekyll-build-action.yml
# [...]
steps:
  - name: --- CHECK_VOLUME ---
  run: |
    ls -al /blog_staging

und schon schlägt die Aktion wieder fehl:

gitea-runner-1  | [Deploy Jekyll site/build] [DEBUG] Working directory '/workspace/schallbert/blog'
gitea-runner-1  | [Deploy Jekyll site/build]   | ls: /blog_staging: No such file or directory
gitea-runner-1  | [Deploy Jekyll site/build]   ❌  Failure - Main --- CHECK_VOLUME ---

Nach ein paar weiteren, erfolglosen Versuchen (vielleicht hatte ich etwas falsch geschrieben?) und langer Recherche in den Foren fand ich heraus, dass es das valid_volumes Attribut im act gibt - taucht das Volume dort nicht auf, so wird es auch nicht eingebunden.

OK, also schnell der Action hinzugefügt:

# jekyll-build-action.yml
# [...]
container: 
      image: jvconseil/jekyll-docker:latest
      valid_volumes: 
        - '**'  # This does not work. Also specialized lists indicating the volumes directly won't work
      volumes: 
        - /tmp/blog_staging:/blog_staging
        - /opt/cache:/opt/hostedtoolcache

Aber immer noch nichts. Immerhin kann ich in den Logs mit dem Wissen über valid_volumes jetzt eine Warnung entdecken:

gitea-runner-1  | [Deploy Jekyll site/build] [/tmp/blog_staging] is not a valid volume, will be ignored
gitea-runner-1  | [Deploy Jekyll site/build] [/opt/cache] is not a valid volume, will be ignored

Etwas stimmt mit der Übergabe dieser Option nicht. Also recherchiere ich weiter. Es gibt Berichte, in denen das Anmelden der Volumes funktioniert. Also erzeuge ich nach Anleitung eine Konfigurationsdatei, wo ich das Folgende eintrage:

# runner/config.yml

# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
  # You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
  # For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
  # valid_volumes:
  #   - data
  #   - /src/*.json
  # If you want to allow any volume, please use the following configuration:
  # valid_volumes:
  #   - '**'
  valid_volumes: ["/tmp/blog_staging", "/opt/hostedtoolcache"]

Wichtig: Die Notation muss als String vorliegen, kommasepariert, und gibt stets die Source des Volumes an. Also den Teil, der vor dem : steht. Doch noch immer erscheint beim Bauen die Fehlermeldung “not a valid volume”.

Doch dann fällt mir auf, dass die Config dem act_runner selbst als Volume zur Verfügung gestellt werden muss - sonst kann der im Container laufende Runner ja gar nicht darauf zugreifen!

Daher sieht meine docker-compose.yml für Gitea, Abschnitt “runner” nun wie folgt aus:

# gitea/docker-compose.yml
# [...]
  runner:
    image: gitea/act_runner:latest
    environment:
      - CONFIG_FILE=/config.yml
      - GITEA_INSTANCE_URL=https://git.schallbert.de
      - GITEA_RUNNER_NAME=ichlaufe
      - GITEA_RUNNER_REGISTRATION_TOKEN= <redacted>
    volumes:
      - ./runner/config.yml:/config.yml
      - ./runner/data:/data
      - /opt/hostedtoolcache:/opt/hostedtoolcache
      - /var/run/docker.sock:/var/run/docker.sock

Und endlich:

gitea-runner-1  | ls -al /blog_staging
gitea-runner-1  | [Deploy Jekyll site/build]   | total 8
gitea-runner-1  | [Deploy Jekyll site/build]   | drwxr-xr-x    2 root     root          4096 Dec 27 07:32 .
gitea-runner-1  | [Deploy Jekyll site/build]   | drwxr-xr-x    1 root     root          4096 Dec 27 07:46 ..
gitea-runner-1  | [Deploy Jekyll site/build]   ✅  Success - Main --- CHECK_VOLUME ---

Was für ein Akt. Ich bin so froh, dass jetzt alles durchläuft und ich auf meinem lokalen Hostsystem tatsächlich die gebauten Dateien vorfinde! 🥳

Noch ein letzter Tipp

Wenn ihr so wie ich mit einem Reverse Proxy arbeitet und so komische connection refused Fehlermeldungen beim Hochfahren des Runners erhaltet wie:

docker compose up
[+] Running 2/0
 ✔ Container gitea           Created                                                                                                                     0.0s 
 ✔ Container gitea-runner-1  Created                                                                                                                     0.0s 
Attaching to gitea, gitea-runner-1
gitea-runner-1  | level=info msg="Starting runner daemon"
gitea-runner-1  | level=error msg="fail to invoke Declare" error="unavailable: dial tcp <address>: connect: connection refused"
gitea-runner-1  | Error: unavailable: dial tcp <address> connect: connection refused
gitea           | Server listening on :: port 22.
gitea-runner-1 exited with code 1

Dann ist euer Reverse Proxy entweder falsch eingestellt oder - wie in meinem Falle - nicht hochgefahren.

  1. Die Nutzung des SFTP (Secure File Transfer Protocol), also der Dateitransfer über ssh (secure shell), erhöht die Angriffsfläche meines Systems ein wenig. Der SSH-Dienst ist nämlich dann nicht nur für meine Action, sondern ganz allgemein von außen erreichbar.