В статье отражен опыт применения языков Perl и Golang в повседневной работе бородатого сисадмина в качестве скриптового языка и показаны примеры использования.
Начало времен
Когда-то давно в молодости я выбирал инструмент, который помог бы мне автоматизировать ручной труд, а именно распарсить лог или конфиг, протестировать коннект к базе данных, собрать ответы от сайтов и т.д. И я выбрал Perl. Он до сих пор является палочкой-выручалочкой. Внятное объяснение этому можно найти в данной заметке, из которой я приведу лишь главные причины актуальности Perl:
Он везде установлен по умолчанию, при этом удобен для быстрых сценариев и адаптируется к новым парадигмам.
Я могу быть уверен, что сценарий Perl, который я пишу сегодня, будет работать без изменений через 10 лет.
Любой уважающий себя сисадмин должен быть с программистским уклоном. Мне всегда было интересно писать всякого рода прокси, а также скрипты синхронизации баз данных, мониторинга, бэкапа и т.д. Для этого на CPAN можно найти кучу примеров кода с отличной документацией, которая, по моему мнению, является лучшим примером оформления и представления документации к коду.
Что-то новенькое
И вот недавно меня попросили написать скрипт (точнее я сам напросился :-)), который дергает некое api по http и результат (json) складывает в noSQL базу данных, при этом главным условием было то, что нельзя писать на Perl, т.к. в команде программистов никто его не знает. Тогда я предложил написать на Golang, ведь, по моему мнению, именно этот современный язык заслуживает внимания сисадминов. До этого момента я никогда не писал на языках со статической типизацией, да и образование у меня не программистское, но тем интересней мне показалась задача.
Так как Golang для меня новый язык, то прежде чем браться за работу (времени у меня было предостаточно) я решил напиcать скрипты на Perl и Golang для трех распространенных задач и одной не очень распространенной, тем самым сравнив некоторые моменты: скорость написания скриптов, время выполнения, потребление памяти. Вот список задач, который я собрал для примеров кода:
Найти 500-е коды ответов в access.log размером ~1G
Сделать выборку из sql базы данных (в таблице 12200 строк)
Узнать дату выдачи и дату окончания действия ssl сертификата сайта www.example.com
По особенному распарсить json
Примеры скриптов
Для замера времени выполнения все скрипты запускались через утилиту time
, вывод которой будет показан после примеров кода. В момент работы скриптов в соседнем терминале была запущена команда (for i in $(seq 1 3);do ps -eo rss,command | grep script | grep -vE 'grep|vim|go run'; sleep 1 ;done
), делающая замер потребления памяти (первая колонка) три раза, вывод которой так же будет показан после примеров кода.
Задача 1. Найти 500-е коды ответов в access.log размером ~1G
script.pl
#!/usr/bin/env perl
use strict;
use warnings;
use utf8;
use open qw(:std :utf8);
open FILE, "<", 'access.log' or die $!;
while (<FILE>) {
my @a = split /\s+/;
print if $a[8] eq '500';
}
close FILE;
script.go
package main
import (
"bufio"
"fmt"
"log"
"os"
"regexp"
)
func main() {
file, err := os.Open("access.log")
if err != nil {
log.Fatalf("%s", err)
}
fileScanner := bufio.NewScanner(file)
for fileScanner.Scan() {
s := regexp.MustCompile(`\s+`).Split(fileScanner.Text(), 10)
if s[8] == "500" {
fmt.Println(s)
}
}
}
script.pl |
script.go |
|
Вермя выполнения |
real 0m16,132s |
real 0m24,357s |
Потребление памяти (in kilobytes) |
6400 perl ./script.pl |
9608 ./script |
Задача 2. Сделать выборку из sql базы данных (в таблице 12200 строк)
script.pl
#!/usr/bin/env perl
use strict;
use warnings;
use utf8;
use open qw(:std :utf8);
use DBI;
my $dbh = DBI->connect(
"dbi:Pg:dbname='exp';host='10.10.10.1';port=5432",
'exp', '111',
{AutoCommit => 1, RaiseError => 1, PrintError => 0, pg_enable_utf8 => 1, ShowErrorStatement => 0}
);
my $sth = $dbh->prepare('SELECT deal_city_id, "ShortName", "FullName" FROM public.deal_city ORDER BY deal_city_id');
$sth->execute();
while (my $ref = $sth->fetchrow_hashref) {
print $ref->{deal_city_id}.' '.$ref->{ShortName}.' '.$ref->{FullName}. "\n";
}
script.go
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/lib/pq"
)
func main() {
db, err := sql.Open("postgres", "host=10.10.10.1 port=5432 user=exp password=111 dbname=exp sslmode=disable")
if err != nil {
log.Fatal(err)
}
rows, err := db.Query("SELECT deal_city_id, \"ShortName\", \"FullName\" FROM public.deal_city ORDER BY deal_city_id")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
var (
deal_city_id int
ShortName string
FullName string
)
for rows.Next() {
err := rows.Scan(&deal_city_id, &ShortName, &FullName)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%v %s %s\n", deal_city_id, ShortName, FullName)
}
}
script.pl |
script.go |
|
Время выполнения |
real 0m2,885s |
real 0m2,790s |
Потребление памяти (in kilobytes) |
15232 perl ./script.pl |
6896 ./script |
Задача 3. Узнать дату выдачи и дату окончания действия ssl сертификата сайта www.example.com
script.pl
#!/usr/bin/env perl
use strict;
use warnings;
use Crypt::OpenSSL::X509;
use IO::Socket::SSL;
my $client = IO::Socket::SSL->new(
PeerHost => "www.example.com",
PeerPort => 443,
SSL_verify_callback => \&verify_cert,
) or die "error=$!, ssl_error=$SSL_ERROR";
$client->close();
sub verify_cert {
return 1 if $_[5] != 0;
my $cert_pem = Net::SSLeay::PEM_get_string_X509($_[4]);
my $x509 = Crypt::OpenSSL::X509->new_from_string($cert_pem);
print $x509->subject() . "\n";
print $x509->notBefore() . "\n";
print $x509->notAfter() . "\n";
return 1;
}
вывод скрипта
C=US, ST=California, L=Los Angeles, O=InternetCorporationforAssignedNamesandNumbers, CN=www.example.org
Jan 13 00:00:00 2023 GMT
Feb 13 23:59:59 2024 GMT
script.go
package main
import (
"crypto/tls"
"fmt"
)
func main() {
conn, err := tls.Dial("tcp", "www.example.com:443", &tls.Config{InsecureSkipVerify: true})
if err != nil {
panic("failed to connect: " + err.Error())
}
defer conn.Close()
cs := conn.ConnectionState()
for _, cert := range cs.PeerCertificates {
fmt.Printf("%v\n", cert.Subject)
fmt.Printf("%v\n", cert.NotBefore)
fmt.Printf("%v\n", cert.NotAfter)
break
}
}
вывод скрипта
CN=www.example.org,O=Internet Corporation for Assigned Names and Numbers,L=Los Angeles,ST=California,C=US
2023-01-13 00:00:00 +0000 UTC
2024-02-13 23:59:59 +0000 UTC
script.pl |
script.go |
|
Время выполнения |
real 0m0,531s |
real 0m0,441s |
Потребление памяти (in kilobytes) |
22732 perl ./script.pl |
9900 ./script |
Задача 4. По особенному распарсить json
Эта задача родилась уже после того, как я написал тот самый скрипт, который меня попросили. Дело тут в том, что Perl просто берет json строку и парсит ее целиком в свои структуры (массивы, хеши) и дальше ты работаешь уже с ними. В Golang все немного сложнее, прежде чем парсить нужно самому описать весь json (все объекты!) в типах, с которыми дальше удобно работать. Вот тут есть статья, описывающая нюансы парсинга json в Golang, прочитав которую можно понять весь масштаб трагедии для человека писавшего всю жизнь на языке с динамическими типами. Хорошо, что в моем скрипте мне не надо было глубоко парсить json, а хватило лишь разбить верхний json массив на строки, которые представляют json объект. В приведенных ниже скриптах показан пример такого подхода, сначала на Golang, а уже потом, ради интереса, повторенный на Perl, поэтому сначала представлен скрипт на Golang, а уже потом на Perl, в отличие от других задач, в которых последовательность написания скриптов была другой.
script.go
package main
import (
"encoding/json"
"fmt"
)
type Developer struct {
RawValue string
}
func (d *Developer) UnmarshalJSON(data []byte) error {
d.RawValue = string(data)
return nil
}
func main() {
jsonStr := `[
{"id":1,"name":"Larry"},
{"id":2,"name":"Robert"},
{"id":3,"name":"Rob"},
{"id":4,"name":"Ken"}
]`
developers := []Developer{}
if err := json.Unmarshal([]byte(jsonStr), &developers); err != nil {
panic(err)
}
for _, d := range developers {
//fmt.Printf("%s --- is %T\n", d.RawValue, d.RawValue)
fmt.Printf("%s\n", d.RawValue)
}
}
вывод скрипта
{"id":1,"name":"Larry"}
{"id":2,"name":"Robert"}
{"id":3,"name":"Rob"}
{"id":4,"name":"Ken"}
script.pl
#!/usr/bin/env perl
use strict;
use warnings;
use utf8;
use open qw(:std :utf8);
use JSON ();
my $jsonStr = '[
{"id":1,"name":"Larry"},
{"id":2,"name":"Robert"},
{"id":3,"name":"Rob"},
{"id":4,"name":"Ken"}
]';
my $developers = eval {
JSON
->new
->filter_json_object(sub{JSON::encode_json(shift)})
->decode($jsonStr)
};
die $@ if $@;
print $_ . "\n" for @$developers;
вывод скрипта
{"id":1,"name":"Larry"}
{"id":2,"name":"Robert"}
{"name":"Rob","id":3}
{"name":"Ken","id":4}
В этой задаче не будет таблицы с замерами, т.к. это бессмысленно. Взглянув на скрипты, можно увидеть, что подходы совершенно разные. Golang не пытается распарсить весь json, а разбивает лишь верхний массив на строки. Perl сначала парсит массив, затем парсит объекты внутри него в свои хеши и уже затем просто заменяет их на то, что ему подсунули, а подсунули ему обратно сериализованный хеш, и поэтому в выводе скрипта ключи внутри некоторых json объектов перемешаны.
Выводы
Начнем с самого простого - скорость написания скриптов. У обоих языков хорошая документация и куча примеров кода на все случаи, поэтому этот критерий зависит только от практики, и зависимость тут линейная - чем больше практики, тем быстрее скорость написания скриптов.
При работе с текстом (задача 1) Perl оказался на высоте: он и памяти меньше потребляет, и быстрее работает. Вот тут есть классные тесты по потреблению памяти, в которых подтверждается превосходство Perl при работе с текстом. Возможно, приведенный мною код на Golang не сильно оптимизированный и можно его улучшить, чтобы сократить отставание. Я буду только рад, если в комментариях предложат вариант пооптимальней. Нужно сделать еще одну оговорку: в данной задаче в варианте на Perl я не использовал ни одного модуля, и если вдруг окажется, что для распарсивания лога будет нужен модуль (например DateTime), то считаю, что Perl сравняется по скорости с Golang и проиграет по памяти, как это происходит в других задачах.
Во всех других задачах Perl чуток уступает по скорости выполнения и сильно проигрывает по памяти, что вполне объяснимо - Golang язык со строгой статической типизацией, а следовательно, должен потреблять меньше памяти.
Главные выводы из всего этого можно сделать такие:
изучать Golang после многолетнего использования Perl сложновато, но вполне по силам сисадминам;
Golang вполне способен заменить Perl как скриптовый язык.
Комментарии (30)
kale
29.06.2023 11:47+2Компилировать regex конечно надо вне цикла, да и в целом тесты производительности с выводом на внешнее устройство такое себе.. Но perl удивил, да.
krpsh Автор
29.06.2023 11:47Компилировать regex конечно надо вне цикла
точно, а вы ведь правы
я проверил и Golang отработал быстрее (везде он чуток, но быстрее, елки зеленые :-)), но по памяти проиграл
real 0m15,796s user 0m15,603s sys 0m0,691s
8232 /tmp/go-build260055243/b001/exe/script 8300 /tmp/go-build260055243/b001/exe/script 8120 /tmp/go-build260055243/b001/exe/script
noRoman
29.06.2023 11:47+1/занудаon/ Решение немножко в лоб в коде. Так будет шустрее. /занудаoff/
package main import ( "bufio" "fmt" "log" "os" "regexp" ) func main() { file, err := os.Open("access.log") if err != nil { log.Fatalf("%s", err) } fileScanner := bufio.NewScanner(file) re := regexp.MustCompile(`(?s)(?:.+?\s){8}(.+?)\s`) for fileScanner.Scan() { s := re.FindStringSubmatch(fileScanner.Text()) if s[1] == "500" { fmt.Println(s) } } }
krpsh Автор
29.06.2023 11:47Да, так работает быстрее
real 0m8,611s user 0m8,331s sys 0m0,539s
Но надо сделать оговорку, что в данной регулярке вы отбрасываете все, что идет после кода ответа сервера.
И если захватывать остаток строки вот так
re := regexp.MustCompile(`(?s)(?:.+?\s){8}(.+?)\s(.+)`)
то результат будет другой
real 0m14,968s user 0m14,719s sys 0m0,560s
PaperBread
29.06.2023 11:47В golang необязательно описывать весь json, можно только те поля что вас интересуют.
krpsh Автор
29.06.2023 11:47Я знаю, что так можно, но как раз в этом-то и заключается ключевое отличие. В Perl ты получаешь сразу весь десериализованный json, а в Golang ты должен его описать настолько подробно, насколько тебе надо прежде чем десериализовать. Этот момент понять было сложнее всего.
И да, у Perl то же есть песочница )
kolezz
29.06.2023 11:47смысл сравнения изначально неправилен.
perl силён в операциях со строками, go - в многопоточности.
если в go работа исключительно со строками (или внешними сервисами) и без горутин - это признак неправильного выбора инструмента.это не прямые конкуренты, а инструменты, заточенные каждый на свою область.
krpsh Автор
29.06.2023 11:47Соглашусь с вами - это не конкуренты, я этого и не утверждал. Просто мне было интересно, можно ли использовать Golang (не слишком ли он сложен) как сриптовый язык для простых нужд сисадмина. Да, горутины классные, я пробовал, но скажу вам, что для рядового сисадмина горутины почти не нужны, да и на Perl есть куча модулей позволяющих так же элегантно породить (fork) кучу воркеров. Конечно же fork не сравнится с горутинами, но например для опроса кучи сайтов можно использовать не воркеры, а асинхронно это сделать.
И да, для всего нужен свой инструмент и правильный сисадмин умеет его выбирать под задачу )
domix32
29.06.2023 11:47можно ли использовать Golang как сриптовый язык
Так использовали б его для большего юзабилити вроде вот такого.
Мне кстати интересно сколько времени займёт grep/ripgrep/awk для подобной задачи.
rg "\s500\s" ./access.log
krpsh Автор
29.06.2023 11:47time grep -E "\s500\s" ./access.log
real 0m1,165s user 0m1,051s sys 0m0,114s
time awk '$9~500' ./access.log
real 0m1,258s user 0m1,077s sys 0m0,182s
если что, то в логе вот сколько строк
wc -l ./access.log
3984116 ./access.log
ripgrep
- не встречал такого (domix32
29.06.2023 11:47+1ripgrep отсутвует в дефолтных дистрибутивах обычно, если это не какой-ниубдь gentoo.
`apt install ripgrep` должен сработать или через что вы там ставите. Если пользоваться также как и обычным грепом, то в среднем скорость х5 получается, иногда выше.
krpsh Автор
29.06.2023 11:47уговорили )))
time rg "\s500\s" ./access.log
real 0m0,208s user 0m0,135s sys 0m0,073s
да, действительно, реактивный какой )
спасибо, за полезный инструмент!
domix32
29.06.2023 11:47Если вывод никуда не перенаправляется - вывод ripgrep примерно вдвое медленее. Если отправлять его в /dev/null grep кажется вообще ничего не делает и выводит примерно нисколько времени. Если делать вывод в файл, то grep выдаёт
real 0m2.240s user 0m2.032s sys 0m0.204s
а rg
real 0m0.430s user 0m0.312s sys 0m0.112s
что в примерно равно среднему времени.
awk выдал что-то близкое к вашим цифрам
Данных генерил как-то так
import random request_methods = ["GET", "POST", "PUT", "DELETE"] urls = ["/page1", "/page2", "/page3", "/page4"] status_codes = [200, 200, 200, 200, 200, 404, 500, 500] with open("access_log.txt", "w") as file: for _ in range(3984116): ip_address = "127.0.0.1" # Example IP address # Randomly select request method, URL, and status code request_method = random.choice(request_methods) url = random.choice(urls) status_code = random.choice(status_codes) # Determine the response size based on the status code if status_code == 200: response_size = random.randint(1024, 4096) elif status_code == 500: response_size = random.randint(512, 2048) else: response_size = 0 # Generate the log entry and write it to the file log_entry = f"{ip_address} - - [30/Jun/2023:12:00:00 +0000] \"{request_method} {url} HTTP/1.1\" {status_code} {response_size}\n" file.write(log_entry)
правда размер лога примерно 300 Мб получился.
krpsh Автор
29.06.2023 11:47Я access.log не генерил, а взял с рабочего сервера
У меня 500-х кодов 226 строк
Во всех тестах вывод был на терминал, никуда ничего не перенаправлял
domix32
29.06.2023 11:47Ну, у меня прост не было логов, потому сгенерировал столько же строк, чтобы было чутка чеснее. Правда 500 там было 995570 штук и выводило их 20+/-5 секунд. C 250 штук и выводом в терминал grep стал выводить как rg выше - в районе 0.3 сек, а rg выдал
real 0m0.084s user 0m0.064s sys 0m0.020s
lrrr11
29.06.2023 11:47+1если в go работа исключительно со строками (или внешними сервисами) и без горутин - это признак неправильного выбора инструмента
сами разработчики go из Google используют этот язык в том числе и именно так, в boringssl например: https://github.com/google/boringssl/tree/master/crypto/obj
причем на go тут были переписаны как раз перловые скрипты из openssl
domix32
29.06.2023 11:47А уж бенчить коннекты к sql базе и подавно бред.
krpsh Автор
29.06.2023 11:47Ну почему же. В данном случае это оправдано, т.к. все развернуто локально и база в том числе, а это значит, что она в момент теста не была нагуржена от слова совсем и замеры проводились несколько раз чтобы прогреть всякие буферы, кеши и прочее.
domix32
29.06.2023 11:47Движок SQL вроде в итоге получается ровно тот же самый и фактическая разница это собственно копирование данных из базы в контекст программы, что может иметь некоторые накладные расходы от выбранного языка, и собственно IO, производительность которого в принципе непредсказуема. Разница скорость исполнения запроса в таком случае должна иметь чисто статистическую погрешность. Вот если бы это всё поверх ORM как-то происходило, вот тогда б можно было пободаться у кого он быстрее.
fireSparrow
Можете считать это глубоким имхо, но я должен это написать:
krpsh Автор
А я в статье и не призываю никого учить Perl и ничего против питона не имею.
В точку про производительность!
Скорее всего соглашусь с вами
fireSparrow
Я просто счёл нужным оставить этот коммент на тот случай, если статью будет читать кто-то, кто пока не ориентируется в языках, но присматривает подходящий инструмент автоматизации )
krpsh Автор
Кстати, а нет ли у вас желания воспроизвести такие же скрипты на питоне (можно без замеров), чтобы можно было наглядно сравнить?
fireSparrow
Хорошая мысль, я займусь этим в выходные.
fireSparrow
Вот так получается на питоне:
krpsh Автор
По умолчанию print ставит перевод строки, поэтому я чуток поправил вот так
print(line , end='')
У меня ошибка (
Пришлось поправить вывод результата вот так
print(row)
вывод скрипта
Тут я прям сильно удивился простоте скрипта. Вы, наверное, не поняли сути задачи, ибо в массиве developers явно находятся не строки, а какие-то структуры, т.к. каждая строка вывода скрипта не является валидным json объектом.
вывод скрипта