Остальные 90%
364 subscribers
24 photos
1 file
69 links
Не так страшны первые 90% проекта, как оставшиеся 90%.

Заметки о неожиданных трудностях в Linux и их героическом преодолении.

Автор: @korneev_es
ВК: https://vk.com/final90percent
Download Telegram
Больше месяца потребовалось, чтобы завершить изучение книги Linux Firewalls. Впечатления от неё остались смешанные, хотя и в общем положительные.

В первую очередь необходимо отметить, что, в отличие от многих других книг на схожую тематику, данная книга уделяет много внимания именно работе с утилитами iptables и nftables, подробно описывая правила построения команд - именно этого, как мне кажется, не достаёт официальной документации. Автор довольно подробно описывает синтаксис и принципы работы утилит и лежащего в их основе сетевого фильтра netfilter - за этим я и обращался к книге.

Несмотря на то, что настройка фаервола красной нитью проходит через всю книгу, около половины страниц посвящены общим вопросам защиты Linux-систем: наблюдению за сетевой активностью, реагированию на инциденты, отслеживанию изменений файловой системы и иным мерам обеспечения безопасности. Все эти темы, безусловно, важны, однако исходя из названия книги я скорее ожидал обсуждения сетевого стека Linux, нежели поверхностного затрагивания смежных тем. Более того, чтобы составить полную картину взаимодействия сетевого пакета с фаерволом пришлось искать другие источники, и из найденных хочется выделить следующие три:

1. отличное описание архитектуры netfilter;
2. схема прохождения цепочек правил сетевым пакетом;
3. иной взгляд на ту же схему.

Вместе с приведёнными ссылками и man iptables книга становится отличным введением в тему настройки сетевых экранов.

Как обычно, дальше оставляю выбранные мысли из книги.

1. IP-пакет может иметь адрес 0.0.0.0 только в качестве адреса отправителя в протоколе DHCP.

2. Фаервол должен собирать весь сетевой пакет перед его фильтрацией. В противном случае правила, опирающиеся на значения из заголовков следующего протокола, можно обойти, отправляя достаточно мелкие IP-пакеты - фаервол не сможет принять решение для первого пакета, не содержащего нужных полей, и может пропустить весь поток.

3. Использование модуля conntrack, отслеживающего установленные соединения, позволяет быстрее принимать решения по фильтрации пакета. С другой стороны, при большом числе короткоживущих соединений он только вредит.

4. Модуль -m state команды iptables сейчас является упрощённым синонимом к модулю conntrack.

5. Обойти механизм conntrack можно, навесив на пакет метку notrack.

6. Механизм conntrack полезен даже для UDP-соединений: после отправки пакета фаервол какое-то время будет ожидать ответа от сервера.

7. TCP-соединение может отклоняться как отправкой TCP RST ACK, так и отправкой ICMP-ответа. При этом ICMP-пакет будет содержать в теле заголовок TCP-пакета, на который он отвечает - так получатель поймёт, к какому TCP-потоку относится этот ICMP-пакет.

8. Трафик RELATED - связанный с соединением. Для TCP-соединения это может быть соответствующий ICMP-трафик, для FTP - контрольное соединение.

9. Цепочка INPUT обрабатывает входящие пакеты, FORWARD - пришедшие пакеты, перенаправляемые на другой интерфейс, OUTPUT - пакеты, сгенерированные локальными приложениями.

10. В отличие от iptables, nftables не содержит предопределённых цепочек правил.

11. Шифрование, выполняемое на сетевом уровне, обычно несовместимо с NAT.

12. При настройке фаервола стоит сразу запускать утилиты с ключами, отключающими разрешение DNS-имён. В противном случае утилиты могут зависать в ожидании ответа DNS-сервера, доступ к которому ещё запрещён.

13. Recv-Q показывает количество байтов, полученных интерфейсом, но не прочитанных приложением, Send-Q - количество байтов, отправленных приложением, но ещё не подтверждённых получателем.
Каждый первый инженер, сталкивающийся с docker-контейнерами, знает, что образы состоят из слоёв. Слои накладываются на предыдущие, добавляя и удаляя файлы. На этом описании книги и статьи и останавливаются, не погружаясь в детали реализации, и зря: современные утилиты, например, skopeo, поддерживают работу с разными форматами образов, и без этого описания отличия транспорта oci-archive от ostree могут казаться туманными. А ещё могут возникнуть неожиданные ошибки при попытке подписать образ, и разобраться с ними без изучения внутренностей образа будет трудно.

В настоящее время можно встретить образы, соответствующие 2 стандартам: традиционной спецификации docker-образов и родившейся от него спецификации OCI. Поскольку OCI - проект, направленный на унификацию формата образов, начать стоит с него. Для получения образа воспользуемся уже упомянутой утилитой skopeo, поддерживающей множество форматов. Также в заметке будет упоминаться немало sha256-сумм, которые для читабельности я буду сокращать до пары первых и последних символов.

Итак, первым делом скачаем и распакуем подопытный архив:
$ skopeo copy docker://nginx:latest oci-archive:nginx.tar
$ ls -rtlh
total 68M
-rw-rw-r-- 1 mint mint 68M сен 1 21:05 nginx.tar
$ mkdir -p nginx && tar -xf nginx.tar -C nginx

