Nexus: налаштування Docker proxy repository та ContainerD в Kubernetes

Автор |  05/03/2025
 

Про запуск Nexus писав в пості Nexus: запуск в Kubernetes та налаштування PyPi caching repository, тепер до PyPi хочеться додати кешування Docker images, тим більш Docker Hub з 1-го квітня 2025 вводить нові ліміти – див. Docker Hub usage and limits (дяка @Anatolii).

Робити будемо як завжди – спочатку вручну локально на робочій машині, подивимось, як воно працює, а потім додамо конфіг для Helm-чарту, задеплоїмо в Kubernetes, і подивимось, як налаштувати ContainerD для використання цього mirror.

Запуск Sonatype Nexus локально з Docker

Створюємо локальний каталог для nexus data, аби дані зберігались при рестарті Docker, міняємо юзера, бо будемо ловити помилки типу:

...
mkdir: cannot create directory '../sonatype-work/nexus3/log': Permission denied
mkdir: cannot create directory '../sonatype-work/nexus3/tmp': Permission denied
...

Виконуємо:

$ mkdir /home/setevoy/Temp/nexus3-data/
$ sudo chown -R 200:200 /home/setevoy/Temp/nexus3-data/

Запускаємо Nexus з цією директорією, і додаємо порти для доступу до самого Nexus та для Docker registry, який створимо далі:

$ docker run  -p 8080:8081 -p 8092:8092 --name nexus3 --restart=always -v /home/setevoy/Temp/nexus3-data:/nexus-data sonatype/nexus3

Чекаємо, поки все запуститься, і отримуємо пароль для admin:

$ docker exec -ti nexus3 cat /nexus-data/admin.password
d549658c-f57a-4339-a589-1f244d4dd21b

Заходимо в браузері на http://localhost:8080, логінимось в систему:

Setup wizard можна пропустити, або швиденько проклікати “Next” і задати новий пароль адміну.

Створення Docker cache repository

Переходимо в Administration > Repository > Repositories:

Клікаємо Create repository:

Вибираємо тип docker (proxy):

Задаємо ім’я, HTTP-порт, на якому будуть прийматись конекти від docker-daemon, і дозволяємо anonymous docker pull:

Далі задаємо адресу, з якої будемо пулити образи – https://registry-1.docker.io, решту параметрів поки можна залишити без змін:

Окремо варто згадати можливість створення docker (group), де налаштовується єдиний конектор для кількох репозиторіїв в Nexus. Але мені це поки не потрібно, хоча в майбутньому – можливо.

Див. Grouping Docker Repositories та Using Nexus OSS as a proxy/cache for Docker images.

Включення Docker Security Realm

Хоча ми і не використовуємо аутентифікацію, але реалм треба включити.

Переходимо в Security > Realms, додаємо Docker Bearer Token Realm:

Перевірка Docker mirror

Знаходимо IP контейнера, в якому запущено Nexus:

