diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ab932c6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.* +test/ +example.png diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e83aef4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true +[*] +end_of_line = lf +insert_final_newline = true + +[*.py] +charset = utf-8 +indent_style = space +indent_size = 4 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..659205c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-patch"] + - dependency-name: "*" + update-types: ["version-update:semver-minor"] diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..f87ecc0 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,60 @@ +name: Publish Docker image +on: + push: + branches: + - '**' + tags: + - 'v*' +jobs: + push_to_registry: + name: Build and push docker image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Docker meta + id: docker_meta + uses: docker/metadata-action@v4 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/docker-network-graph,ghcr.io/${{ github.repository_owner }}/docker-network-graph + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + - name: Checkout + uses: actions/checkout@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Cache Docker layers + if: github.event_name != 'pull_request' + uses: actions/cache@v3 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64 + push: True + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.docker_meta.outputs.labels }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache diff --git a/.gitignore b/.gitignore index fa66208..2bd3662 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ -/sandbox -/*.gv -/*.pdf +*.gv +*.pdf +*.png +*.svg +.idea diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index b7593f2..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "supermake"] - path = supermake - url = https://github.com/eagafonov/supermake diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e8e0de6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3-alpine + +WORKDIR /usr/src/app + +COPY Pipfile Pipfile.lock ./ + +RUN pip install pipenv +RUN pipenv install --system --deploy + +COPY docker-net-graph.py ./ + +ENTRYPOINT ["python", "docker-net-graph.py"] \ No newline at end of file diff --git a/Makefile b/Makefile deleted file mode 100644 index dd71c4c..0000000 --- a/Makefile +++ /dev/null @@ -1,7 +0,0 @@ -all: - -include supermake/python-sandbox.mk - -build-graph: - $(SANDBOX) python docker-net-graph.py - diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..290df6b --- /dev/null +++ b/Pipfile @@ -0,0 +1,13 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +docker = "~=3.7" +graphviz = "*" + +[dev-packages] + +[requires] +python_version = "3.9" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..54aa7f1 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,94 @@ +{ + "_meta": { + "hash": { + "sha256": "6a6221941c907af6c963dda01292c00cc04123ccc5f1cb59a556961d006b832f" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.9" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", + "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" + ], + "version": "==2021.5.30" + }, + "chardet": { + "hashes": [ + "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", + "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" + ], + "version": "==4.0.0" + }, + "docker": { + "hashes": [ + "sha256:2434b396e616a5ef682fbf80e04839a59e8b81880ece5662c33dff34b8863519", + "sha256:a062a9f82dff025f79c2097c46f49f143f8898274db7e66041f78cafee66b962" + ], + "index": "pypi", + "version": "==3.7.3" + }, + "docker-pycreds": { + "hashes": [ + "sha256:6ce3270bcaf404cc4c3e27e4b6c70d3521deae82fb508767870fdbf772d584d4", + "sha256:7266112468627868005106ec19cd0d722702d2b7d5912a28e19b826c3d37af49" + ], + "version": "==0.4.0" + }, + "graphviz": { + "hashes": [ + "sha256:3cad5517c961090dfc679df6402a57de62d97703e2880a1a46147bb0dc1639eb", + "sha256:d2d25af1c199cad567ce4806f0449cb74eb30cf451fd7597251e1da099ac6e57" + ], + "index": "pypi", + "version": "==0.16" + }, + "idna": { + "hashes": [ + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + ], + "version": "==2.10" + }, + "requests": { + "hashes": [ + "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", + "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" + ], + "version": "==2.25.1" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "version": "==1.16.0" + }, + "urllib3": { + "hashes": [ + "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", + "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" + ], + "index": "pypi", + "version": "==1.26.5" + }, + "websocket-client": { + "hashes": [ + "sha256:3e2bf58191d4619b161389a95bdce84ce9e0b24eb8107e7e590db682c2d0ca81", + "sha256:abf306dc6351dcef07f4d40453037e51cc5d9da2ef60d0fc5d0fe3bcda255372" + ], + "version": "==1.0.1" + } + }, + "develop": {} +} diff --git a/README.md b/README.md index 09ebef3..e64b817 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,55 @@ -Docker Network Graph --------------------- +# Docker Network Graph -Sample python script to draw graph of Docker networks and containers +Visualize the relationship between Docker networks and containers +as a neat graphviz graph. -Install/run -=========== +## Example +![example graph](https://raw.githubusercontent.com/LeoVerto/docker-network-graph/master/example.png) - #> git clone --recursive https://github.com/eagafonov/docker-network-graph-poc.git - #> make install-requirements - #> make build-graph +## Usage + usage: docker-net-graph.py [-h] [-v] [-o OUT] + + Visualize docker networks. + + optional arguments: + -h, --help show this help message and exit + -v, --verbose Verbose output + -o OUT, --out OUT Write output to file + +In most cases what you want to run are the following couple commands: + + git clone https://github.com/LeoVerto/docker-network-graph.git + cd docker-network-graph + pipenv install + pipenv run python docker-net-graph.py -o output.svg + +This will generate an .svg file containing the graph. + +## Running inside docker +If you want to generate a graph for a remote system you can also easily +run this script inside a pre-built docker container: + + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock leoverto/docker-network-graph + +This will just generate and output the graph in the [DOT Language][dot]. +You can then paste that code into [GraphvizOnline][gvonline] +to render it. The recommended rendering engine is `fdp`. + +Alternatively, if you prefer to render locally, you can run +`fdp -Tpng -o out.png` on a system with graphviz installed, +paste the previous output there, press enter and finally CTRL+C to +generate the file. + + +For more advanced use cases you can append arguments to the `docker run` +command as if you were running it in a local shell. + +[dot]: https://www.graphviz.org/doc/info/lang.html +[gvonline]: https://dreampuf.github.io/GraphvizOnline/ + +## Development +If you'd like to contribute to this project, there is a sample docker-compose file +using dummy containers in `test`. + +You can deploy it using `docker-compose -f test/docker-compose.yml up -d`. diff --git a/docker-net-graph.py b/docker-net-graph.py old mode 100644 new mode 100755 index 0e783a0..bd00804 --- a/docker-net-graph.py +++ b/docker-net-graph.py @@ -1,85 +1,211 @@ -from docker import Client +#!/usr/bin/python3 import os -import pprint -import json - +import argparse +import random +import docker +import typing +from dataclasses import dataclass from graphviz import Graph +from graphviz.backend import FORMATS + +# colorlover.scales["12"]["qual"]["Paired"] converted to hex strings +COLORS = ["#1f78b4", "#33a02c", "#e31a1c", "#ff7f00", "#6a3d9a", "#b15928", "#a6cee3", "#b2df8a", "#fdbf6f", + "#cab2d6", "#ffff99"] +i = 0 + + +@dataclass +class Network: + name: str + gateway: str + internal: bool + isolated: bool + color: str + + +@dataclass +class Interface: + endpoint_id: str + address: str + aliases: typing.List[str] + + +@dataclass +class Container: + container_id: str + name: str + interfaces: typing.List[Interface] + + +@dataclass +class Link: + container_id: str + endpoint_id: str + network_name: str + + +def get_unique_color() -> str: + global i + + if i < len(COLORS): + c = COLORS[i] + i += 1 + else: + # Generate random color if we've already used the 12 preset ones + c = "#%06x".format(random.randint(0, 0xFFFFFF)) + + return c + + +def get_networks(client: docker.DockerClient, verbose: bool) -> typing.Dict[str, Network]: + networks: typing.Dict[str, Network] = {} + + for net in sorted(client.networks.list(), key=lambda k: k.name): + try: + gateway = net.attrs["IPAM"]["Config"][0]["Subnet"] + except (KeyError, IndexError): + # This network doesn't seem to be used, skip it + continue + + internal = False + try: + if net.attrs["Internal"]: + internal = True + except KeyError: + pass + + isolated = False + try: + if net.attrs["Options"]["com.docker.network.bridge.enable_icc"] == "false": + isolated = True + except KeyError: + pass + + if verbose: + print(f"Network: {net.name} {'internal' if internal else ''} {'isolated' if isolated else ''} gw:{gateway}") + + color = get_unique_color() + networks[net.name] = Network(net.name, gateway, internal, isolated, color) + + networks["host"] = Network("host", "0.0.0.0", False, False, "#808080") + + return networks + + +def get_containers(client: docker.DockerClient, verbose: bool) -> (typing.List[Container], typing.List[Link]): + containers: typing.List[Container] = [] + links: typing.List[Link] = [] + + for container in client.containers.list(): + interfaces: typing.List[Interface] = [] + + # Iterate over container interfaces + for net_name, net_info in container.attrs["NetworkSettings"]["Networks"].items(): + endpoint_id = net_info["EndpointID"] -dot = Graph(comment='Docker Network Graph', - graph_attr=dict( rankdir="TB", packmode='graph', pack='true') - ) + aliases = [] + if net_info["Aliases"]: + for alias in net_info["Aliases"]: + # The aliases always contain the shortened container id and container name + if alias != container.id[:12] and alias != container.name: + aliases.append(alias) -docker_client = Client(os.environ.get("DOCKER_HOST", "unix:///var/run/docker.sock")) + interfaces.append(Interface(endpoint_id, net_info['IPAddress'], aliases)) + links.append(Link(container.id, endpoint_id, net_name)) -def dump_json(obj): - print json.dumps(obj, indent=4) + if verbose: + print(f"Container: {container.name} {''.join([iface.address for iface in interfaces])}") -for c in sorted(docker_client.containers()): - name = c['Names'][0] - container_id = c['Id'] - - node_id = 'container_%s' % container_id - + containers.append(Container(container.id, container.name, interfaces)) + + return containers, links + + +def draw_network(g: Graph, net: Network): + label = f"{{ {net.gateway} | {net.name}" + if net.internal: + label += " | Internal" + if net.isolated: + label += " | Containers isolated" + label += "}" + + g.node(f"network_{net.name}", + shape="record", + label=label, + fillcolor=net.color, + style="filled" + ) + + +def draw_container(g: Graph, c: Container): iface_labels = [] - - for net_name, net_info in c['NetworkSettings']['Networks'].iteritems(): - label_iface = "<%s> %s" % (net_info['EndpointID'], net_info['IPAddress']) - - iface_labels.append(label_iface) - - print '|'.join(iface_labels) - - - dot.node(node_id, - shape='record', - label="{ %s | { %s } }" % (name, '|'.join(iface_labels)), - fillcolor='#ff9999', - style='filled') - - - -for net in sorted(docker_client.networks()): - #print "Net" - net_name = net['Name'] - - #subnet = net['IPAM']['Config']['Subnet'] - - #dump_json(net) - - try: - gateway = net['IPAM']['Config'][0]['Gateway'] - except IndexError: - gateway = None - - try: - subnet = net['IPAM']['Config'][0]['Subnet'] - except IndexError: - subnet = None - - print "Network: %s %s gw:%s" % ( net_name, subnet,gateway) - - net_node_id = "net_%s" % (net_name,) - - net_label_html = '
'.join([s for s in ['network', net_name, subnet, gateway] if s != None]) - - dot.node(net_node_id, - shape='record', - label="{ %s| %s }" % (gateway, net_name), - fillcolor='#99ff99', - style='filled') - - - for container_id, container in sorted(net['Containers'].iteritems()): - dump_json(container) - print " * ", container['Name'], container['IPv4Address'], container['IPv6Address'] - - container_node_id = 'container_%s' % container_id - - container_iface_ref = "%s:%s" % (container_node_id, container['EndpointID']) - - dot.edge(container_iface_ref, net_node_id+":gw_iface") - -print dot.source -dot.render('dng.gv') - + + for iface in c.interfaces: + iface_label = "{" + + for alias in iface.aliases: + iface_label += f" {alias} |" + + iface_label += f"<{iface.endpoint_id}> {iface.address} }}" + iface_labels.append(iface_label) + + label = f"{{ {c.name} | {{ {' | '.join(iface_labels)} }} }}" + + g.node(f"container_{c.container_id}", + shape="record", + label=label, + fillcolor="#ff9999", + style="filled" + ) + + +def draw_link(g: Graph, networks: typing.Dict[str, Network], link: Link): + g.edge(f"container_{link.container_id}:{link.endpoint_id}", + f"network_{link.network_name}", + color=networks[link.network_name].color + ) + + +def generate_graph(verbose: bool, file: str): + docker_client = docker.from_env() + + networks = get_networks(docker_client, verbose) + containers, links = get_containers(docker_client, verbose) + + if file: + base, ext = os.path.splitext(file) + g = Graph(comment="Docker Network Graph", engine="sfdp", format=ext[1:], graph_attr=dict(splines="true")) + else: + g = Graph(comment="Docker Network Graph", engine="sfdp", graph_attr=dict(splines="true")) + + for _, network in networks.items(): + draw_network(g, network) + + for container in containers: + draw_container(g, container) + + for link in links: + if link.network_name != "none": + draw_link(g, networks, link) + + if file: + g.render(base) + else: + print(g.source) + + +def graphviz_output_file(filename: str): + ext = os.path.splitext(filename)[1][1:] + if ext.lower() not in FORMATS: + raise argparse.ArgumentTypeError("Must be valid graphviz output format") + return filename + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Visualize docker networks.") + parser.add_argument("-v", "--verbose", help="Verbose output", action="store_true") + parser.add_argument("-o", "--out", help="Write output to file", type=graphviz_output_file) + args = parser.parse_args() + + generate_graph(args.verbose, args.out) diff --git a/example.png b/example.png new file mode 100644 index 0000000..9cc05bf Binary files /dev/null and b/example.png differ diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 560d405..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,2 +0,0 @@ -docker-py -graphviz diff --git a/supermake b/supermake deleted file mode 160000 index bfaf407..0000000 --- a/supermake +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfaf407946ae2a33e726b109cebf72d4e8de3215 diff --git a/test/docker-compose.yml b/test/docker-compose.yml new file mode 100644 index 0000000..e149f32 --- /dev/null +++ b/test/docker-compose.yml @@ -0,0 +1,47 @@ +version: '2.4' + +services: + service_1: + container_name: service_1 + image: leoverto/dummy-image + networks: + - network_a + - network_b + service_2: + container_name: service_2 + image: leoverto/dummy-image + networks: + network_b: + network_c: + aliases: + - "s2.netc" + service_3: + container_name: service_3 + image: leoverto/dummy-image + networks: + - network_a + - network_b + - network_c + - no_gateway + host_service: + container_name: host_service + image: leoverto/dummy-image + network_mode: host + isolated_service: + container_name: isolated_service + image: leoverto/dummy-image + network_mode: none + +networks: + network_a: + network_b: + network_c: + empty_network: + no_gateway: + driver: bridge + driver_opts: + com.docker.network.bridge.name: br-no-gateway + ipam: + driver: default + config: + - subnet: 172.22.1.0/24