Теперь директория nginx содержит файлы, составляющие OCI-образ Nginx:
$ cd nginx/
$ ls -F1
blobs/
index.json
oci-layout


Файл oci-layout скучен и содержит версию стандарта, согласно которой собран образ, директория blobs содержит слои и их описание, и, наконец, файл index.json является точкой входа для работы с образом:
$ jq . index.json
{
"schemaVersion": 2,
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:5f0...b7f",
"size": 2295
}
]
}


Для указания на файлы стандарт использует Content Addressable storage: файлы в директории blobs названы как собственная хеш-сумма:
$ sha256sum blobs/sha256/5f0...b7f
5f0...b7f blobs/sha256/5f0...b7f

Общие правила именования файлов описываются стандартом.

Директория blobs содержит файлы разного формата, и как их читать становится ясно из поля mediaType, указанного рядом с каждой ссылкой на blob. Первым делом index.json ссылается на JSON-файл blobls/sha256/5f0...b7f, который уже содержит ссылки на слои и конфигурацию образа:
$ cd blobls/sha256
$ jq . '5f0...b7f'
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:5ef...03c",
"size": 7486
},
"layers": [
...
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:23f...1e0",
"size": 1398
}
],
...
}


Файл конфигурации содержит переменные окружения, entrypoint, cmd, лейблы и прочие параметры запуска контейнера, а также историю команд, из которых образ был создан (её же можно посмотреть командой `docker history nginx:latest`):
$ jq . 5ef...03c
{
"architecture": "amd64",
"config": {
"ExposedPorts": {
"80/tcp": {}
},
"Env": [
"NGINX_VERSION=1.27.1",
...
],
"Entrypoint": [
"/docker-entrypoint.sh"
],
"Cmd": [
"nginx",
"-g",
"daemon off;"
],
...
},
"created": "2024-08-14T21:31:12Z",
"history": [
...
{
"created": "2024-08-14T21:31:12Z",
"created_by": "COPY 30-tune-worker-processes.sh /docker-entrypoint.d # buildkit",
"comment": "buildkit.dockerfile.v0"
},
...
],
"os": "linux",
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:985...034",
...
]
}
}
Посмотрев на манифесты, можно изучить сами слои файловой системы, из которых образ был собран. Возьмём последний непустой слой, ссылку на который мы видели в файле 5f0...b7f:
$ jq .layers[-1] 5f0...b7f
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:23f...1e0",
"size": 1398
}


Поле mediaType подсказывает, как прочитать соответствующий blob:
$ mkdir -p last_layer
$ tar -xf 23f...1e0 -C last_layer
$ cd last_layer
$ ls -RF
.:
docker-entrypoint.d/

./docker-entrypoint.d:
30-tune-worker-processes.sh*
$ head docker-entrypoint.d/30-tune-worker-processes.sh
#!/bin/sh
# vim:sw=2:ts=2:sts=2:et

set -eu

LC_ALL=C
ME=$(basename "$0")
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

[ "${NGINX_ENTRYPOINT_WORKER_PROCESSES_AUTOTUNE:-}" ] || exit 0


Вот мы и добрались до обещанного слоя ФС, который действительно представляет собой простой tar-архив с добавляемыми слоем файлами. При создании файловой системы контейнера архив не просто распаковывается в предыдущую директорию: иногда слой должен удалить файл, и в таком случае он напротив содержит дополнительный пустой файл, названный по имени удаляемого, но с префиксом .wh. (ибо "whiteout"). Очевидно, просто распаковать такой архив для удаления файла будет недостаточно: архив создаст лишний файл, а не удалит нужный. Также становится понятно, почему удаление файлов отдельной операцией при сборке не уменьшает итоговый размер образа, а наоборот, только увеличивает его вес.

Конечно же, исследовать образы, вооружившись командами jq, tar и cat слишком утомительно, поэтому сами утилиты docker или skopeo позволяют изучать те или иные части образа менее сложным способом. Например, прочитать первый манифест можно следующей командой:
$ docker manifest inspect nginx:latest
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"size": 2295,
"digest": "sha256:5f0...b7f",
"platform": {
"architecture": "amd64",
"os": "linux"
}
},
...
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"size": 2297,
"digest": "sha256:628...b82",
"platform": {
"architecture": "arm",
"os": "linux",
"variant": "v5"
}
},
...
]

Команда читает описание образа из удалённого регистра, содержащего образы для разных платформ, поэтому в полученном JSON-объекте можно найти ссылку как на только что изученную сборку под архитектуру amd64, так и на другие - например, ARMv5. Выше мы рассматривали образ, предварительно сохранив его на локальный хост: по умолчанию утилиты не скачивают части образа, которые не пригодятся при запуске, поэтому и видели мы только часть, которую можно запустить на локальном хосте.

Прочую конфигурацию образа можно посмотреть командами docker image inspect и docker history. Аналогичную информацию соберёт и команда skopeo:
$ skopeo inspect docker://nginx:latest
{
"Name": "docker.io/library/nginx",
"Digest": "sha256:447...add",
"RepoTags": [
"1",
"1-alpine",
...
"stable-otel",
"stable-perl"
],
"Created": "2024-08-14T21:31:12Z",
"DockerVersion": "",
"Labels": {
"maintainer": "NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e"
},
"Architecture": "amd64",
"Os": "linux",
"Layers": [
"sha256:e4f...305",
...
],
"Env": [
...
"NGINX_VERSION=1.27.1",
...
]
}


