header svg

Von der ersten Code-Zeile bis zum Betrieb auf Kubernetes – ein Abriss

30.03.2022

Dominik

Bittl

DevOps Engineer und K8s-Trainer

Für den Betrieb großer Applikationslandschaften hat sich Kubernetes über Jahre hinweg fest in der Industrie etabliert und ermöglicht Anwendern eine effiziente Nutzung von Ressourcen aus der Cloud. Doch wie sieht das konkret in der Praxis aus? Wie soll man beginnen? Ziel des Artikels ist, dass Sie einen eigenen Container mit einem Golang-Webservice bauen, den entstandenen Code in Git versionieren und diesen in Kubernetes ausrollen und das alles soll rudimentär automatisiert sein.

Sie benötigen:

Erstellung unseres Webservices

Der Webservice

Als erstes erstellen Sie einen kleinen ″Hello World″-Webservice, welcher nicht nur die Welt grüßen kann, sondern auch beliebige weitere Dinge, die der User in Form eines URL-Path-Parameters in seinem HTTP-Request mitgeben kann. Das kann zum Beispiel ″Dominik″ sein. Es wird dann ″Hello, Dominik!″ als Response ausgegeben. Da- bei nutzen Sie nur die von Go mitgelieferten Standard-Librarys ″http″ und ″fmt″.

package main

import (
″fmt″″net/http″
)

func main() {
http.HandleFunc(″/″, HelloServer) http.ListenAndServe(″:8080″, nil)
}

func HelloServer(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, ″Hello, %s!″, r.URL.Path[1:])
}#
Sourcecode-Verwaltung mit GitHub

