Выполняю тестовое задание для DevOps Cloud.ru Camp 2025

Я тут недавно пытался попасть на бесплатные курсы от Aston, но получил отказ с такой формулировкой.
Денис, добрый день!
После изучения вашей анкеты, к сожалению, мы не готовы пригласить вас к дальнейшему рассмотрению на обучение в компании. У нас очень большой поток кандидатов на курсы. В данный момент взяли в рассмотрение участников с показателями выше. Ваша анкета будет сохранена, возможно, вернемся к вашей кандидатуре, при наборе на следующий поток.
Благодарим за внимание к нашей компании.
Хотя я даже не понял, как смогли оценить мои показатели и в чем они измерялись, если не давали ни тестового задания, ни собеседования. В анкете у них несколько вопросов и уровень образования. Но да ладно, это тема отдельной статьи.
Сегодня хочу попробовать пройти тестовое задание от клауд.ру. Оно охватывает тот самый стек для devops, linux, docker, git, kubernetes.
На первый взгляд, задание кажется не таким уж и сложным, однако, дьявол кроется в деталях и есть неоднозначное право выбора, которое будет зависеть от ситуации.
Тестовое задание состоит из 4 задач, начнем с первой.
Web приложение на Python/Golang
Цель
Написать простейший echo-server на Python или Go
Задача
Требуется написать простое веб-приложение на Python/Go, которое слушает входящие соединения на порту 8000 и предоставляет страницу с информацией:
имя хоста
ip адрес хоста
имя автора, которое передаётся через переменную окружения $AUTHOR
Для приложения написать Dockerfile и запушить образ в докер хаб* (запушить в приватный регистри - пройти регистрацию в докер хаб, сделать регистри приватным).
В ответе к задаче приложить:
исходный код
Dockerfile
команды, которые использовались для пуша образа в регистри
Полученный артефакты положить в папку /01-application
На первом пункте уже кроется подвох. Python или Go , здесь нужно знать контекст, потому что при грамотной сборке на го, образ получится около 2 мб, вместо около 20 на пайтоне. С другой стороны, на питоне докерфайл писать легче, потому как мультистэйдж сборка здесь не прокатит.
Для чистоты эксперимента, я сделаю 2 варианта, начну с го.
Создаем папку 01-application, в ней 2 файла main.go, go.mod.
// main.go
package main
import (
"fmt"
"net"
"net/http"
"os"
)
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// IP контейнера в его собственном network namespace.
func getContainerIP() string {
conn, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
return "unknown"
}
defer conn.Close()
return conn.LocalAddr().(*net.UDPAddr).IP.String()
}
func handler(w http.ResponseWriter, r *http.Request) {
containerHost, _ := os.Hostname() // имя из UTS namespace = ID контейнера
containerIP := getContainerIP() // адрес из network namespace
// Данные ХОСТА не добываются — они приходят снаружи.
hostHost := envOr("HOST_HOSTNAME", "(not injected)")
hostIP := envOr("HOST_IP", "(not injected)")
author := envOr("AUTHOR", "anonymous")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html>
<html><head><title>Echo Server</title></head>
<body style="font-family:monospace">
<h1>Echo Server</h1>
<ul>
<li><b>Container hostname:</b> %s</li>
<li><b>Container IP:</b> %s</li>
<li><b>Host hostname:</b> %s</li>
<li><b>Host IP:</b> %s</li>
<li><b>Author:</b> %s</li>
</ul>
</body></html>`, containerHost, containerIP, hostHost, hostIP, author)
}
func healthz(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
}
func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/healthz", healthz)
http.ListenAndServe(":8000", nil)
}
// go.mod
module echo-server
go 1.23
Далее напишем докерфайл, используя лучшие практики, мультистэйдж сборки, выкинем все лишнее, чтобы образ весил минимальное количество мегабайт, а также создадим пользователя, чтобы контейнер не был запущен от рута.
# ---- Этап 1: сборка ----
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod ./
COPY main.go ./
# CGO_ENABLED=0 — статическая линковка, чтобы бинарник
# жил в scratch без единой системной библиотеки.
# -ldflags="-s -w" срезает отладочные символы (меньше вес).
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /echo-server .
RUN echo 'appuser:x:10001:10001::/:' > /etc/passwd
# ---- Этап 2: финальный образ ----
FROM scratch
COPY --from=builder /echo-server /echo-server
COPY --from=builder /etc/passwd /etc/passwd
USER appuser
EXPOSE 8000
ENV AUTHOR="alfabuster"
ENTRYPOINT ["/echo-server"]
Далее зарегаемся на докерхабе, если еще нет аккаунта, создаем репозиторий, делаем его приватным и создаем токен доступа.
# 1. Логинимся (введёт логин и Access Token — не пароль, пароли уже немодно)
docker login -u <ваш_логин>
# 2. Собираем с правильным тегом: <логин>/<репозиторий>:<версия>
docker build -t <ваш_логин>/echo-server:1.0 .
# 3. Проверяем локально, что оно вообще дышит
docker run --rm -p 8001:8000 \
-e AUTHOR="alfabuster" \
-e HOST_HOSTNAME="$(hostname)" \
-e HOST_IP="$(ip -4 route get 1.1.1.1 | grep -oP 'src \K\S+')" \
<ваш_логин>/echo-server:1.0
# открываем http://localhost:8001 — должны увидеть hostname/IP/author
Если все сделали правильно, то увидим инфу о контейнере и хосте.

# 4. Пушим в приватный регистри
docker push <ваш_логин>/echo-server:1.0
Теперь проделаем, все тоже самое, но для пайтона.
В папке созаем server.py с таким кодом.
#!/usr/bin/env python3
import os
import socket
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
def env_or(key, fallback):
return os.getenv(key) or fallback
def get_container_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except Exception:
return "unknown"
finally:
s.close()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/healthz":
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"ok\n")
return
container_host = socket.gethostname() # ID контейнера
container_ip = get_container_ip()
host_host = env_or("HOST_HOSTNAME", "(not injected)")
host_ip = env_or("HOST_IP", "(not injected)")
author = env_or("AUTHOR", "anonymous")
body = f"""<!DOCTYPE html>
<html><head><title>Echo Server</title></head>
<body style="font-family:monospace">
<h1>Echo Server (Python)</h1>
<ul>
<li><b>Container hostname:</b> {container_host}</li>
<li><b>Container IP:</b> {container_ip}</li>
<li><b>Host hostname:</b> {host_host}</li>
<li><b>Host IP:</b> {host_ip}</li>
<li><b>Author:</b> {author}</li>
</ul>
</body></html>"""
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(body.encode("utf-8"))
def log_message(self, *args):
pass
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", 8003), Handler).serve_forever()
Напишем простой докерфайл. А теперь ключевой момент, ради которого всё затевалось. Помните элегантный финал Go-версии — FROM scratch, образ из чистой пустоты весом в мегабайты? С Python так не выйдет никогда. Python — язык интерпретируемый: чтобы запустить server.py, в образе обязан жить сам интерпретатор со всей его свитой. В пустоту его не положишь, multi-stage тут бесполезен — выкидывать нечего, рантайм нужен целиком.
FROM python:3.12-alpine
WORKDIR /app
RUN adduser -D appuser
COPY --chown=appuser:appuser app.py .
COPY server.py .
EXPOSE 8003
ENV AUTHOR="alfabuster"
ENTRYPOINT [ "python3" , "server.py"]
Однако писать такой файл проще, всего 8 строк, которые по гайду из докера, напишет даже человек без профильного образования в IT.
Теперь сравним размер полученных из билда образов.

Как видно разница примерно в 10 раз. Здесь однозначно выигрывает go.
А что на счет безопасности? Образ на scratch — это крепость без дверей: там нет sh, нет apt, нет curl, нет вообще ничего, кроме бинарника. Злоумышленник, пробравшийся в такой контейнер, оказывается в пустой бетонной коробке — ни закрепиться, ни осмотреться, ни подтянуть инструменты. Однако scratch не является защитой от RCE, при определенных условиях можно использовать загруженные/встроенные бинарники или эксплуатировать уязвимости ядра/процесса.
В python:3.12-alpine sh присутсвует. Также содержит сам Python, поэтому при RCE атакующий может использовать Python как инструмент, даже если каких-то Unix-команд нет.
Справедливости ради — у Python есть и козыри. Dockerfile проще (никакой стадии сборки), правки на лету без перекомпиляции, а внутрь можно зайти и потыкать палкой, когда что-то пошло не так. Go-scratch за свою неприступность расплачивается тем, что отлаживать его — как чинить запечатанные часы: удобной дверцы нет. Это классический размен безопасность против удобства.
Поскольку я учусь на безопасника, для меня выбор очевиден, однако в рамках тестового задания, не знаю, какой вариант правильный, возможно они оба верные. Просто для прода, нужно знать контекст и действовать по ситуации.
Тем временем первая задача выполнена, переходим ко второй.
Ansible playbook или роль
Цель
Автоматизация конфигурации на Ansible
Задача
Требуется поднять Виртуальную Машину (VM) с ОС Ubuntu 22.04. Возможные сценарии: локально через Virtual Box (или любое другое средство виртуализации), либо воспользовавшись Free-tier предложением от cloud.ru - https://cloud.ru/offers/free-tier.
Выполнить следующие задачи, используя Ansible:
На VM установить пакет docker-ce;
Запустить на docker контейнеры c подготовленным Web-приложением из первой задачи в 3-х экземплярах. Для развёртывания использовать приватный регистри из первой задачи;
Настроить балансировщик на nginx, который будет отвечать при обращении к хосту и прокидывать запрос на запущенные контейнеры. Выбрать алгоритм балансировки для приложения и обосновать свой выбор.
В ответе к задаче приложить:
исходный код Ansible
текстовое обоснование выбранного алгоритма балансировки
Полученные артефакты положить в папку /02-ansible
Ок, приступим. Поскольку хостовая система у меня Void Linux, вручную качать и устанавливать ubuntu, я не буду, воспользуюсь утилитой vagrant.
Для установки, установлю пакетный менеджер Brew.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
Он рассчитан на bash, но у меня оболочка fish, поэтому, то что советует установщик в выводе в моем случае не сработает.
Нужно добавить Homebrew в PATH fish
fish_add_path /home/linuxbrew/.linuxbrew/bin
fish_add_path /home/linuxbrew/.linuxbrew/sbin
После этого можно устанавливать сам Vagrant.
brew tap hashicorp/tap
brew install hashicorp/tap/vagrant
На хосте создаю любую папку, в ней создаю файл Vagrantfile.
Vagrant.configure("2") do |config|
config.vm.box = "ubuntu/jammy64"
config.vm.network "private_network",
ip: "10.0.0.7",
virtualbox__intnet: "intnet"
config.vm.provider "virtualbox" do |vb|
vb.memory = 2048
vb.cpus = 2
end
end
Этим файлом я создал машину во внтуренней сети, которая у меня уже была.
Пингуем сервер, чтобы убедиться, что вагрант все верно настроил, доступ по ssh, настраиваем через ключи.
Теперь можно создавать плейбук через Ansible.
В папке 02-ansible создаем пару файлов ansible.cfg и inventory.ini.
[defaults]
inventory = inventory.ini
remote_user = vagrant
private_key_file = ~/.ssh/id_ed25519
[web]
web-vm ansible_host=10.0.0.7
[web:vars]
ansible_python_interpreter=/usr/bin/python3
В этих файлах указываем, нашу созданную убунту через вагрант, ее ip ssh ключ и переменные.
Для данной задачи хватит и плейбука, можно конечно городить и роль, но для данной задачи хватит и плейбука.
Нам нужно, установить docker, залогиниться в приватный докер репозиторий, запустить 3 контейнера и накрыть балансировщиком на nginx.
Возможно руками, залезь на 1 сервак, там установить докер и запустить 3 контейнера будет быстрее. Но задание требует именно ansible, поэтому файл будет достаточно большим.
Начало плейбука
---
- name: Deploy echo-server behind an nginx load balancer
hosts: web
become: true # выполнять с sudo — иначе apt ругнется на permission
vars:
docker_image: "<ваш_логин>/echo-server:1.0"
author_name: "alfabuster"
container_count: 3
base_port: 8000 # контейнеры сядут на 8001, 8002, 8003
registry_user: "<ваш_логин>"
registry_password: "{{ vault_registry_token }}" # прячем в vault, создадим далее
Далее установим сам докер
tasks:
- name: Install prerequisites
apt:
name: [ca-certificates, curl, gnupg, python3-pip]
state: present
update_cache: true
- name: Create apt keyrings directory
file:
path: /etc/apt/keyrings
state: directory
mode: '0755'
- name: Download Docker's GPG key
get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: '0644'
- name: Add Docker apt repository
apt_repository:
repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
filename: docker
state: present
- name: Install docker-ce
apt:
name: [docker-ce, docker-ce-cli, containerd.io]
state: present
update_cache: true
- name: Install Docker SDK for Python
pip:
name: docker
state: present
- name: Ensure docker is running and enabled
service:
name: docker
state: started
enabled: true
Обратите внимание на ansible_distribution_release — это факт, который Ansible сам собирает о хосте перед стартом (для 22.04 подставит jammy).
Модуль docker_container работает не через CLI, а через Python-библиотеку docker. Поэтому мы ставим pip install docker — без неё модуль выдаст загадочную ошибку.
Приватный реджистри и запуск трех контейнеров
- name: Log in to the private registry
community.docker.docker_login:
username: "{{ registry_user }}"
password: "{{ registry_password }}"
- name: Pull the application image
community.docker.docker_image:
name: "{{ docker_image }}"
source: pull
- name: Run echo-server containers
community.docker.docker_container:
name: "echo-{{ item }}"
image: "{{ docker_image }}"
state: started
restart_policy: unless-stopped
published_ports:
- "127.0.0.1:{{ item }}:8000"
env:
AUTHOR: "{{ author_name }}"
loop:
- 8001
- 8002
- 8003
published_ports привязан к 127.0.0.1, а не к 0.0.0.0. Контейнеры слушают только локально — снаружи к ним не достучаться, единственная дверь наружу — через nginx.
Nginx и его конфиг через шаблон.
- name: Install nginx
apt:
name: nginx
state: present
- name: Deploy the load-balancer config
template:
src: nginx-lb.conf.j2
dest: /etc/nginx/sites-available/echo-lb.conf
notify: Reload nginx
- name: Enable the site
file:
src: /etc/nginx/sites-available/echo-lb.conf
dest: /etc/nginx/sites-enabled/echo-lb.conf
state: link
notify: Reload nginx
- name: Remove the default nginx site
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Reload nginx
- name: Ensure nginx is running and enabled
service:
name: nginx
state: started
enabled: true
handlers:
- name: Reload nginx
service:
name: nginx
state: reloaded
handlers — механизм, который отличает Ansible от тупого шелл-скрипта. notify: Reload nginx не перезагружает nginx немедленно. Он ставит галочку «надо будет перечитать конфиг» и делает это один раз в конце, и только если конфиг реально изменился.
Шаблон templates/nginx-lb.conf.j2
upstream echo_backend {
server 127.0.0.1:8001;
server 127.0.0.1:8002;
server 127.0.0.1:8003;
}
server {
listen 80 default_server;
server_name _;
location / {
proxy_pass http://echo_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Обоснование алгоритма
А теперь ради чего всё затевалось. У nginx есть несколько алгоритмов балансировки, и вот полный расклад, кто есть кто:
round-robin (по умолчанию) — раздаёт запросы по кругу: первый на 8001, второй на 8002, третий на 8003, четвёртый снова на 8001.
least_conn — отдаёт тому бэкенду, у кого меньше активных соединений.
ip_hash — привязывает клиента к бэкенду по хешу его IP (липкие сессии).
hash / random — экзотика для особых случаев.
Поскольку у нас простое stateless приложение, нет ни куков, ни личного кабинета, ни сессий. Все 3 контейнера равнозначны и идентичны. Поэтому равномерное распределение по кругу будет оптимальным вариантом в данной ситуации.
Least_conn здесь не станет преимуществом, потому как нагрзука в условии не указана. ip_hash в данной топологии противопоказан, так как весь трафик приходит с localhost и был бы прикован к одному контейнеру.
Поэтому выбираем round-robin.
Создаем vault и запускаем плейбук
Создание vault и пароля к нему.
ansible-vault create group_vars/web/vault.yml
Внутри одна строка с токеном из докера
vault_registry_token: "docker_token"
Далее можем запускать наш плейбук.
ansible-playbook playbook.yaml --ask-vault-pass
Тут не все прошло гладко, вылезла ошибка.
fatal: [web-vm]: FAILED! => {"changed": false, "msg": "Error connecting: Error while fetching server API version: Not supported URL scheme http+docker"}
Это произошло потому что, я давно не обновлял коллекции из ansible. Чиним.
ansible-galaxy collection install community.docker --upgrade
Запускаем плейбук еще раз и все заработало.
Проверяем сервак.
Балансировщик работает, второе задание выполнено.
Kubernetes manifest
Цель
Деплой приложения в Kubernetes
Задача
Требуется написать манифест для запуска приложения из первой задачи в Kubernetes в отдельном неймспейсе в виде Deployment с 3 репликами и сервиса с типом ClusterIP.
Выполнить следующие задачи:
Для приложения организовать проброс значения переменной AUTHOR;
Реализовать readiness- и liveness- пробы;
Использовать образ из приватного регистри из задачи 1;
опционально: поднять ingress-controller и создать правило ingress;
опционально: использовать helm-chart, а не raw манифест.
В ответе к задаче приложить:
исходный код всех манифестов
Полученные артефакты положить в папку /03-kubernetes
В задании не сказано, что нужно установливать кубер на машину. Однако для наглядности нужно понимать и тестировать написанные манифесты.
У меня на убунту для тестов как раз стоит кубер и 2 воркер ноды версии v1.31.14. Поэтому я применю написанные манифесты.
Недавно проходил курс Certified Kubernetes Administrator от kodekloud, там подсмотрел полезную тему для генерации манифестов.
Во первых, пропишем алиас, чтобы каждый раз не вводить kubectl.
alias k=kubectl
Во вторых, почти любой объект kubectl create умеет напечатать готовый манифест вместо создания.
--dry-run=client -o yaml
Дальше можно воспользоваться перенаправлением в файл и у нас будет готовый шаблон манифеста, с правильными отступами по канонам yaml.
Создаем namespace
k create ns cloud --dry-run=client -o yaml
В выводе будет лежать готовый манифест.
apiVersion: v1
kind: Namespace
metadata:
creationTimestamp: null
name: cloud
spec: {}
status: {}
Конечно врядли, кто пишет манифест для создания namespace, его проще создать командой k create ns name. Однако в задании нужен именно манифест.
Секрет
Задача усложняется тем, что реджистри приватный. Для него необходимо создавать секрет. Иначе получим вечный ImagePullBackOff.
Создаётся секрет императивно, чтобы не хранить токен в git.
kubectl create secret docker-registry regcred \
--docker-server=https://index.docker.io/v1/ \
--docker-username=<ваш_логин> \
--docker-password=<ваш_access_token> \
--namespace=cloud
Секрет живёт внутри неймспейса cloud — в другом неймспейсе он невидим.
Configmap
Можно было впихнуть AUTHOR прямо в манифест как value: “alfabuster”. Но задание просит «организовать проброс», а по-взрослому переменные конфигурации живут в ConfigMap — отдельно от кода деплоя:
apiVersion: v1
kind: ConfigMap
metadata:
creationTimestamp: null
name: cloud-configmap
namespace: cloud
data:
AUTHOR: alfabuster
Deployment
Собственно дальше идет деплоймент с 3 репликами и пробами. Генерируем шаблон, а дальше правим руками.
k create deploy cloud-deploy --image=alfabuster/echo-server:1.0 --replicas=3 --port=8000 -n cloud --dry-run=client -o yaml
Итоговый манифест получился такой.
apiVersion: apps/v1
kind: Deployment
metadata:
creationTimestamp: null
labels:
app: cloud-deploy
name: cloud-deploy
namespace: cloud
spec:
replicas: 3
selector:
matchLabels:
app: cloud-deploy
strategy: {}
template:
metadata:
creationTimestamp: null
labels:
app: cloud-deploy
spec:
imagePullSecrets:
- name: regcred
containers:
- image: alfabuster/echo-server:1.0
name: echo-server
ports:
- containerPort: 8000
env:
- name: AUTHOR
valueFrom:
configMapKeyRef:
name: cloud-configmap
key: AUTHOR
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: 8000
periodSeconds: 5
livenessProbe отвечает на вопрос «пациент вообще жив?». Если проба падает — Kubernetes перезапускает контейнер. Это лекарство от зависших процессов и дедлоков.
readinessProbe отвечает на вопрос «пациент готов принимать посетителей?». Если проба падает — под не перезапускается, его просто исключают из эндпоинтов Service, то есть перестают слать трафик, пока он не оклемается.
Service
Service в Kubernetes — это тоже балансировщик. ClusterIP раскидывает входящие соединения по всем готовым подам.
apiVersion: v1
kind: Service
metadata:
name: cloud-deploy
namespace: cloud
spec:
type: ClusterIP
selector:
app: cloud-deploy
ports:
- port: 80
targetPort: 8000
protocol: TCP
Ingress
Создаем уже привычным способом, через генератор.
k create ingress cloud-ingress --rule="cloud.local/*=cloud-deploy:80" -n cloud --dry-run=client -o yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
creationTimestamp: null
name: cloud-ingress
namespace: cloud
spec:
rules:
- host: cloud.local
http:
paths:
- backend:
service:
name: cloud-deploy
port:
number: 80
path: /
pathType: Prefix
status:
loadBalancer: {}
Домен cloud.local надо прописать в /etc/hosts, указав на IP кластера (minikube ip или адрес ноды). Без этой записи браузер будет искать cloud.local в интернете.

По итогу должно получиться, тоже самое приложение, но уже в кубере на своем домене, с 3 репликами и пробами.

Запереть контейнер изнутри*
Цель
Проверить навыки администрирования *nix
Задача
Создать ВМ с ОС Ubuntu 22.04;
Установить пакет docker-ce;
Выполнить команду docker run -it --rm ubuntu -- bash;
Не покидая контейнер, не меняя флаги запуска и настройки демона, выполнить задачи:
4.1. сделать apt-get update, показать, что команда выполняется успешно;
4.2. изнутри контейнера заблокировать ему выход в сеть интернет, команды блокировки с описанием приложить к задаче;
4.3. показать, что apt-get update больше не работает.
Доп. задача - найти ещё +1 способ выполнить подобную блокировку изнутри контейнера.
В ответе к задаче приложить:
текстовое описание применённых конфигураций
Полученные артефакты положить в папку /04-unix
На первый взгляд задача несложная, однако не зря она со звездочкой. Для начала запустим контейнер с убунтой и проверим связь.

Вот в чём фокус: Docker по умолчанию запускает контейнеры с урезанным набором Linux capabilities, выбрасывая большинство привилегий, даже когда внутри вы root.
Root внутри контейнера не равняется рут на хосте.
Из очевидного в голову приходит установить iptables и запретить исходящий трафик. Установить можем, однако для правки таблицей маршрутизации рута недостаточно.
root@d79ba688c2c9:/# iptables -A OUTPUT -j DROP
iptables v1.8.11 (nf_tables): Could not fetch rule set generation id: Permission denied (you must be root)
Контейнер работает под root, но демон docker по умолчанию сбрасывает все capabilities, кроме дефолтного набора. А в таком списке отсутсвует привелегия NET_ADMIN, без которой вы не можете ни трогать iptables, ни гасить интерфейсы, ни править таблицу маршрутизации.
Флаги запуска и настройки демона трогать нельзя, иначе можно было бы просто в запуске добавить нужную капу и задача решилась очень быстро.
Нам нужно заблокировать интернет именно для утилиты apt.
Способ №1 - nsswitch.conf
Начну не с самого очевидного варианта файла /etc/nsswitch.conf, я о нем сам недавно узнал, принимая участие в одном CTF.
NSS расшифровывается как Name Service Switch. Это механизм, встроенный в стандартную библиотеку C (glibc). Когда любая программа пытается узнать IP-адрес по доменному имени или проверить, существует ли в системе определенный пользователь, она обращается к glibc. А glibc первым делом читает /etc/nsswitch.conf, чтобы понять, где именно искать ответ.
В структуре этого файла есть строка hosts: files dns
Убрав dns из строки hosts:, вы вообще запрещаете системе обращаться к DNS — резолвиться будет только то, что явно прописано в /etc/hosts.
Кстати NSS управляет не только хостами. В файле прописаны правила для десятка системных баз данных.
Но в данном таске нас интересует dns, поэтому редактируем файл и получаем неработающий apt.

Способ №2 - resolv.conf
Второй способ, аналогичен первому, только на этот раз править будем файл /etc/resolv.conf.
Если /etc/nsswitch.conf решает, нужно ли вообще делать DNS-запрос, то /etc/resolv.conf указывает системе, к кому именно с ним обращаться. Это главный конфигурационный файл DNS-резолвера. Он часто генерируется networkmanager’ом, поэтому при следующем подключении вероятнее всего он затрется, но для демонстрации в контейнере, хватит обычной правки через vim или nano.
> /etc/resolv.conf

Снова получаем неработающий интернет.
Способ 3 - /etc/hosts
/etc/hosts — это простейший локальный справочник, который жестко связывает IP-адреса с доменными именами.
В него можно прописать локальный ip адрес серверов убунту и обновление не пройдет.
127.0.0.1 archive.ubuntu.com
127.0.0.1 security.ubuntu.com
127.0.0.1 ports.ubuntu.com

Способ 4 - apt proxy
У APT есть собственная настройка HTTP/HTTPS-прокси. Можно попробовать для всех HTTP-запросов использовать прокси 127.0.0.1:9.
Порт 9 используется для протокола Discard (RFC 863), который является сетевым эквивалентом черной дыры (/dev/null). Любые данные, отправленные на этот порт по TCP или UDP, просто уничтожаются сервером без какого-либо ответа или подтверждения.
cat > /etc/apt/apt.conf.d/99-block-network <<'EOF'
Acquire::http::Proxy "http://127.0.0.1:9";
Acquire::https::Proxy "http://127.0.0.1:9";
EOF

Как итог
Получилось неплохое задание, мне понравилось его выполнять. Много чего в памяти освежил, некоторые вещи изучил. Задание не сказать, чтобы сильно простое, но и не сложное. Некоторые задания потребуют необычного подхода и глубокое знание linux.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.