Самостоятельно прочитать OCI-образ контейнера оказалось не так и сложно - непонятно, почему книги об этом умалчивают.
После знакомства со стандартом OCI будет логично повторить упражнение с docker-образом - путешествие покажется очень знакомым. Сторонние утилиты для этого не потребуются, команда docker save сохраняет образы как раз в этом формате. Длинные хеш-суммы, как и раньше, сократим до нескольких символов.

Спецификация docker-образов легла в основу спецификации OCI, и на её фоне выглядит совсем простой.

Подопытным будет тот же Nginx:
$ docker save nginx:latest > nginx.tar
$ mkdir nginx
$ tar -xf nginx.tar -C nginx
$ cd nginx
$ ls -F1
146...a8e/
2f6...32e/
6bf...1dd/
7ee...7d6/
b60...6ef/
caa...f3a/
df7...6ca/
e78...070.json
manifest.json
repositories


Файл repositories использовался в первой версии стандарта и сейчас создаётся только для обратной совместимости, современные реализации смотреть туда не будут. Как и в случае с OCI, начинать нужно с файла manifest.json:
$ jq . manifest.json
[
{
"Config": "e78...070.json",
"RepoTags": [
"nginx:latest"
],
"Layers": [
...
"caa...f3a/layer.tar"
]
}
]


Манифест отсылает к JSON-файлу образа и слоям. Начнём с первого, он содержит привычную конфигурацию образа: историю, entrypoint, cmd и так далее:
$ jq . e78...070.json
{
"architecture": "amd64",
"config": {
"ExposedPorts": {
"80/tcp": {}
},
"Env": [
...
"NGINX_VERSION=1.25.5",
...
],
"Entrypoint": [
"/docker-entrypoint.sh"
],
"Cmd": [
"nginx",
"-g",
"daemon off;"
],
"Labels": {
"maintainer": "NGINX Docker Maintainers <docker-maint@nginx.com>"
},
"StopSignal": "SIGQUIT",
"ArgsEscaped": true
},
"created": "2024-05-03T19:49:21Z",
"history": [
...
{
"created": "2024-05-03T19:49:21Z",
"created_by": "COPY 30-tune-worker-processes.sh /docker-entrypoint.d # buildkit",
"comment": "buildkit.dockerfile.v0"
},
...
],
"os": "linux",
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:5d4...e25",
...
]
}
}


Как именуются директории, в которых лежат слои, уже не столь очевидно. Согласно спецификации, имена должны состоять из 64 hex-символов и однозначно определяться содержимым слоя. Занятно, однако, что в качестве ссылок на слои указывается путь до layer.tar, а не просто имя директории:
$ jq .[0].Layers[-1] manifest.json
"caa...f3a/layer.tar"

Помимо файла layer.tar директория содержит файлы json и VERSION, и снова ни один из них не используется в современном стандарте.

И вот мы добрались до файлов, добавляемых слоем:
$ cd caa...f3a
$ tar -xf layer.tar -C last_layer/
$ cd last_layer/
$ ls -RF
.:
docker-entrypoint.d/

./docker-entrypoint.d:
30-tune-worker-processes.sh*
$ head docker-entrypoint.d/30-tune-worker-processes.sh
#!/bin/sh
# vim:sw=2:ts=2:sts=2:et

set -eu

LC_ALL=C
ME=$(basename "$0")
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

[ "${NGINX_ENTRYPOINT_WORKER_PROCESSES_AUTOTUNE:-}" ] || exit 0


Правила создания и удаления файлов каждым слоем в практически неизменном виде перекочевали из спецификации Docker в OCI: даже пример создания слоя толком не поменялся.
Формат OCI поддерживается Docker, однако включается эта поддержка не совсем тривиально. Попытка собрать образ OCI без подготовки завершается ошибкой:
$ cat Dockerfile

FROM ubuntu

$ docker buildx build --output=type=oci . > img.tar
[+] Building 0.0s (0/0) docker:default
ERROR: OCI exporter is not supported for the docker driver.
Switch to a different driver, or turn on the containerd image store, and try again.
Learn more at https://docs.docker.com/go/build-exporters/


Для сборки OCI-образа нужно подключить соответствующий драйвер:
$ docker buildx create \
--driver docker-container \
--driver-opt image=moby/buildkit:master,network=host \
--use
sleepy_beaver

После выполнения этой команды сборка уже пройдёт успешно:
$ docker buildx build --output=type=oci . > img.tar
[+] Building 20.4s (6/6) FINISHED docker-container:sleepy_beaver
=> [internal] booting buildkit 15.5s
=> => pulling image moby/buildkit:master 12.0s
=> => creating container buildx_buildkit_sleepy_beaver0 3.5s
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 51B 0.0s
=> [internal] load metadata for docker.io/library/ubuntu:latest 1.8s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/1] FROM docker.io/library/ubuntu:latest@sha256:8a3...3ee 2.9s
=> => resolve docker.io/library/ubuntu:latest@sha256:8a3...3ee 0.0s
=> => sha256:31e...356 29.71MB / 29.71MB 2.9s
=> exporting to oci image format 3.0s
=> => exporting layers 0.0s
=> => exporting manifest sha256:9e2...9e6 0.0s
=> => exporting config sha256:0db...509 0.0s
=> => sending tarball