Falls Sie noch kein Login auf GitHub besitzen, registrieren Sie sich auf der Webseite github.com und laden sich den Client herunter (https://cli.github.com/). Sie können alle Schritte aus diesem Tutorial auch direkt auf der GitHub-Webseite durchführen – wir empfinden es aber auf einer Linux-Konsole als angenehmer.

Im ersten Schritt richten wir die GitHub CLI mit dem Befehl ″~ gh auth login″ ein:

~ gh auth login
  • Folgen Sie nun den Anweisungen

Nach erfolgreicher Anmeldung ist die Git-Hub CLI mit Ihrem Konto verknüpft und kann verwendet werden.

Legen Sie jetzt mit dem Befehl

~ gh re- po  create from-code-to-k8s-deployment --private

ein neues Repository mit dem Namen ″from-code-to-k8s-deployment″ an.

Richten Sie nun mit ″~ git init″ Ihr lokales Git-Repository in Ihrem Arbeitsverzeichnis ein.

~ #setzen des systemweiten Default-Branch-Namens auf 'main'
~ git config --global init.defaultBranch main
~ #Initialisierung des git repos
~ git init
~ #hinzufuegen der README.md zum lokalen repo
~ git add README.md
~ #Ausfuehren des resten commits
~ git commit -m "add README.md"
~ # Einbindung des bereits erzeugent remote repos von github.com
~ git remote add origin git@github.com:x-cellent/from-code-to-k8s-deployment.git
~ # Erster push in das main repo
~ git push -u origin main

Jetzt schieben Sie den Go-Code des Webservices in den Ordner ″code″. Er wird committet und in das Remote-Repository gepushet. Das Git-Log sieht nun so aus:

~ git log

commit 62961146f90bbd1a18d8b20710c49a2d6da8837b (HEAD -> main, origin/main)
Author: Dominik Bittl <dominik.bittl@gmail.com>
Date:   Tue Feb 8 19:46:06 2022 +0100

    add code folder

commit a1abb6515dc89b9b36d9aa4d5d6e12506c3e0797
Author: Dominik Bittl <dominik.bittl@gmail.com>
Date:   Tue Feb 8 19:43:28 2022 +0100

    add *

commit 9650658b49662a2f5f392f880fb146d1e1cefddb
Author: Dominik Bittl <dominik.bittl@gmail.com>
Date:   Tue Feb 8 19:36:02 2022 +0100

    add README.md

Die Sourcecode-Verwaltung ist nun eingerichtet, der Code des Webservices ist im Repo erstellt und Sie sind bereit das Thema ″Container″ zu beleuchten und die Containerisierung des Webservices anzugehen.

Bauen des Container-Images inklusive des Webservices

Sie Containerisieren jetzt Ihren Webservice. Ein besonderes Augenmerk liegt dabei darauf, ein möglichst kleines Docker Image zu bauen, daher verwenden Sie in diesem Beispiel als Basis-Image ″distroless″ von Google.

Dazu legen Sie ein File namens ″Dockerfile″ an und kopieren das Dockerfile-Beispiel von der distroless-Dokumentation https://github.com/GoogleContainerTools/distroless und passen es leicht an Ihre Bedürfnisse an.

Erstellung Dockerfile

~ cat Dockerfile

# Start by building the application.
FROM golang:1.17-bullseye as build

WORKDIR /go/src/app
ADD code /go/src/app

RUN go get -d -v ./...

RUN go build -o /go/bin/app

# Now copy it into our base image.
FROM gcr.io/distroless/base-debian11
COPY --from=build /go/bin/app /
CMD ["/app"]

# Start by building the application. FROM golang:1.17-bullseye as build

WORKDIR /go/src/app ADD code /go/src/app

RUN go get -d -v ./...

RUN go build -o /go/bin/app

# Now copy it into our base image. FROM gcr.io/distroless/base-debian11 COPY --from=build /go/bin/app /
CMD [″/app″]

Exkurs: Container

Herausforderungen der Containerisierung

Im Rahmen der Containerisierung werden bestehende Anwendungen modularisiert, in Container verpackt und neue Anwendungen, agil als Cloud-Native-Apps oder in Form entkoppelter Microservices, erstellt. Das vereinfacht die Arbeit für Betriebsteams, da die Container unabhängig von ihrem Inhalt mit einer standardisierten Schnittstelle verarbeitet werden können. Nur stellt sich unweigerlich die Frage, wie Sie die wachsende Menge an Containern nachvollziehbar und mit vertretbarem Aufwand betreiben sollen. Nur mit Docker und einem Automatisierungstool wie "Ansible", stoßen Sie schnell an die Grenzen des Machbaren.

Container-Orchestrierung

Mit Docker wird der Lebenszyklus einzelner Container verwaltet. Orchestrierungs-Werkzeuge (wie z.B. Kubernetes) ermöglichen das Management komplexer Multi-Container-Workloads, die verteilt in einem Cluster aus vielen Maschinen betrieben werden. Container-Orchestratoren sind so etwas wie das "Betriebssystem" eines Rechenzentrums, das für die Abstraktion der einzelnen Server und die einheitliche Bereitstellung von CPU-Rechenkapazität, RAM-Speicher, Plattenspeicher und Netzwerk sorgt.

Durch die Abstraktion der Host-Infrastruktur ermöglichen die Orchestrierungs-Werkzeuge, sich von dem individuellen Management und Rollouts auf einzelne Hosts zu verabschieden und die gesamten Cluster als ein einheitliches Deployment-Ziel zu betrachten. Eine Konsequenz davon ist, dass sich der Betrieb damit grundsätzlich wandelt. Diese Denkweise schlägt sich auch in der Anwendungsentwicklung nieder: Statt zu versuchen, einzelne Service-Instanzen auf möglichst leistungsfähigen Maschinen immer verfügbar zu halten, liegt der Fokus nun darauf, Hochverfügbarkeit und Skalierung durch viele verteilte, idealerweise zustandslose, Service-Instanzen auf abstrakten Compute-Ressourcen zu erreichen.

Bauen des Images

Jetzt führen Sie den Image Build aus: Zuerst müssen Sie aber noch die Go-Modules initialisieren, ansonsten schlägt das Kompilieren des Go-Codes fehl:

~ # Wechseln in das code-Verzeichnis
~ cd code
~ #go modules initialisieren
~ go mod init modules
~ # Zurueck wechseln in den Projekt-Root-Ordner
~ cd ..
~ # Ausfuehren des docker image builds
~ docker build -t webservice .
~ # Anschauen des Ergebnisses mit
~ docker image ls
REPOSITORY                        TAG             IMAGE ID       CREATED          SIZE
webservice                        latest          47bf5fd0580d   9 seconds ago    26.4MB
<none>                            <none>          48254b166a91   16 seconds ago   947MB
golang                            1.17-bullseye   80d9a75ccb38   12 days ago      941MB
gcr.io/distroless/base-debian11   latest          24787c1cd2e4   52 years ago     20.2MB

Das neu gebaute Image ist mit etwa 26 MB recht klein und sorgt somit später für eine schnelle Skalierung und geringen Ressourcenbedarf in Ihrem Kubernetes-Cluster.

Um Ihr Webservice-Image lokal zu testen, können Sie den Container schon einmal mit ″~ docker run -p 80:8080 welcome″ starten.

~ # wie exposen den Port des Containers '8080' auf den Host-Port '80'
~ docker run -p 80:8080

Wenn Sie den Webservice im Browser mit

http://localhost:80″ aufrufen, sehen Sie die Webseite.


Docker-Image-Test-Screenshot

Damit haben Sie den ersten Abschnitt gemeistert. Jetzt geht es weiter, indem Sie das Image in Ihre Docker-Registry hochladen.

Upload des Images in die Docker-Registry

Als erstes konfigurieren Sie, falls noch nicht geschehen, die AWS CLI, da wir für unser Beispiel eine private AWS-Registry verwenden möchten. Falls nötig, sehen Sie sich dazu folgenden Dokumentation an: https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-welcome.html

Wichtiger Hinweis: Um das Tutorial nachzubauen, benötigen Sie AWS-Ressourcen, die Geld kosten – Es ist leider nicht möglich das kostenlos zu machen.

Die Erstellung der privaten Registry geht mit ″~ aws ecr create-repository″

~ # ich arbeite aktuell noch mit der aws-cli v1
~ aws ecr create-repository  --repository-name webservice --image-scanning-configuration scanOnPush=true

Pushen kann man das von Ihnen erstellte Image dann mit ″~ docker push″

~ # Holen des Anmeldetokens
~ aws ecr get-login-password --region eu-central-1 | docker login --username AWS --password-stdin 1471238641122.dkr.ecr.eu-central-1.amazonaws.com
~ # Bauen des Images (zur vollstaendigkeit nochmals gemacht)
~ docker build -t webservice .
~ # Nun vergeben wir einen Versions-Tag hier 'latest'
~ docker tag webservice:latest 1471238641122.dkr.ecr.eu-central-1.amazonaws.com/webservice:latest
~ # Und jetzt wird das Image hochgeladen in die Registry
~ docker push 1471238641122.dkr.ecr.eu-central-1.amazonaws.com/webservice:latest

Das Image ist nun hochgeladen und kann im nächsten Schritt verwendet werden.

~ docker push 1471238641122.dkr.ecr.eu-central-1.amazonaws.com/webservice:latest

The push refers to repository [1471238641122.dkr.ecr.eu-central-1.amazonaws.com/webservice]
eb642fdcf599: Pushed
0b3d0512394d: Pushed
5b1fa8e3e100: Pushed
latest: digest: sha256:060c8cec4ee49d6dc6ae6c4ca7cb7a8c15990f1ecb00172dd6b06abfcccd0d21 size: 949
Erstellung der ersten CI/CD Pipelines

Kurzer Recap: Sie haben den Webservice programmiert, das Image erstellt, ein Image Repository angelegt und das Image gepusht. Nun wird es Zeit alles zusammen zu führen und zu automatisieren: Dazu erstellen Sie im ersten Schritt eine ″IAM Policy″, um diese mit einem neuen AWS User ′github-action′ zu verknüpfen. Dieser wird dann in Ihren GitHub Actions verwendet.

Ihre IAM Policy liegt unter ′./aws/iam/ecr- policy.json′:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ecr:GetAuthorizationToken",
                "ecr:BatchCheckLayerAvailability",
                "ecr:GetDownloadUrlForLayer",
                "ecr:GetRepositoryPolicy",
                "ecr:DescribeRepositories",
                "ecr:ListImages",
                "ecr:DescribeImages",
                "ecr:BatchGetImage",
                "ecr:GetLifecyclePolicy",
                "ecr:GetLifecyclePolicyPreview",
                "ecr:ListTagsForResource",
                "ecr:DescribeImageScanFindings",
                "ecr:InitiateLayerUpload",
                "ecr:UploadLayerPart",
                "ecr:CompleteLayerUpload",
                "ecr:PutImage"
            ],
            "Resource": "webservice"
        }
    ]
}