$ docker inspect nexus3
...
        "NetworkSettings": {
           ..
            "Networks": {
              ...
                    "IPAddress": "172.17.0.2",
...

Порт для Docker cache в Nexus ми задавали 8092.

На робочій машині редагуємо файл /etc/docker/daemon.json, задаємо registry-mirrors та insecure-registries, бо у нас нема SSL:

{
        "insecure-registries": ["http://172.17.0.2:8092"],
        "registry-mirrors": ["http://172.17.0.2:8092"]
}

Ребутаємо локальний Docker service:

$ systemctl restart docker

Виконуємо docker info, перевіряємо, що зміни застосовані:

$ docker info
...
 Insecure Registries:
  172.17.0.2:8092
  ::1/128
  127.0.0.0/8
 Registry Mirrors:
  http://172.17.0.2:8092/

Виконуємо docker pull nginx – запит має піти через Nexus, і там зберегти копії даних:

Якщо дані не з’являються – то скоріш за все проблема з аутентифікацією.

Для перевірки до /etc/docker/daemon.json додаємо debug=true:

{
        "insecure-registries": ["http://172.17.0.2:8092"],
        "registry-mirrors": ["http://172.17.0.2:8092"],
        "debug": true
}

Рестартимо локальний Docker, виконуємо docker pull і дивимось логи с journalctl -u docker:

$ sudo journalctl -u docker --no-pager -f
...
level=debug msg="Trying to pull rabbitmq from http://172.17.0.2:8092/"
level=info msg="Attempting next endpoint for pull after error: Head \"http://172.17.0.2:8092/v2/library/rabbitmq/manifests/latest\": unauthorized: "
level=debug msg="Trying to pull rabbitmq from https://registry-1.docker.io"
...

Першого разу я забув включити Docker Bearer Token Realm.

А другого разу в мене в ~/.docker/config.json був збережений токен для https://index.docker.io, і Docker намагався використати його. В такому випадку можна просто видалити/перемістити config.json, і виконати pull ще раз.

Окей.

А що там с ContainerD? Бо в AWS Elastic Kubenretes Service у нас не Docker.

ContainerD та registry mirrors

Чесно кажучи, з containerd болі було більше, ніж з Nexus. Тут і його TOML для конфігів, і різні версії самого ContainerD та конфігурації, і deprecated параметри.

В мене просто очі болять від такого формату:

[plugins]
  [plugins.'io.containerd.cri.v1.runtime']
    [plugins.'io.containerd.cri.v1.runtime'.containerd]
      [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes]
        [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc]
          [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options]

Anyway, воно все ж запрацювало, тому поїхали.

Давайте спочатку теж зробимо локально, потім вже будемо налаштовувати в Kubernetes.

На Arch Linux встановлюємо з pacman:

$ sudo pacman -S containerd crictl

Генеруємо дефолтний конфіг для containerd:

$ sudo mkdir /etc/containerd/
$ containerd config default | sudo tee /etc/containerd/config.toml

В файл /etc/containerd/config.toml додаємо параметри для mirrors:

...
[plugins]
  [plugins.'io.containerd.cri.v1.images']
...
    [plugins.'io.containerd.cri.v1.images'.registry]
      [plugins.'io.containerd.cri.v1.images'.registry.mirrors."docker.io"]
        endpoint = ["http://172.17.0.2:8092"]
...

Тут:

  • plugins.'io.containerd.cri.v1.images: параметри для image service, управління образами
    • registry: налаштування registry для images service
      • mirrors."docker.io": дзеркала для docker.io
        • endpoint: куди звертатись, коли треба спулити образ з docker.io

Перезапускаємо containerd:

$ sudo systemctl restart containerd

Перевіряємо, що нові параметри застосовані:

$ containerd config dump | grep -A 1 mirrors
      [plugins.'io.containerd.cri.v1.images'.registry.mirrors]
        [plugins.'io.containerd.cri.v1.images'.registry.mirrors.'docker.io']
          endpoint = ['http://172.17.0.2:8092']

Виконуємо crictl pull:

$ sudo crictl pull ubuntu

І перевіряємо Nexus:

З’явився образ Ubuntu.

Тут наче все працює – давайте спробуємо це все діло налаштувати в Kubernetes.

Nexus Helm chart values та Kubernetes

В частині Додавання репозиторію в Nexus через Helm chart values трохи писав про те, як і які values додавались для запуску з Nexus Helm chart в Kubernetes для кешу PyPi.

Трохи їх оновимо:

  • додамо окремий blob store: підключимо окремий persistentVolume, бо в дефолтному лише 8 гіг, і якщо для PyPi цього більш-менш достатньо, то для Docker images буде замало
  • додамо additionalPorts: тут задаємо порт, на якому буде Docker cache
  • включимо Ingress

Всі values – values.yaml.

В мене деплоїться з Terraform під час налаштування Kubernetes-кластеру.

Все разом, з PyPi, в мене зараз виглядає так:

resource "helm_release" "nexus" {
  namespace        = "ops-nexus-ns"
  create_namespace = true

  name                = "nexus3"
  repository          = "https://stevehipwell.github.io/helm-charts/"
  #repository_username = data.aws_ecrpublic_authorization_token.token.user_name
  #repository_password = data.aws_ecrpublic_authorization_token.token.password
  chart               = "nexus3"
  version             = "5.7.2"
  # also:
  #  Environment:
  #  INSTALL4J_ADD_VM_PARAMS:          -Djava.util.prefs.userRoot=${NEXUS_DATA}/javaprefs -Xms1024m -Xmx1024m -XX:MaxDirectMemorySize=2048m
  values = [
    <<-EOT
      # use existing Kubernetes Secret with admin's password
      rootPassword:
        secret: nexus-root-password
        key: password

      # enable storage
      persistence:
        enabled: true
        storageClass: gp2-retain

      # create additional PersistentVolume to store Docker cached data
      extraVolumes:
        - name: nexus-docker-volume
          persistentVolumeClaim:
            claimName: nexus-docker-pvc

      # mount the PersistentVolume into the Nexus' Pod
      extraVolumeMounts:
        - name: nexus-docker-volume
          mountPath: /data/nexus/docker-cache

      resources:
        requests:
          cpu: 100m
          memory: 2000Mi
        limits:
          cpu: 500m
          memory: 3000Mi

      # enable to collect Nexus metrics to VictoriaMetrics/Prometheus
      metrics:
        enabled: true
        serviceMonitor:
          enabled: true

      # use dedicated ServiceAccount
      # still, EKS Pod Identity isn't working (yet?)
      serviceAccount:
        create: true
        name: nexus3
        automountToken: true

      # add additional TCP port for the Docker caching listener
      service:
        additionalPorts:
          - port: 8082
            name: docker-proxy
            containerPort: 8082
            hosts:
              - nexus-docker.ops.example.co

      # to be able to connect from Kubernetes WorkerNodes, we have to have a dedicated AWS LoadBalancer, not only Kubernetes Service with ClusterIP
      ingress:
        enabled: true
        annotations:
          alb.ingress.kubernetes.io/group.name: ops-1-30-internal-alb
          alb.ingress.kubernetes.io/target-type: ip
        ingressClassName: alb
        hosts:
        - nexus.ops.example.co

      # define the Nexus configuration
      config:
        enabled: true
        anonymous:
          enabled: true

        blobStores:

          # local EBS storage; 8 GB total default size ('persistence' config above)
          # is attached to a repository in the 'repos.pip-cache' below
          - name: default
            type: file
            path: /nexus-data/blobs/default
            softQuota:
              type: spaceRemainingQuota
              limit: 500

          # dedicated sorage for PyPi caching
          - name: PyPILocalStore
            type: file
            path: /nexus-data/blobs/pypi
            softQuota:
              type: spaceRemainingQuota
              limit: 500              

          # dedicated sorage for Docker caching
          - name: DockerCacheLocalStore
            type: file
            path: /data/nexus/docker-cache
            softQuota:
              type: spaceRemainingQuota
              limit: 500

        # enable Docker Bearer Token Realm
        realms:
          enabled: true
          values:
            - NexusAuthenticatingRealm
            - DockerToken

        # cleanup policies for Blob Storages
        # is attached to epositories below
        cleanup:
          - name: CleanupAll
            notes: "Cleanup content that hasn't been updated in 14 days downloaded in 28 days."
            format: ALL_FORMATS
            mode: delete
            criteria:
              isPrerelease:
              lastBlobUpdated: "1209600"
              lastDownloaded: "2419200"

        repos:

          - name: pip-cache
            format: pypi
            type: proxy
            online: true
            negativeCache:
              enabled: true
              timeToLive: 1440
            proxy:
              remoteUrl: https://pypi.org
              metadataMaxAge: 1440
              contentMaxAge: 1440
            httpClient:
              blocked: false
              autoBlock: true
              connection:
              retries: 0
              useTrustStore: false
            storage:
              blobStoreName: default
              strictContentTypeValidation: false
            cleanup:
              policyNames:
                - CleanupAll

          - name: docker-cache
            format: docker
            type: proxy
            online: true
            negativeCache:
              enabled: true
              timeToLive: 1440
            proxy:
              remoteUrl: https://registry-1.docker.io
              metadataMaxAge: 1440
              contentMaxAge: 1440
            httpClient:
              blocked: false
              autoBlock: true
              connection:
              retries: 0
              useTrustStore: false
            storage:
              blobStoreName: DockerCacheLocalStore
              strictContentTypeValidation: false
            cleanup:
              policyNames:
                - CleanupAll
            docker:
              v1Enabled: false
              forceBasicAuth: false
              httpPort: 8082
            dockerProxy:
              indexType: "REGISTRY"
              cacheForeignLayers: "true"
    EOT
  ]
}

Деплоїмо, відкриваємо порт:

$ kk -n ops-nexus-ns port-forward svc/nexus3 8081

Перевіряємо Realm:

Перевіряємо сам Docker repository:

Гуд.

Ingress/ALB для Nexus Docker cache

Так як ContainerD на EC2 не відноситься до Kubernetes, то і доступу до Kubernetes Service з ClusterIP в нього нема. Відповідно, він не зможе виконати pull образів з порта 8082 на nexus3.ops-nexus-ns.svc.cluster.local.

В Helm chart є можливість створити окремий Ingress, в якому задаємо всі параметри:

...
      # to be able to connect from Kubernetes WorkerNodes, we have to have a dedicated AWS LoadBalancer, not only Kubernetes Service with ClusterIP
      ingress:
        enabled: true
        annotations:
          alb.ingress.kubernetes.io/group.name: ops-1-30-internal-alb
          alb.ingress.kubernetes.io/target-type: ip
        ingressClassName: alb
        hosts:
        - nexus.ops.example.co
...

В мене використовується анотація alb.ingress.kubernetes.io/group.name для обєднання кількох Kubernetes Ingress через один AWS LoadBalancer, див. Kubernetes: єдиний AWS Load Balancer для різних Kubernetes Ingress.

Важливий нюанс тут: в параметрах Ingress не треба додавати порти і хости, які задані в Service.

Тобто, для:

...
      # add additional TCP port for the Docker caching listener
      service:
        additionalPorts:
          - port: 8082
            name: docker-proxy
            containerPort: 8082
            hosts:
              - nexus-docker.ops.example.co
...

В Helm-чарті автоматично створиться роут на Ingress:

$ kk -n ops-nexus-ns get ingress nexus3 -o yaml
...
spec:
  ingressClassName: alb
  rules:
  - host: nexus.ops.example.co
    http:
      paths:
      - backend:
          service:
            name: nexus3
            port:
              name: http
        path: /
        pathType: Prefix
  - host: nexus-docker.ops.example.co
    http:
      paths:
      - backend:
          service:
            name: nexus3
            port:
              name: docker-proxy
        path: /
        pathType: Prefix

Див. ingress.yaml.

Го далі.

Налаштування ContainerD mirror на Kubernetes WorkerNode

Вже бачили локально, в AWS EKS в принципі все теж саме.

Єдине, що локально у нас версія v2.0.3, а в AWS EKS – 1.7.25, тому формат конфігу буде трохи різний.

На AWS EKS WorkerNode/EC2 перевіряємо файл /etc/containerd/config.toml:

...

[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d:/etc/docker/certs.d"
...

Поки руками додаємо сюди нове дзеркало – тут і є трохи відмінності від того, що ми робили локально. Див. приклади в Configure Registry Credentials Example – GCR with Service Account Key Authentication.

Тобто, для containerd версії 1 – версія конфігу == 2, а для containerd версії 2 – версія конфігу == 3…? Okay, man… 

На EC2 конфіг буде виглядати так:

[plugins."io.containerd.grpc.v1.cri".registry]
  [plugins."io.containerd.grpc.v1.cri".registry.mirrors]
    [plugins."io.containerd.grpc.v1.cri".registry.mirrors."docker.io"]
      endpoint = ["http://nexus-docker.ops.example.co"]

Порт не задаємо, бо на AWS ALB це розрулиться через hostname, який зароутить запит на потрібний Listener.

Рестартимо сервіс:

[root@ip-10-0-32-218 ec2-user]# systemctl restart containerd

Виконуємо pull якогось образу:

[root@ip-10-0-46-186 ec2-user]# crictl pull nginx

Перевіряємо Nexus:

Все є.

Залишилось додати налаштування ContainerD при створенні сервері з Karpenter.

Karpenter та конфіг для ContainerD

В мене EC2NodeClass для Karpenter створюються в Terraform, див. Terraform: створення EKS, частина 3 – установка Karpenter.

Звістно, всі ці операції краще виконувати на тестовому оточенні, або створити окремий NodeClass та NodePool.

Зараз там через AWS EC2 UserData конфігуриться ~ec2-user/.ssh/authorized_keys для SSH (див. AWS: Karpenter та SSH для Kubernetes WorkerNodes), і сюди ж можемо додати створення файлу для ContainerD mirror.

В дефолтному варіанті ми бачили, що containerd буде перевіряти такі каталоги:

...
[plugins."io.containerd.grpc.v1.cri".registry] 
  config_path = "/etc/containerd/certs.d:/etc/docker/certs.d"
...

Отже, в них можемо додати новий файл.

Але пам’ятаєте, як в тому анекдоті – “А тепер забудьте все, чому вас вчили в університеті”?

Ну, от – забудьте все, що ми робили з конфігами containerd вище, бо це вже deprecated way. Тепер стільно-модно-молодьожно робити з Registry Host Namespace.

Ідея в тому, що в /etc/containerd/certs.d створюється каталог для registry, в ньому файл hosts.toml, а вже в ньому – описується налаштування registry.

В нашому випадку виглядати це буде так:

[root@ip-10-0-45-117 ec2-user]# tree /etc/containerd/certs.d/
/etc/containerd/certs.d/
└── docker.io
    └── hosts.toml

І в hosts.toml:

server = "https://docker.io"

[host."http://nexus-docker.ops.example.co"]
  capabilities = ["pull", "resolve"]

Окей. Описуємо це все діло в UserData нашого тестового EC2NodeClass.

Так тут наш “улюблений” YAML – то приведу весь конфіг, аби не мати проблем з відступами, бо трохи погемороївся:

resource "kubectl_manifest" "karpenter_node_class_test_latest" {
  yaml_body = <<-YAML
    apiVersion: karpenter.k8s.aws/v1
    kind: EC2NodeClass
    metadata:
      name: class-test-latest
    spec:
      kubelet:
        maxPods: 110
      blockDeviceMappings:
        - deviceName: /dev/xvda
          ebs:
            volumeSize: 40Gi
            volumeType: gp3
      amiSelectorTerms:
        - alias: al2@latest
      role: ${module.eks.eks_managed_node_groups["${local.env_name_short}-default"].iam_role_name}
      subnetSelectorTerms:
        - tags:
            karpenter.sh/discovery: "atlas-vpc-${var.aws_environment}-private"
      securityGroupSelectorTerms:
        - tags:
            karpenter.sh/discovery: ${var.env_name}
      tags:
        Name: ${local.env_name_short}-karpenter
        nodeclass: test
        environment: ${var.eks_environment}
        created-by: "karpenter"
        karpenter.sh/discovery: ${module.eks.cluster_name}
      userData: |
        #!/bin/bash
        set -e

        mkdir -p ~ec2-user/.ssh/
        touch ~ec2-user/.ssh/authorized_keys
        echo "${var.karpenter_nodeclass_ssh}" >> ~ec2-user/.ssh/authorized_keys
        chmod 600 ~ec2-user/.ssh/authorized_keys
        chown -R ec2-user:ec2-user ~ec2-user/.ssh/

        mkdir -p /etc/containerd/certs.d/docker.io

        cat <<EOF | tee /etc/containerd/certs.d/docker.io/hosts.toml

        server = "https://docker.io"

        [host."http://nexus-docker.ops.example.co"]
          capabilities = ["pull", "resolve"]
        EOF

        systemctl restart containerd
  YAML

  depends_on = [
    helm_release.karpenter
  ]
}

Для створення EC2 маю окремий тестовий Pod, який має tolerations і nodeAffinity (див. Kubernetes: Pods та WorkerNodes – контроль розміщення подів на нодах), через які Karpenter має створити EC2 саме з “class-test-latest” EC2NodeClass:

apiVersion: v1
kind: Pod
metadata:
  name: test-pod
spec:
  containers:
    - name: nginx
      image: nginx
      command: ['sleep', '36000']
  restartPolicy: Never
  tolerations:
    - key: "TestOnly"
      effect: "NoSchedule"  
      operator: "Exists"
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - key: component
                operator: In
                values:
                  - test

Деплоїмо зміни, деплоїмо Pod, логінимось на EC2 та перевіряємо конфіг:

[root@ip-10-0-45-117 ec2-user]# cat /etc/containerd/certs.d/docker.io/hosts.toml 

server = "https://docker.io"

[host."http://nexus-docker.ops.example.co"]
  capabilities = ["pull", "resolve"]

Ще раз виконуємо pull якого-небудь PHP:

[root@ip-10-0-43-82 ec2-user]# crictl pull php

І перевіряємо Nexus:

Готово.

Корисні посилання