Команда самостоятельно загрузит образ сборщика, сконфигурирует его и создаст образ нашего приложения.

Больше о фреймворке сборки образов в Docker можно узнать в документации и на GitHub.
В прошедшем месяце неожиданно для самого себя решил познакомиться с Java. Основной целью было начать понимать готовый код, пока без глубокого погружения в детали и изучения популярных фреймворков, за чем было решено обратиться к 9 изданию книги Java: A Beginner's Guide за авторством небезызвестного Herbert Schildt.

По структуре и подаче материала книга очень напоминает издание C++ Primer Plus Стивена Праты, также плавно проводя читателя от самых основ к обсуждению продвинутых умений языка. Шилдт описывает базовые типы переменных, управление потоком исполнения, поддержку ООП и работу с исключениями, после чего акцент смещается уже в сторону действительно специфичных для Java тем: пакетам и модулям, многопоточному программированию, дженерикам, лямбдам и интерфейсам. К сожалению, в книге не обсуждается структура проекта, системы сбороки, внутреннее устройство JVM, профилирование и оптимизация кода, java-агенты и прочие смежные темы.

После чуть более глубокого знакомства с несколькими языками становится особенно заметно их влияние друг на друга, легко проводятся аналогии. Например, интерфейсы Go совершенно аналогичны интерфейсам Java, а оператор Java try-with-resources уж очень напоминает контекстный менеджер языка Python. Не секрет, что удачные идеи быстро распространяются и реализуются соседними языками, и становится интересно, кто же первым предложил тот или иной подход. В книге об этом, конечно, не говорится, и найти концы и новые переплетения было бы интересно.

Автор также имеет и расширенную версию книги: Java: The Complete Reference. Первая её треть содержит все те же темы, что и в описываемой, остальная часть уже рассказывает о стандартной библиотеке и применениях Java. Если у вас уже есть опыт в программировании и вы хотите изучить Java подробнее, то, кажется, стоит обратиться сразу ко второй книге.

На фоне огромной популярности книги удивительно, что она содержит примеры кода сомнительного качества. Опытному читателю подобные примеры будут резать глаза, неопытного же учить плохим практикам. Не раз в книге встречается паттерн:
if (a == b)
return true;
else
return false;

И ладно, если бы паттерн использовался повсеместно - другие примеры написаны корректно:
return a == b;

Другим подобным примером будет сравнение чисел с плавающей точкой через оператор == - есть даже сайт, подробно разбирающий ошибку.

Несмотря на возникшие замечания книга остаётся стоящей для плавного погружения в тему, читается легко и быстро. Список традиционных заметок здесь приводить было бы странно, однако пару интересных пунктов всё-таки оставить можно:

1. Oracle выпускает новую минорную версию компилятора каждые 6 месяцев, LTS-версия - каждая четвёртая. Бесплатная поддержка LTS осуществляется в течение 4-5 лет.

2. Java поддерживает возможность интерактивного написания и исполнения кода (REPL) - JShell.
Заметка дня #3

Для поиска полного пути к исполняемому файлу я привык пользоваться командой which:
$ which sshd
/usr/sbin/sshd


Команда представляет из себя shell-скрипт длиной в 63 строки, обходящий директории из переменной PATH в поисках переданных аргументов:
$ cat /usr/bin/which
#! /bin/sh
set -ef
...


Оказывается, при помощи Bash ту же задачу можно выполнить, не вызывая системных утилит: с этим справится встроенная команда command. Изначально она предназначалась для выполнения команд с диска в ситуациях, когда их затеняет функция Bash с тем же именем, однако она же показывает полный путь к утилитам:
$ command -v sshd
/ust/bin/sshd
$ command -V sshd
sshd is /usr/sbin/sshd


Использовать встроенную команду кажется корректнее, чем стороннюю утилиту: команда будет пользоваться той же логикой, что и сама оболочка при поиске пути к файлу. Также встроенные средства Bash оказываются значительно быстрее аналогов-утилит:
$ time which sshd
/usr/sbin/sshd

real    0m0,002s
user    0m0,002s
sys     0m0,000s

$ time command -v sshd
/usr/sbin/sshd

real    0m0,000s
user    0m0,000s
sys     0m0,000s


Выигрыш в тысячные доли секунды здесь не играет никакой роли, однако в скриптах выигрыш может оказаться существенным - именно из-за этого, например, Bash поддерживает команду echo, дублирующую системную утилиту.

#tip
Вообще говоря, я сторонник использования стандартных утилит: я совершенно уверен, что многие идеи были переизобретены из-за недостаточного владения существующими (вот чем ls не угодил?). И всё же дать шанс Podman решился - и не пожалел. Только что одним адептом Podman стало больше :)