Angelegt wird diese mit ″~ aws iam create″.

~ aws iam create-policy --policy-name ecr-policy --policy-document file://aws/iam/ecr-policy.json

Das Erstellen des neuen Users erledigen wir mit ″aws iam create user″

~ aws iam create-user --user-name github-actions
{
    "User": {
        "Path": "/",
        "UserName": "github-actions",
        "UserId": "AIDARE5NEGHMGXET3WZ33",
        "Arn": "arn:aws:iam::1471238641122:user/github-actions",
        "CreateDate": "2022-02-09T14:05:10Z"
    }
}

und verknüpfen die Policy mit dem User: ″aws iam attach-user-policy″

~ # Die policy-arn kopieren von der Ausgabe zuvor
~ aws iam attach-user-policy --policy-arn arn:aws:iam::1471238641122:policy/ecr-policy --user-name github-actions

Automatisches Bauen des Containers mit Hilfe von GitHub-Actions

Damit die GitHub-Actions-Pipeline Zugriff auf die private AWS Registry bekommt, erstellen wir im Folgenden die User Credentials und hinterlegen diese als CI Secret in GitHub.

Generieren des AWS-Access-Keys für ″github-actions″

~ aws iam create-access-key
{
    "AccessKey": {
        "UserName": "github-actions",
        "AccessKeyId": "AKIXXXXXXXXXXXXXXXXPU",
        "Status": "Active",
        "SecretAccessKey": "HD9K7FXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXEjfR",
        "CreateDate": "2022-02-10T13:26:47Z"
    }
}

