From dc71b8c6b9e68f0fa6d9a0b85da63ef8b32f7044 Mon Sep 17 00:00:00 2001 From: ElevenNotes Date: Tue, 28 Nov 2023 14:36:55 +0100 Subject: [PATCH] init --- .gitignore | 2 + LICENSE | 21 +++++++ README.md | 89 ++++++++++++++++++++++++++++++ amd64.dockerfile | 33 +++++++++++ rootfs/labels/app.js | 74 +++++++++++++++++++++++++ rootfs/labels/main.js | 18 ++++++ rootfs/usr/local/bin/entrypoint.sh | 6 ++ 7 files changed, 243 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 amd64.dockerfile create mode 100644 rootfs/labels/app.js create mode 100644 rootfs/labels/main.js create mode 100644 rootfs/usr/local/bin/entrypoint.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26e336c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +maintain/ +/build \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..76597e8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 11notes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..22f4ab7 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Alpine :: Traefik Labels +![size](https://img.shields.io/docker/image-size/11notes/traefik-labels/0.1.0?color=0eb305) ![version](https://img.shields.io/docker/v/11notes/traefik-labels?color=eb7a09) ![pulls](https://img.shields.io/docker/pulls/11notes/traefik-labels?color=2b75d6) ![activity](https://img.shields.io/github/commit-activity/m/11notes/docker-traefik-labels?color=c91cb8) ![commit-last](https://img.shields.io/github/last-commit/11notes/docker-traefik-labels?color=c91cb8) + +Run Traefik Labels based on Alpine Linux. Small, lightweight, secure and fast 🏔️ + +What can I do with this? Simply put: It will export any traefik labels on a container on the same host as this image runs to a Redis instance. This makes it possible for a centralized Traefik load balancer to update endpoints dynamically by utilizing the docker labels, just like you would on a local installation of Traefik with labels. It is meant as an alternative and simple way to proxy requests from a Traefik load balanacer to multiple docker nodes running in different networks and locations. + +In order to use this image, you need to setup Traefik with a Redis provider and then point this image via REDIS_URL to the same Redis instance. Each entry will have an expire timer set in Redis, so that if a container is removed by a server crashing, Redis will automatically remove stale entries as well. Entries are refreshed every 60 seconds or an all docker container events (create, run, kill, stop, restart, ...). + +## Run +This will export all labels from all containers to the Redis instance specified in LABELS_REDIS_URL from the same host this container is running on. +```shell +docker run --name traefik-labels \ + -v /run/docker.sock:/run/docker.sock \ + -e LABELS_REDIS_URL="rediss://foo:bar@10.127.198.254:6379/0" \ + -d 11notes/traefik-labels:[tag] +``` + +This is a demo webserver that will start on :8080, all the traefik labels will be exportet to the Redis instance. They follow the exact same [syntax](https://doc.traefik.io/traefik/routing/providers/kv/) as for normal Redis and Traefik, just as labels. +```shell +docker run --name demo \ + -p 8080:8080 \ + -l "traefik/http/routers/demo.domain.com/service=demo.domain.com" \ + -l "traefik/http/routers/demo.domain.com/rule=Host(`demo.domain.com`)" \ + -l "traefik/http/routers/demo.domain.com/tls=true" \ + -l "traefik/http/routers/demo.domain.com/entrypoints=https" \ + -l "traefik/http/services/demo.domain.com/loadbalancer/servers/0/url=http://fqdn-of-docker-node:8080" \ + -d 11notes/nginx:stable +``` + +## Defaults +| Parameter | Value | Description | +| --- | --- | --- | +| `user` | docker | user docker | +| `uid` | 1000 | user id 1000 | +| `gid` | 1000 | group id 1000 | +| `home` | /labels | home directory of user docker | + +## Environment +| Parameter | Value | Default | +| --- | --- | --- | +| `LABELS_REDIS_URL` | the redis URL to connect, use rediss:// for SSL | redis:://localhost:6379/0 | +| `LABELS_INTERVAL` | in what interval container information is pulled | 60 | +| `LABELS_TIMEOUT` | how many seconds after an interval the keys should stay till they expire | 15 | + +## Example +```mermaid +flowchart TB + + subgraph Edge + A[WAN]:::WAN -->|:8443| B(keepalived VIP):::KEEPALIVED + end + + subgraph Domain_B + B -->|:8443| C(Traefik):::TRAEFIK + C -->|:6379| E(Redis):::REDIS + end + subgraph Domain_A + B -->|:8443| D(Traefik):::TRAEFIK + D -->|:6379| F(Redis):::REDIS + end + subgraph Docker_Nodes + id1[Node 1] + id2[Node 2] + idn[Node n] + end + + Domain_A -->|:8443| Docker_Nodes + Domain_B --> |:8443|Docker_Nodes + + classDef WAN fill:#000000,stroke:none,color#FFF + classDef KEEPALIVED fill:#CC9933,stroke:none,color#000 + classDef TRAEFIK fill:#3399CC,stroke:none,color:#FFF + classDef REDIS fill:#AA0000,stroke:none,color:#FFF + +``` + + +## Parent image +* [11notes/node:stable](https://github.com/11notes/docker-node) + +## Built with and thanks to +* [nodejs](https://nodejs.org/en) +* [Alpine Linux](https://alpinelinux.org) + +## Tips +* Only use rootless container runtime (podman, rootless docker) +* Don't bind to ports < 1024 (requires root), use NAT/reverse proxy (haproxy, traefik, nginx) +* Do not access docker.sock as root \ No newline at end of file diff --git a/amd64.dockerfile b/amd64.dockerfile new file mode 100644 index 0000000..33330d4 --- /dev/null +++ b/amd64.dockerfile @@ -0,0 +1,33 @@ +# :: Header + FROM 11notes/node:stable + ENV APP_VERSION=0.1.0 + ENV APP_ROOT=/labels + +# :: Run + USER root + + # :: prepare image + RUN set -ex; \ + mkdir -p ${APP_ROOT}; + + # :: install + RUN set -ex; \ + cd ${APP_ROOT}; \ + npm --save install \ + redis@4.6.11 \ + dockerode@4.0.0; + + # :: update image + RUN set -ex; \ + apk --no-cache upgrade; + + # :: copy root filesystem changes and set correct permissions + COPY ./rootfs / + RUN set -ex; \ + chmod +x -R /usr/local/bin; \ + chown -R 1000:1000 \ + ${APP_ROOT}; + +# :: Start + USER docker + ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] \ No newline at end of file diff --git a/rootfs/labels/app.js b/rootfs/labels/app.js new file mode 100644 index 0000000..9387385 --- /dev/null +++ b/rootfs/labels/app.js @@ -0,0 +1,74 @@ +const Docker = require('dockerode'); +const redis = require('redis'); + +class Labels{ + #docker; + #redis; + + constructor(){ + this.#docker = new Docker({socketPath: '/run/docker.sock'}); + } + + async watch(){ + this.#redis = await redis.createClient({ + url:process.env.LABELS_REDIS_URL, + pingInterval:30000, + socket:{ + rejectUnauthorized: false, + } + }); + + this.#redis.connect(); + this.#redis.on('ready', ()=>{ + this.dockerEvents(); + }); + + this.#redis.on('error', error =>{ + console.error(error); + }); + + setInterval(() => { + this.dockerPoll(); + }, parseInt(process.env.LABELS_INTERVAL)*1000); + } + + dockerEvents(){ + this.#docker.getEvents({}, (error, data) => { + data.on('data', (chunk) => { + const event = JSON.parse(chunk.toString('utf8')); + if(/Container/i.test(event?.Type) && /start|stop|restart|kill|die|destroy/i.test(event?.status)){ + this.dockerInspect(event.id, event.status); + } + }); + }); + } + + dockerPoll(){ + this.#docker.listContainers((error, containers) => { + containers.forEach(container => { + this.dockerInspect(container.Id, 'start'); + }); + }); + } + + dockerInspect(id, status = null){ + const container = this.#docker.getContainer(id); + container.inspect(async(error, data) => { + for(const label in data?.Config?.Labels){ + if(/traefik\//i.test(label)){ + switch(true){ + case /start|restart/i.test(status): + await this.#redis.set(label, data?.Config?.Labels[label], {EX:parseInt(process.env.LABELS_INTERVAL) + parseInt(process.env.LABELS_TIMEOUT)}); + break; + + case /stop|kill|die|destroy/i.test(status): + await this.#redis.del(label); + break; + } + } + } + }); + } +} + +new Labels().watch(); \ No newline at end of file diff --git a/rootfs/labels/main.js b/rootfs/labels/main.js new file mode 100644 index 0000000..14285eb --- /dev/null +++ b/rootfs/labels/main.js @@ -0,0 +1,18 @@ +process.once('SIGTERM', () => process.exit(0)); +process.once('SIGINT', () => process.exit(0)); +const { fork } = require('node:child_process'); +const child = fork(`${__dirname}/app.js`, [], { + env:{ + LABELS_REDIS_URL:process.env?.LABELS_REDIS_URL || 'redis:://localhost:6379/0', + LABELS_INTERVAL:parseInt(process.env?.LABELS_INTERVAL || 60), + LABELS_TIMEOUT:parseInt(process.env?.LABELS_TIMEOUT|| 15), + } +}); +child.on('error', (error) =>{ + console.error(error); + process.exit(1); +}); +child.on('close', (code) =>{ + console.warn(`child process closed with exit code ${code}`); + process.exit(code); +}); \ No newline at end of file diff --git a/rootfs/usr/local/bin/entrypoint.sh b/rootfs/usr/local/bin/entrypoint.sh new file mode 100644 index 0000000..f1ffb3d --- /dev/null +++ b/rootfs/usr/local/bin/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/ash + if [ -z "${1}" ]; then + set -- "node" ${APP_ROOT}/main.js + fi + + exec "$@" \ No newline at end of file