Насколько книга "Docker: Up & Running" впечатлила пару месяцев назад, настолько же Podman in Action оказалась открытием. Изучать книгу будет особенно полезно инженерам, уже знакомым с Docker - и вынести из неё можно немало полезного даже если планов перейти на Podman нет.

Автор книги - Daniel Walsh, специалист информационной безопасности в Red Hat, и под его же началом Podman и был рождён. Область интересов Дена наложила отпечаток и на архитектуру Podman: впервые познакомившись с системой Docker, Ден увидел множество сомнительных и уязвимых мест в её архитектуре. Так, пользователи демона dockerd часто могут легко получить права администратора на хосте, замести следы своей активности и даже скрыться от auditd. И все описанные автором проблемы успешно и прозрачно для пользователя устранены в новом решении.

Podman действительно выглядит выигрышно с учётом комментариев Дена, и позволяет исправить множество мелких шероховатостей, возникающих при использовании Docker. Более того, авторы утилиты поддерживают строгую совместимость с CLI docker, описывая её как alias docker=podman. За счёт неё переход на использование Podman значительно упрощается. Перечислять достоинства утилиты можно долго, однако эту задачу оставим её автору, меня убедить он смог :)

В заметках я постараюсь сделать акцент на особенностях Podman, пожертвовав в их пользу описаниями разнообразных мелких, но приятных улучшений CLI.

1. В отличие от Docker, podman не нужен запущенный демон, все контейнеры являются прямыми потомками пользовательского процесса podman.

2. podman позволяет указать регистр для использования по умолчанию, в то время как docker всегда обращается к docker.io.

3. Командой podman image mount можно смонтировать образ в файловую систему хоста и исследовать его, не запуская контейнер. Подобное свойство может оказаться удобным для аудита.

4. При наличии флага монтирования U podman изменит владельца смонтированных файлов на пользователя в контейнере. Также podman может использовать idmap, если эту опцию поддерживает рантайм.

5. podman может запускать Поды. Помимо этого он может запускать контейнеры на основе манифестов k8s, и наоборот, генерировать манифесты, с помощью которых можно перенести уже запущенные на хосте контейнеры в k8s.

6. При помощи команды podman unshare можно получить доступ к командной оболочке, запущенной в пространстве имён пользователя. Другими словами, можно оценить, как увидит систему процесс, запущенный в контейнере с текущими настройками.

7. В отличие от Docker, podman не отказывается от интеграции с SystemD. Более того, podman позволяет запускать контейнеры, содержащие SystemD, автоматически выполняя нужную для этого конфигурацию файловой системы при старте контейнера.

8. По умолчанию контейнеры podman отправляют логи в journald.

9. podman можно запускать и в режиме ожидания команд, как dockerd. Более того, благодаря тесной интеграции с SystemD сервис podman может подниматься только при первом обращении к нему, экономя ресурсы системы, выполнять обновления контейнеров по таймеру и даже откатывать контейнеры при неудачном обновлении к предыдущему состоянию, используя сервис sd_notify.

10. Rootless podman выделяет часть UID пользователю и по умолчанию создаёт все контейнеры пользователя в одном пространстве имён. В то же время каждому контейнеру можно назначить своё подпространство, и в таком случае процесс одного из контейнеров не сможет атаковать другие контейнеры этого пользователя.
P.S. Выдержки из книги лучше всего описываются этой картинкой...
Нередко процессу необходимо следить за содержимым файла и реагировать на изменения в нём. Реализовать эту логику можно по-разному: перечитывать файл раз в несколько секунд, прерываться на чтение в определённых точках исполнения или по сигналу, или как-либо ещё. Если перезагрузку файла нужно выполнять без участия человека, то популярным вариантом оказывается применение подсистемы ядра inotify.

API inotify позволяет незамедлительно получать уведомления об изменениях в файлах и директориях и не тратить процессорное время на лишнюю периодическую загрузку этих файлов. Приложению остаётся только перечитывать их при необходимости.

Именно такую механику использует, например, демон flagd, который использует файл для управления feature-флагами приложений. Все изменения в конфиге демона подхватываются на лету и становятся сразу доступны читателям - по крайней мере, так задумывалось. В действительности демон упорно не замечал изменения в файле, внесённые через vim, и требовал постоянной перезагрузки. Подобное поведение казалось особенно удивительным, поскольку редактирование того же конфига при помощи VSCode работало (и заставляло переживать, не может же vim быть хуже VSCode!).

Разгадка крылась в том, как vim фиксирует изменения в буфере: при редактировании vim не записывает сразу изменения в оригинальный файл. Вместо этого редактор создаёт временную копию файла, и при успешной записи подменяет оригинальный файл изменённой версией. Проверяется это легко наблюдением за inode редактируемого файла:
$ touch test.txt
$ ls -i test.txt
21038329 test.txt
# Write something, save and quit
$ vim test.txt
$ ls -i test.txt
21038337 test.txt

Видно, inode стал другим. Под капотом vim переименовал оригинал и записал новое содержимое по старому пути. Убедиться в этом можно, традиционно расчехлив strace:
$ strace -e unlink,rename,openat -o vim.trace vim test.txt
$ cat vim.trace
...
unlink("test.txt~") = -1 ENOENT (No such file or directory)
rename("test.txt", "test.txt~") = 0
openat(AT_FDCWD, "test.txt", O_WRONLY|O_CREAT, 0664) = 3
unlink("test.txt~") = 0
...