Außerdem ″AWS Account ID″

~ aws sts get-caller-identity --query "Account"
1471238641122

Erstellen Sie die Secrets im GitHub Repository mit folgendem Befehl ″~ gh secret set″ und kopieren Sie die entsprechenden Werte aus der letzten Ausgabe nach Auf- forderung hinein:

~ gh secret set AccessKeyId -r x-cellent/from-code-to-k8s-deployment
? Paste your secret ********************

✓ Set secret AccessKeyId for x-cellent/from-code-to-k8s-deployment

~ gh secret set SecretAccessKey  -r x-cellent/from-code-to-k8s-deployment
? Paste your secret ****************************************

✓ Set secret SecretAccessKey for x-cellent/from-code-to-k8s-deployment

~ gh secret set AWSACCOUNTID  -r x-cellent/from-code-to-k8s-deployment
? Paste your secret ************

✓ Set secret AWSACCOUNTID for x-cellent/from-code-to-k8s-deployment

Nun können Sie die GitHub Action erstellen. Was soll diese können?

  • Automatisch starten, wenn sich Änderungen auf dem main-Branch ergeben
  • Das Image neu bauen
  • Das neue Image in die AWS Registry hochladen

    name: create-and-push-image on: [push] jobs: build-and-push: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: kciter/aws-ecr-action@v3 with: access_key_id: ${{ secrets.ACCESSKEYID }} secret_access_key: ${{ secrets.SECRETACCESSKEY }} account_id: ${{ secrets.AWSACCOUNTID }} repo: webservice region: eu-central-1 tags: latest,${{ github.sha }}

Sie nutzen dabei eine GitHub Action aus dem Marketplace, die Sie hier finden: https://github.com/kciter/aws-ecr-action

Gespeichert wird das yaml-File in Ihrem Entwicklungsordner unter ′.github/work- flows/create-and-push-image.yml′.

Das Ganze wird dann, wie gewohnt mit 'git commit' und 'git push', in Ihr Github Repository gepusht und schon läuft der Workflow los, baut und published das Image.

Exkurs: Kubernetes