Хоть имя файла и осталось прежним, в действительности мы создали другой файл с обновлённым содержимым. Поэтому и flagd не видел изменений: формально, мы просто удалили его конфиг и создали новый на прежнем месте, и об этом процесс узнать уже не мог.

Как и стоило ожидать, vim всё-таки позволяет управлять описанным процессом и при необходимости отключать его. Ситуация с flagd наладилась сразу, стоило выполнить команду :set backupcopy=yes:
$ ls -i test.txt
21038336 test.txt
$ cat test.txt
123
# Type `: set backupcopy=yes' before updates
$ vim test.txt
$ ls -i test.txt
21038336 test.txt
$ cat test.txt
456


Подобная ситуация может проявляться и в других сценариях. Команды sed -i, sponge и прочие тоже могут приводить к пересозданию файлов, поэтому стоит особенно внимательно следить за их применением в схожих случаях.
Заметка дня #4

Довольно часто возникает необходимость отследить, сколько ресурсов потребляет процесс.

Если процесс долгоживущий - например, если это веб-сервер - то вариантов много. Самый наглядный ответ даст, наверное, process_exporter с симпатичной витриной Grafana, хотя и требует этот подход наибольших вложений.

Следующей на ум приходит команда top, фильтры которой позволяют вывести интересующие метрики процессов. Если результат команды нужно использовать в скрипте, то для задачи лучше подойдёт утилита ps, ключ -o которой принимает через запятую параметры, которые надо показать - например, самую базовую статистику для процесса можно получить так:
# $$ is PID of the current shell.
# Use the one you need or like instead
$ ps -o pcpu,pmem,comm -p $$
%CPU %MEM COMMAND
1.0 0.0 bash

Свежие версии утилиты даже поддерживают спецификатор docker, выводящий id контейнера, в котором запущен процесс.

Ни один из предложенных вариантов не подойдёт для процессов, живущих лишь пару секунд - мы просто не успеем узнать его PID, чтобы подставить в ps, и тем более не сможем посмотреть на полную статистику по завершении процесса. И, сколько бы неожиданно это ни звучало, задачу отлично решает утилита time.

Утилита GNU time, вызванная с ключом -v, показывает не только время работы команды, но и потребление системных ресурсов. Выглядит это так:
# Hide output of `ls'
$ /bin/time -v ls >/dev/null
Command being timed: "ls"
User time (seconds): 0.00
System time (seconds): 0.00
Percent of CPU this job got: 50%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0:00.00
Average shared text size (kbytes): 0
Average unshared data size (kbytes): 0
Average stack size (kbytes): 0
Average total size (kbytes): 0
Maximum resident set size (kbytes): 2560
Average resident set size (kbytes): 0
Major (requiring I/O) page faults: 0
Minor (reclaiming a frame) page faults: 120
Voluntary context switches: 1
Involuntary context switches: 0
Swaps: 0
File system inputs: 0
File system outputs: 0
Socket messages sent: 0
Socket messages received: 0
Signals delivered: 0
Page size (bytes): 4096
Exit status: 0


Конечно же, параметры можно вывести выборочно и красиво:
$/bin/time -f '"%C" took %E\n%M kbytes used' ls -R  >/dev/null
"ls -R" took 0:03.41
3840 kbytes used


Важно обратить внимание на способ вызова утилиты. Во множестве оболочек имеется встроенная команда time, просто замеряющая время исполнения пайплайна и затеняющая утилиту GNU time, поэтому во избежание путаницы лучше прописывать полный путь до утилиты.

Лично мне эта команда пригодилась недавно, чтобы оценить, сколько памяти потребляет Python-скрипт, работающий долю секунды. Оказывается, списки действительно сильно экономнее словарей :)

#tip
Grafana - отличный инструмент, законодатель мод в мире наблюдаемости, и вообще важнейший компонент любой IT-системы. В то же время её документация местами оставляет желать лучшего, и одним из таких узких мест оказываются витрины Grafana.

Сложности начали преследовать меня ещё пару лет назад, когда я писал Ansible-роль для установки Grafana. Почему-то автоматически добавить несколько заготовленных витрин не удавалось: то мешали конфликты ID витрин, то datasource не подходил, то модуль Ansible сыпал ошибками. Хоть победить проблемы и удалось, подробности вспомнить уже не выйдет. С тех времён остался лишь вгоняющий в уныние скрипт fix_dashboard.sh, который обязательно надо было запускать после внесения любых правок в дашборд:
cat "$1" | tr '\n' ' ' |
sed -E 's/"datasource"[^}]*}/"datasource": {"uid": "prometheus"}/g' |
jq '.' > fixed.json.$$
mv fixed.json.$$ "$1"


Недавно схожие симптомы начали встретили меня снова, но уже при переносе витрин между парой серверов Grafana, и документация Grafana снова толком не помогла. Опуская часть про исследование и чтение исходников, перечислю основные выводы и советы по работе с дашбордами, которые могут спасти читателю несколько часов работы.