Containerisierung erfordert, wie oben bereits erläutert, auch Automatisierung im Betrieb. Kubernetes ist der de facto Standard in diesem Bereich - bringt aber auch eigene Komplexität, sowie eine steile Lernkurve mit sich und erfordert Veränderungen in Anwendungsarchitektur und Betrieb.

Kubernetes

Kubernetes ist eine Open Source Plattform, mit der containerisierte Anwendungen bereitgestellt, verwaltet, überwacht und automatisch skaliert werden können. Es abstrahiert die zugrunde liegende Infrastruktur und stellt eine API für deklaratives Management von Container-Workloads bereit - und vereinfacht dadurch die Entwicklung, Bereitstellung, und Management containerbasierter Anwendungen.

Architektur

Ein Kubernetes-Cluster besteht aus mehreren physischen oder virtuellen Maschinen (Nodes), die in zwei Typen unterteilt werden: Master-Nodes, auf welchen sich die Kubernetes-Steuerungsebene befindet und Worker-Nodes, auf denen "Pods" ausgeführt werden. "Pods" sind die kleinste auf den Worker-Nodes einsetzbare Einheit und enthalten einen oder auch mehrere Container.


kubernetes-architektur-simpel

Einrichtung eines managed Kubernetes-Clusters auf AWS

Mit dem installierten ″aws eksctl″ erzeugen Sie jetzt einen managed-Kubernetes-Cluster auf AWS. Diesen nutzen Sie dann für Ihren Webservice.

Setzen von Variablen:

export CLUSTERNAME="mein-cluster"
ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text)


~ # Der Befehl erzeugt ein Cluster mit dem Namen 'mein-cluster' in der Region Frankfurt im Mode 'fargate', so dass wir keine Nodes managen muessen.
~ eksctl create cluster --name $CLUSTERNAME --region eu-central-1 --fargate

Testen des Zugriffs auf den Cluster:

~ kubectl get nodes
NAME                                                       STATUS   ROLES    AGE   VERSION
fargate-ip-192-168-130-109.eu-central-1.compute.internal   Ready    <none>   1m   v1.21.2-eks-06eac09
fargate-ip-192-168-161-58.eu-central-1.compute.internal    Ready    <none>   1m   v1.21.2-eks-06eac09

Jetzt müssen Sie noch einige AWS-spezifische Anpassungen am Cluster vornehmen, damit die Loadbalancer-Integration und somit das Publishen des Webservices sauber funktioniert:

~ # Damit der Cluster AWS Identity and Access Management (IAM) für Servicekonten verwenden kann
~ eksctl utils associate-iam-oidc-provider --cluster $CLUSTERNAME --approve
~ # Einrichten der IAM Policy fuer den AWS Loadbalancer Controller
~ curl -o iam_policy.json https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.2.0/docs/install/iam_policy.json
~ aws iam create-policy --policy-name AWSLoadBalancerControllerIAMPolicy --policy-document file://iam_policy.json
~ # Um ein Servicekonto mit dem Namen aws-load-balancer-controller im kube-system-Namensraum für den AWS Load Balancer Controller zu erstellen (arn mit der richtigen account-id anpassen nicht vergessen)
~ eksctl create iamserviceaccount \
  --cluster=$CLUSTERNAME \
  --namespace=kube-system \
  --name=aws-load-balancer-controller \
  --attach-policy-arn=arn:aws:iam::$ACCOUNT_ID:policy/AWSLoadBalancerControllerIAMPolicy \
  --override-existing-serviceaccounts \
  --approve

Als nächstes fügen Sie das entsprechende Installations-Repo hinzu:

~ # Damit der Cluster AWS Identity and Access Management (IAM) für Servicekonten verwenden kann
~ eksctl utils associate-iam-oidc-provider --cluster $CLUSTERNAME --approve
~ # Einrichten der IAM Policy fuer den AWS Loadbalancer Controller
~ curl -o iam_policy.json https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.2.0/docs/install/iam_policy.json
~ aws iam create-policy --policy-name AWSLoadBalancerControllerIAMPolicy --policy-document file://iam_policy.json
~ # Um ein Servicekonto mit dem Namen aws-load-balancer-controller im kube-system-Namensraum für den AWS Load Balancer Controller zu erstellen (arn mit der richtigen account-id anpassen nicht vergessen)
~ eksctl create iamserviceaccount \
  --cluster=$CLUSTERNAME \
  --namespace=kube-system \
  --name=aws-load-balancer-controller \
  --attach-policy-arn=arn:aws:iam::$ACCOUNT_ID:policy/AWSLoadBalancerControllerIAMPolicy \
  --override-existing-serviceaccounts \
  --approve