1. Grafana разделяет "внешний" и "внутренний" экспорт витрин. Внешний экспорт позволяет переносить витрины между серверами графаны, внутренний - просто сохраняет текущую витрину как файл. Если вы хотите перенести витрину или поделиться ей, обязательно укажите Export for sharing externally. Обычная копия витрины не заработает на другом сервере, с чем я и столкнулся.

2. UID datasource локален для сервера. Grafana позволяет просматривать схему витрины в её настройках, и UID источников данных, встречающихся там, не имеют смысла на другом сервере Grafana.

3. Дашборды, экспортированные наружу, содержат особые поля. Экспортированная наружу витрина должна содержать поля:
1. __inputs: список источников данных, используемых витриной;
2. __requires: список плагинов, используемых витриной.
3. __elements: ни разу не встречал поле заполненным, упоминается только в разделе про API;
Эти поля интерпретируются Grafana при загрузке витрины и сразу же удаляются.

4. Grafana не умеет экспортировать витрины наружу через API. Внешним экспортом витрин занимается UI Grafana, а поддержка совершенно серьёзно предлагает самостоятельно изучить код, добавляя, что документации к нему особо и нет.

5. Не мы первыми решаем эту задачу. Например, эта витрина просто рендерит кнопку, которая скачивает архив со всеми экспортированными наружу дашбордами. К сожалению, работает витрина не везде: по умолчанию настройки безопасности Grafana не позволяют исполнять JS-код в текстовых полях витрин. А QIWI пробовал реализовать dashboards-as-code.

Актуальное описание схемы витрин брать, видимо, нужно из кода Grafana. Комментарии к полям там, к слову, гораздо подробнее и полезнее официальной документации.
Заметка дня #5

Про curl рассказывать можно вечно. Однажды мне уже повезло наткнуться на презентацию автора проекта, длилась она долгих 4 часа и рассказывала о многих непопулярных особенностях утилиты. Своими заметками я уже поделился, и там же я упоминал о встроенной документации curl, соревноваться с которой может разве что kubectl explain.

Полная документация curl вшита в утилиту и выводится по команде curl --manual. Это та самая страница man curl, которой так иногда не хватает во время отладки внутри контейнера - к счастью, вместо man curl можно вызвать curl --manual | less и всё-таки найти нужный ключик.

В то же время искать конкретный параметр по всей страничке man curl может быть затруднительно: описание версии 7.81.0 растянуто на 5088 строк. И тут на помощь приходит полуинтерактивный справочник, также встроенный в утилиту.

Ключ командной строки --help all предлагает однострочные описания самых популярных ключей, а команда curl --help <category> сужает вывод лишь до выбранного набора опций. Сам список категорий тоже можно посмотреть:
$ curl --help category
Usage: curl [options...] <url>
auth Different types of authentication methods
connection Low level networking operations
curl The command line tool itself
dns General DNS options
file FILE protocol options
ftp FTP protocol options
http HTTP and HTTPS protocol options
imap IMAP protocol options
misc Options that don't fit into any other category
output Filesystem output
pop3 POP3 protocol options
post HTTP Post specific options
proxy All options related to proxies
scp SCP protocol options
sftp SFTP protocol options
smtp SMTP protocol options
ssh SSH protocol options
telnet TELNET protocol options
tftp TFTP protocol options
tls All TLS/SSL related options
upload All options for uploads
verbose Options related to any kind of command line output of curl


С помощью этой справки нет необходимости листать полную документацию каждый раз, вспоминая, каким ключом выбрать версию HTTP-протокола, изменить DNS-сервер или путь к доверенным сертификатам. А ещё это позволяет лишний раз окинуть взглядом все возможности утилиты: с её помощью можно не только отправлять HTTP-запросы, но и читать и отправлять почту, копировать файлы по протоколам WebDav и scp и многое другое.

А ваш httpie так может? :)
$ curl -k sftp://demo:password@test.rebex.net:22
drwx------ 2 demo users 0 Mar 31 2023 .
drwx------ 2 demo users 0 Mar 31 2023 ..
drwx------ 2 demo users 0 Mar 31 2023 pub
-rw------- 1 demo users 379 Sep 19 2023 readme.txt


#tip
Следующей попавшей мне в руки книгой стала CPython internals, рассказывающая о внутреннем устройстве главного интерпретатора Python - CPython.

Книга - уникальная в своём роде, написана она в первую очередь для инженеров, которые планируют внести свой вклад в развитие языка. В книге автор рассказывает, из каких частей состоит интерпретатор Python, как можно его расширить и отладить, и как можно принять участие в развитии проекта. Подобные задачи маловероятно встретятся большинству Python-разработчиков, и в то же время знакомство с интерпретатором окажется полезным при разработке модулей-расширений или углублённом профилировании кода.

Автор иллюстрирует описываемые темы, добавляя в интерпретатор поддержку новой языковой конструкции. Для этого приходится внести правки в исходные файлы CST/AST-парсеров, компиятора и самого интерпретатора - то есть задеть все основные движущиеся части языка. На мой взгляд, пример получился наглядным и удачным, изучать учебник было интересно.

Помимо прочего, в книге я ожидал увидеть описания оптимизаций, таких как кеширование целых чисел или эффективная конкатенация строк, но, к сожалению, о них автор умолчал. Прикладные темы практически не были затронуты, автор не выходил за пределы внутренностей CPython. По этой же причине заметки к книге прикладными скорее всего назвать не удастся.

1. Среди прочего, python3 поддерживает такой формат запуска скриптов: cat script.py | python3.

2. Команда python3 -m sysconfig показывает параметры компиляции интерпретатора.

3. В отличие от многих других языков, CPython компилирует код сначала в CST, и лишь затем в AST.

4. Единица компиляции в CPython - модуль.

5. Технически, начиная с версии 3.6 Python инструкции интерпретатора могут занимать более 1 байта, поэтому "bytecode" корректнее будет называть "wordcode".

6. Интерпретатор использует отдельный стек для вызовов функций и отдельный для операций с переменными.

7. Реализация команды LOAD_FAST на C включает несколько операций, использует if и несколько вызовов функций. Что ж, это во многом объясняет отличия в скоростях языков :)

8. Модуль tracemalloc позволяет изучить, какие строки кода потребляют память и в каком количестве.

9. Рантайм CPython может включать несколько интерпретаторов, каждый из которых может использовать несколько потоков исполнения.

10. В будущие версии языка скорее всего войдёт модуль subinterpreters.

11. Словари хранят ключи и значения в отдельно друг от друга и бывают "split" и "combined". В первом случае указатели на значения хранятся в отдельном атрибуте, во втором - как значения ключей.
Во время работы на удалённом хосте по SSH часто возникает необходимость выполнить пару команд локально. Daniel J. Barrett в своей книге Efficient Linux at the Command Line советует на каждую новую задачу открывать новый терминал - этот подход решит проблему, однако не всегда: выполнив ещё одно подключение уже с удалённой машины, нам может потребоваться вернуться на неё же, а новый терминал открывается локально.

За примером далеко ходить не надо: предположим, мы хотим склонировать репозиторий Git на третьем хосте, имея ключ аутентификации на удалённой машине. Подключившись к последнему хосту, мы обнаружим, что не добавили ключ в ssh-агент, и за ним нужно вернуться. Конечно, можно так и поступить: набрав exit или ctrl+D, вернуться на удалённый хост, добавить ключ и подключиться снова, однако можно поступить элегантнее.

Команда ssh (по крайней мере, от OpenSSH) поддерживает несколько полезных комбинаций клавиш, о чём подробнее пишется в разделе Escape Characters странички man ssh. Среди них есть и нужная нам комбинация ~^Z, сбрасывающая текущую SSH-сессию в фон. Чтобы отправить эту последовательность, надо нажать клавиши Enter ~ Ctrl z, чтобы подключиться к сброшенной сессии - выполнить fg.

В примере выше поступить надо чуть хитрее: набрав ~^Z, мы свернём само подключение к удалённому хосту, а не то, которое мы открыли с него. В действительности нам нужно отправить комбинацию ~^Z на удалённый хост, набрав символ ~ дважды: Enter ~ ~ Ctrl z. Последовательность перемещений и вводимых команд будет выглядеть так:
# Connect to remote host: host2
host1 $ ssh host2

# Now go to the third host with ssh-agent forwarding
host2 $ ssh -a host3
host3 $ cd do/some/work/and/cd/deeply

# Forgot to add our key :(
host3 $ git clone git@gitlab.com:ExAnimo/books.git
Cloning into 'books'...
git@gitlab.com: Permission denied (publickey).
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.

# Indeed no key :(
host3 $ ssh-add -l
The agent has no identities.

# Return to host2 to get the key
# Enter ~ ~ Ctrl z
host2 $ ssh-add ~/.ssh/git_key

# Go back (workdir has not changed since ^Z!)
# and finish the work!
host2 $ fg
host3 $ git clone git@gitlab.com:ExAnimo/books.git
Cloning into 'books'...
remote: Enumerating objects: 239, done.
remote: Counting objects: 100% (9/9), done.
remote: Compressing objects: 100% (9/9), done.
remote: Total 239 (delta 2), reused 0 (delta 0), pack-reused 230 (from 1)
Receiving objects: 100% (239/239), 51.48 KiB | 8.58 MiB/s, done.
Resolving deltas: 100% (76/76), done.


Описанный способ возвращения на предыдущий хост в цепочке подключений плохо масштабируется с количеством прыжков, поскольку требует много раз нажать на клавишу ~, чтобы добраться до последнего хоста. Если переключения неизбежны и их ожидается множество, то, чтобы не путаться, можно переопределить escape-последовательность при помощи ключа -e, и предыдущие ssh-сессии будут игнорировать её.

Важно заметить, что сброшенная в фон сессия протухает относительно быстро. Время ожидания зависит от Keepalive-настроек сервера, но в любом случае ожидания на пару команд всегда хватает с запасом.

Наконец, говоря об escape-последовательностях ssh, нельзя обойти стороной последовательность ~.. Из-за проблем с сетью сессия может зависать, и ssh не отпустит терминал до тех пор, пока не наступит таймаут. По умолчанию наибольшее время ожидания соответствует TCP-таймауту, поэтому, чтобы не ждать час и не закрывать окно принудительно, гораздо удобнее просто прервать сессию, введя Enter ~ .