und installieren den ″AWS Load Balancer Controller″:

~ # Helm Repo hinzufuegen
~ helm repo add eks https://aws.github.io/eks-charts
~ # Installation der TargetGroupBIondings
~ kubectl apply -k "github.com/aws/eks-charts/stable/aws-load-balancer-controller//crds?ref=master"
~ # Ermitteln der VPC-ID der vorher erstellten EKS-Umgebung
~ VPC=$(aws cloudformation describe-stacks --query 'Stacks[?StackName==`eksctl-mein-cluster-cluster`][].Outputs[?OutputKey==`VPC`].OutputValue' --output text)
~ # Installieren des Controllers
~ helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
    --set clusterName=$CLUSTERNAME \
    --set serviceAccount.create=false \
    --set region=eu-central-1 \
    --set vpcId=$VPC \
    --set serviceAccount.name=aws-load-balancer-controller \
    -n kube-system

NAME: aws-load-balancer-controller
LAST DEPLOYED: Thu Feb 17 00:42:13 2022
NAMESPACE: kube-system
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
AWS Load Balancer controller installed!
Erstellung eines Helm-Charts für das Deployment des Webservices

Im Ordner ″deploy″ bauen Sie mit Hilfe von kustomize (https://kustomize.io/) einen Helm-Chart:

# Mit "kustomize build" kann man das komplette Helm-Chart ausgeben
~ cd deploy
~ kustomize build
apiVersion: v1
kind: Service
metadata:
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
    service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
    service.beta.kubernetes.io/aws-load-balancer-type: external
  labels:
    app: webservice
  name: webservice
spec:
  ports:
  - port: 80
    protocol: TCP
    targetPort: 8080
  selector:
    app: webservice
  type: LoadBalancer
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: webservice
  name: webservice-deployment
spec:
  replicas: 3
  selector:
    matchLabels:
      app: webservice
  template:
    metadata:
      labels:
        app: webservice
    spec:
      containers:
      - image: 1471238641122.dkr.ecr.eu-central-1.amazonaws.com/webservice:latest
        name: webservice-container
        ports:
        - containerPort: 8080
        resources:
          limits:
            cpu: "0.5"
            memory: 200Mi
          requests:
            cpu: 100m
            memory: 100Mi

Dieses kann nun mit folgenden Befehlen auf dem Cluster installiert werden ″kubectl apply -k″:

kubectl apply -k .

Der Cluster zieht sich nun Ihr Docker-Image aus Ihrer Docker-Registry, legt den Loadbalancer an exponiert den Webservice im Internet.

kubectl get services

NAME         TYPE           CLUSTER-IP       EXTERNAL-IP                                                                       PORT(S)        AGE
kubernetes   ClusterIP      10.100.0.1       <none>                                                                            443/TCP        17m
webservice   LoadBalancer   10.100.253.113   k8s-default-webservi-1a6d153cab-e576e43b4b4351aa.elb.eu-central-1.amazonaws.com   80:30000/TCP   7s

Nach einer kurzen Wartezeit kann der Webservice über den automatisch generierten DNS-Namen im Browser aufgerufen werden.


Aufruf_des_deployden_WebServices

Wichtig: Es entstehen Kosten bei AWS, wenn Sie dieses Beispiel nachbauen. Daher sollten Sie darauf achten, die angelegten Ressourcen wieder zu löschen. Dies können Sie mit den folgenden Befehlen tun:

~ eksctl de- lete cluster –name=$CLUSTERNAME

Und danach prüfen Sie bitte nochmals, ob wirklich alles gelöscht wurde.

Sicher ist sicher!

footer svgfooter svg