Praktikal na aplikasyon ng ELK. Pagse-set up ng logstash

Pagpapakilala

Habang nagde-deploy ng isa pang system, nahaharap kami sa pangangailangang magproseso ng malaking bilang ng iba't ibang mga log. Napili ang ELK bilang tool. Tatalakayin ng artikulong ito ang aming karanasan sa pag-set up ng stack na ito.

Hindi kami nagtatakda ng layunin na ilarawan ang lahat ng kakayahan nito, ngunit gusto naming tumutok partikular sa paglutas ng mga praktikal na problema. Ito ay dahil sa ang katunayan na kahit na mayroong isang medyo malaking halaga ng dokumentasyon at mga yari na imahe, mayroong maraming mga pitfalls, hindi bababa sa natagpuan namin ang mga ito.

Na-deploy namin ang stack sa pamamagitan ng docker-compose. Bukod dito, mayroon kaming mahusay na pagkakasulat na docker-compose.yml, na nagbigay-daan sa amin na itaas ang stack nang halos walang problema. At tila sa amin ay malapit na ang tagumpay, ngayon ay sasabunutan namin ito nang kaunti upang umangkop sa aming mga pangangailangan at iyon na.

Sa kasamaang palad, ang pagtatangkang i-configure ang system upang tumanggap at magproseso ng mga log mula sa aming aplikasyon ay hindi agad nagtagumpay. Samakatuwid, napagpasyahan namin na sulit na pag-aralan ang bawat bahagi nang hiwalay, at pagkatapos ay bumalik sa kanilang mga koneksyon.

Kaya, nagsimula kami sa logstash.

Kapaligiran, pag-deploy, pagpapatakbo ng Logstash sa isang lalagyan

Para sa deployment gumagamit kami ng docker-compose; ang mga eksperimento na inilarawan dito ay isinagawa sa MacOS at Ubuntu 18.0.4.

Ang logstash na imahe na nakarehistro sa aming orihinal na docker-compose.yml ay docker.elastic.co/logstash/logstash:6.3.2

Gagamitin namin ito para sa mga eksperimento.

Sumulat kami ng isang hiwalay na docker-compose.yml upang patakbuhin ang logstash. Siyempre, posibleng ilunsad ang imahe mula sa command line, ngunit nilulutas namin ang isang partikular na problema, kung saan pinapatakbo namin ang lahat mula sa docker-compose.

Maikling tungkol sa mga configuration file

Tulad ng sumusunod mula sa paglalarawan, maaaring patakbuhin ang logstash para sa isang channel, kung saan kailangan nitong ipasa ang *.conf file, o para sa ilang channel, kung saan kailangan nitong ipasa ang pipelines.yml file, na, naman, , ay magli-link sa mga file na .conf para sa bawat channel.
Tinahak namin ang pangalawang daan. Ito ay tila sa amin na mas pangkalahatan at nasusukat. Samakatuwid, gumawa kami ng pipelines.yml, at gumawa ng direktoryo ng pipelines kung saan maglalagay kami ng mga .conf file para sa bawat channel.

Sa loob ng lalagyan ay may isa pang configuration file - logstash.yml. Hindi namin ito ginagalaw, ginagamit namin ito bilang ay.

Kaya, ang aming istraktura ng direktoryo:

Praktikal na aplikasyon ng ELK. Pagse-set up ng logstash

Upang makatanggap ng data ng input, sa ngayon ay ipinapalagay namin na ito ay tcp sa port 5046, at para sa output ay gagamitin namin ang stdout.

Narito ang isang simpleng configuration para sa unang paglulunsad. Dahil ang unang gawain ay ilunsad.

Kaya, mayroon kaming docker-compose.yml na ito

version: '3'

networks:
  elk:

volumes:
  elasticsearch:
    driver: local

services:

  logstash:
    container_name: logstash_one_channel
    image: docker.elastic.co/logstash/logstash:6.3.2
    networks:
      	- elk
    ports:
      	- 5046:5046
    volumes:
      	- ./config/pipelines.yml:/usr/share/logstash/config/pipelines.yml:ro
	- ./config/pipelines:/usr/share/logstash/config/pipelines:ro

Ano ang nakikita natin dito?

  1. Ang mga network at volume ay kinuha mula sa orihinal na docker-compose.yml (ang isa kung saan inilunsad ang buong stack) at sa palagay ko ay hindi gaanong nakakaapekto ang mga ito sa pangkalahatang larawan dito.
  2. Gumagawa kami ng isang (mga) serbisyo ng logstash mula sa docker.elastic.co/logstash/logstash:6.3.2 na imahe at pinangalanan itong logstash_one_channel.
  3. Ipinapasa namin ang port 5046 sa loob ng container, sa parehong panloob na port.
  4. Imamapa namin ang aming pipe configuration file ./config/pipelines.yml sa file /usr/share/logstash/config/pipelines.yml sa loob ng container, kung saan kukunin ito ng logstash at gagawin itong read-only, kung sakali.
  5. Imamapa namin ang direktoryo ng ./config/pipelines, kung saan mayroon kaming mga file na may mga setting ng channel, sa direktoryo ng /usr/share/logstash/config/pipelines at ginagawa rin itong read-only.

Praktikal na aplikasyon ng ELK. Pagse-set up ng logstash

Pipelines.yml file

- pipeline.id: HABR
  pipeline.workers: 1
  pipeline.batch.size: 1
  path.config: "./config/pipelines/habr_pipeline.conf"

Isang channel na may HABR identifier at ang path sa configuration file nito ay inilalarawan dito.

At panghuli ang file na "./config/pipelines/habr_pipeline.conf"

input {
  tcp {
    port => "5046"
   }
  }
filter {
  mutate {
    add_field => [ "habra_field", "Hello Habr" ]
    }
  }
output {
  stdout {
      
    }
  }

Huwag muna nating banggitin ang paglalarawan nito sa ngayon, subukan nating patakbuhin ito:

docker-compose up

Ano ang nakikita natin?

Nagsimula na ang lalagyan. Maaari naming suriin ang operasyon nito:

echo '13123123123123123123123213123213' | nc localhost 5046

At nakikita namin ang tugon sa container console:

Praktikal na aplikasyon ng ELK. Pagse-set up ng logstash

Ngunit sa parehong oras, nakikita rin natin:

logstash_one_channel | [2019-04-29T11:28:59,790][ERROR][logstash.licensechecker.licensereader] Hindi makuha ang impormasyon ng lisensya mula sa server ng lisensya {:message=>β€œElasticsearch Unreachable: [http://elasticsearch:9200/][Manticore ::ResolutionFailure] elasticsearch", ...

logstash_one_channel | [2019-04-29T11:28:59,894][INFO ][logstash.pipeline ] Matagumpay na nagsimula ang pipeline {:pipeline_id=>".monitoring-logstash", :thread=>"# "}

logstash_one_channel | [2019-04-29T11:28:59,988][INFO ][logstash.agent ] Mga pipeline na tumatakbo {:count=>2, :running_pipelines=>[:HABR, :".monitoring-logstash"], :non_running_pipelines=>[ ]}
logstash_one_channel | [2019-04-29T11:29:00,015][ERROR][logstash.inputs.metrics] Naka-install ang X-Pack sa Logstash ngunit hindi sa Elasticsearch. Mangyaring i-install ang X-Pack sa Elasticsearch upang magamit ang tampok na pagsubaybay. Maaaring magagamit ang iba pang mga tampok.
logstash_one_channel | [2019-04-29T11:29:00,526][INFO ][logstash.agent ] Matagumpay na nasimulan ang endpoint ng Logstash API {:port=>9600}
logstash_one_channel | [2019-04-29T11:29:04,478][INFO ][logstash.outputs.elasticsearch] Tumatakbo ng pagsusuri sa kalusugan upang makita kung gumagana ang isang koneksyon sa Elasticsearch {:healthcheck_url=>http://elasticsearch:9200/, :path=> "/"}
logstash_one_channel | [2019-04-29T11:29:04,487][WARN ][logstash.outputs.elasticsearch] Sinubukan na buhayin ang koneksyon sa patay na instance ng ES, ngunit nagkaroon ng error. {:url=>β€œnababanat:9200/", :error_type=>LogStash::Outputs::ElasticSearch::HttpClient::Pool::HostUnreachableError, :error=>"Elasticsearch Unreachable: [http://elasticsearch:9200/][Manticore::ResolutionFailure] elasticsearch"}
logstash_one_channel | [2019-04-29T11:29:04,704][INFO ][logstash.licensechecker.licensereader] Tumatakbo ng pagsusuri sa kalusugan upang makita kung gumagana ang isang koneksyon sa Elasticsearch {:healthcheck_url=>http://elasticsearch:9200/, :path=> "/"}
logstash_one_channel | [2019-04-29T11:29:04,710][WARN ][logstash.licensechecker.licensereader] Sinubukan na buhayin muli ang koneksyon sa patay na ES instance, ngunit nagkaroon ng error. {:url=>β€œnababanat:9200/", :error_type=>LogStash::Outputs::ElasticSearch::HttpClient::Pool::HostUnreachableError, :error=>"Elasticsearch Unreachable: [http://elasticsearch:9200/][Manticore::ResolutionFailure] elasticsearch"}

At ang aming log ay gumagapang sa lahat ng oras.

Dito ko na-highlight sa berde ang mensahe na matagumpay na nailunsad ang pipeline, sa pula ang mensahe ng error at sa dilaw ang mensahe tungkol sa pagtatangkang makipag-ugnayan nababanat: 9200.
Nangyayari ito dahil ang logstash.conf, kasama sa larawan, ay naglalaman ng tseke para sa availability ng elasticsearch. Pagkatapos ng lahat, ipinapalagay ng logstash na gumagana ito bilang bahagi ng Elk stack, ngunit pinaghiwalay namin ito.

Posibleng magtrabaho, ngunit hindi ito maginhawa.

Ang solusyon ay i-disable ang pagsusuring ito sa pamamagitan ng XPACK_MONITORING_ENABLED environment variable.

Gumawa tayo ng pagbabago sa docker-compose.yml at patakbuhin itong muli:

version: '3'

networks:
  elk:

volumes:
  elasticsearch:
    driver: local

services:

  logstash:
    container_name: logstash_one_channel
    image: docker.elastic.co/logstash/logstash:6.3.2
    networks:
      - elk
    environment:
      XPACK_MONITORING_ENABLED: "false"
    ports:
      - 5046:5046
   volumes:
      - ./config/pipelines.yml:/usr/share/logstash/config/pipelines.yml:ro
      - ./config/pipelines:/usr/share/logstash/config/pipelines:ro

Ngayon, maayos na ang lahat. Ang lalagyan ay handa na para sa mga eksperimento.

Maaari tayong mag-type muli sa susunod na console:

echo '13123123123123123123123213123213' | nc localhost 5046

At makita:

logstash_one_channel | {
logstash_one_channel |         "message" => "13123123123123123123123213123213",
logstash_one_channel |      "@timestamp" => 2019-04-29T11:43:44.582Z,
logstash_one_channel |        "@version" => "1",
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |            "host" => "gateway",
logstash_one_channel |            "port" => 49418
logstash_one_channel | }

Nagtatrabaho sa loob ng isang channel

Kaya inilunsad namin. Ngayon ay maaari ka na talagang maglaan ng oras upang i-configure ang logstash mismo. Huwag muna nating hawakan ang pipelines.yml file sa ngayon, tingnan natin kung ano ang makukuha natin sa pagtatrabaho sa isang channel.

Dapat kong sabihin na ang pangkalahatang prinsipyo ng pagtatrabaho sa file ng pagsasaayos ng channel ay mahusay na inilarawan sa opisyal na manwal, dito dito
Kung gusto mong magbasa sa Russian, ginamit namin ang isang ito artikulo(ngunit luma na ang query syntax doon, kailangan nating isaalang-alang ito).

Pumunta tayo nang sunud-sunod mula sa seksyong Input. Nakakita na kami ng trabaho sa TCP. Ano pa ang maaaring maging kawili-wili dito?

Subukan ang mga mensahe gamit ang tibok ng puso

Mayroong isang kawili-wiling pagkakataon upang makabuo ng mga awtomatikong mensahe ng pagsubok.
Upang gawin ito, kailangan mong paganahin ang heartbean plugin sa seksyon ng input.

input {
  heartbeat {
    message => "HeartBeat!"
   }
  } 

I-on ito, simulan ang pagtanggap isang beses sa isang minuto

logstash_one_channel | {
logstash_one_channel |      "@timestamp" => 2019-04-29T13:52:04.567Z,
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |         "message" => "HeartBeat!",
logstash_one_channel |        "@version" => "1",
logstash_one_channel |            "host" => "a0667e5c57ec"
logstash_one_channel | }

Kung gusto naming makatanggap ng mas madalas, kailangan naming idagdag ang parameter ng interval.
Ganito kami makakatanggap ng mensahe kada 10 segundo.

input {
  heartbeat {
    message => "HeartBeat!"
    interval => 10
   }
  }

Pagkuha ng data mula sa isang file

Nagpasya din kaming tingnan ang file mode. Kung ito ay gumagana nang maayos sa file, kung gayon marahil ay hindi kailangan ng ahente, kahit para sa lokal na paggamit.

Ayon sa paglalarawan, ang operating mode ay dapat na katulad ng tail -f, i.e. nagbabasa ng mga bagong linya o, bilang opsyon, binabasa ang buong file.

Kaya kung ano ang gusto naming makuha:

  1. Gusto naming makatanggap ng mga linya na idinagdag sa isang log file.
  2. Gusto naming makatanggap ng data na nakasulat sa ilang mga log file, habang nagagawang paghiwalayin kung ano ang natanggap mula sa kung saan.
  3. Gusto naming tiyakin na kapag na-restart ang logstash, hindi na nito matatanggap muli ang data na ito.
  4. Gusto naming suriin na kung naka-off ang logstash, at patuloy na isinusulat ang data sa mga file, pagkatapos kapag pinatakbo namin ito, matatanggap namin ang data na ito.

Upang maisagawa ang eksperimento, magdagdag tayo ng isa pang linya sa docker-compose.yml, na binubuksan ang direktoryo kung saan natin inilalagay ang mga file.

version: '3'

networks:
  elk:

volumes:
  elasticsearch:
    driver: local

services:

  logstash:
    container_name: logstash_one_channel
    image: docker.elastic.co/logstash/logstash:6.3.2
    networks:
      - elk
    environment:
      XPACK_MONITORING_ENABLED: "false"
    ports:
      - 5046:5046
   volumes:
      - ./config/pipelines.yml:/usr/share/logstash/config/pipelines.yml:ro
      - ./config/pipelines:/usr/share/logstash/config/pipelines:ro
      - ./logs:/usr/share/logstash/input

At baguhin ang seksyon ng input sa habr_pipeline.conf

input {
  file {
    path => "/usr/share/logstash/input/*.log"
   }
  }

Magsimula tayo:

docker-compose up

Upang lumikha at magsulat ng mga file ng log gagamitin namin ang command:


echo '1' >> logs/number1.log

{
logstash_one_channel |            "host" => "ac2d4e3ef70f",
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |      "@timestamp" => 2019-04-29T14:28:53.876Z,
logstash_one_channel |        "@version" => "1",
logstash_one_channel |         "message" => "1",
logstash_one_channel |            "path" => "/usr/share/logstash/input/number1.log"
logstash_one_channel | }

Oo, gumagana ito!

Kasabay nito, nakikita namin na awtomatiko naming idinagdag ang field ng path. Nangangahulugan ito na sa hinaharap, magagawa naming i-filter ang mga tala sa pamamagitan nito.

Subukan nating muli:

echo '2' >> logs/number1.log

{
logstash_one_channel |            "host" => "ac2d4e3ef70f",
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |      "@timestamp" => 2019-04-29T14:28:59.906Z,
logstash_one_channel |        "@version" => "1",
logstash_one_channel |         "message" => "2",
logstash_one_channel |            "path" => "/usr/share/logstash/input/number1.log"
logstash_one_channel | }

At ngayon sa isa pang file:

 echo '1' >> logs/number2.log

{
logstash_one_channel |            "host" => "ac2d4e3ef70f",
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |      "@timestamp" => 2019-04-29T14:29:26.061Z,
logstash_one_channel |        "@version" => "1",
logstash_one_channel |         "message" => "1",
logstash_one_channel |            "path" => "/usr/share/logstash/input/number2.log"
logstash_one_channel | }

Malaki! Ang file ay kinuha, ang landas ay tinukoy nang tama, ang lahat ay maayos.

Itigil ang logstash at magsimulang muli. Maghintay tayo. Katahimikan. Yung. Hindi namin natatanggap muli ang mga rekord na ito.

At ngayon ang pinaka matapang na eksperimento.

I-install ang logstash at isagawa:

echo '3' >> logs/number2.log
echo '4' >> logs/number1.log

Patakbuhin muli ang logstash at tingnan ang:

logstash_one_channel | {
logstash_one_channel |            "host" => "ac2d4e3ef70f",
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |         "message" => "3",
logstash_one_channel |        "@version" => "1",
logstash_one_channel |            "path" => "/usr/share/logstash/input/number2.log",
logstash_one_channel |      "@timestamp" => 2019-04-29T14:48:50.589Z
logstash_one_channel | }
logstash_one_channel | {
logstash_one_channel |            "host" => "ac2d4e3ef70f",
logstash_one_channel |     "habra_field" => "Hello Habr",
logstash_one_channel |         "message" => "4",
logstash_one_channel |        "@version" => "1",
logstash_one_channel |            "path" => "/usr/share/logstash/input/number1.log",
logstash_one_channel |      "@timestamp" => 2019-04-29T14:48:50.856Z
logstash_one_channel | }

Hooray! Pinulot ang lahat.

Ngunit dapat namin kayong bigyan ng babala tungkol sa mga sumusunod. Kung ang lalagyan ng logstash ay tinanggal (docker stop logstash_one_channel && docker rm logstash_one_channel), walang kukunin. Ang posisyon ng file kung saan ito binasa ay nakaimbak sa loob ng lalagyan. Kung patakbuhin mo ito mula sa simula, tatanggap lamang ito ng mga bagong linya.

Pagbabasa ng mga umiiral na file

Sabihin nating naglulunsad kami ng logstash sa unang pagkakataon, ngunit mayroon na kaming mga log at gusto naming iproseso ang mga ito.
Kung magpapatakbo kami ng logstash gamit ang seksyon ng input na ginamit namin sa itaas, wala kaming makukuha. Mga bagong linya lang ang ipoproseso ng logstash.

Upang makuha ang mga linya mula sa mga kasalukuyang file, dapat kang magdagdag ng karagdagang linya sa seksyon ng input:

input {
  file {
    start_position => "beginning"
    path => "/usr/share/logstash/input/*.log"
   }
  }

Bukod dito, mayroong isang nuance: nakakaapekto lamang ito sa mga bagong file na hindi pa nakikita ng logstash. Para sa parehong mga file na nasa larangan ng view ng logstash, naalala na nito ang kanilang laki at ngayon ay kukuha na lamang ng mga bagong entry sa kanila.

Huminto tayo dito at pag-aralan ang input section. Marami pa ring mga opsyon, ngunit sapat na iyon para sa amin para sa mga karagdagang eksperimento sa ngayon.

Pagruruta at Pagbabago ng Data

Subukan nating lutasin ang sumusunod na problema, sabihin nating mayroon tayong mga mensahe mula sa isang channel, ang ilan sa mga ito ay nagbibigay-kaalaman, at ang ilan ay mga mensahe ng error. Nag-iiba sila ayon sa tag. Ang iba ay INFO, ang iba ay ERROR.

Kailangan natin silang paghiwalayin sa labasan. Yung. Nagsusulat kami ng mga mensahe ng impormasyon sa isang channel, at mga mensahe ng error sa isa pa.

Upang gawin ito, lumipat mula sa seksyon ng input patungo sa filter at output.

Gamit ang seksyon ng filter, i-parse namin ang papasok na mensahe, pagkuha ng isang hash (mga pares ng key-value) mula dito, na maaari na nating gamitin, i.e. i-disassemble ayon sa mga kondisyon. At sa seksyon ng output, pipili kami ng mga mensahe at ipadala ang bawat isa sa sarili nitong channel.

Pag-parse ng mensahe gamit ang grok

Upang ma-parse ang mga string ng teksto at makakuha ng isang hanay ng mga patlang mula sa kanila, mayroong isang espesyal na plugin sa seksyon ng filter - grok.

Nang hindi itinatakda sa aking sarili ang layunin ng pagbibigay ng isang detalyadong paglalarawan dito (para dito ay tinutukoy ko opisyal na dokumentasyon), Ibibigay ko ang aking simpleng halimbawa.

Upang gawin ito, kailangan mong magpasya sa format ng mga string ng input. Mayroon akong ganito:

1 mensahe ng INFO1
2 mensahe ng ERROR2

Yung. Nauuna ang identifier, pagkatapos ay INFO/ERROR, pagkatapos ay ilang salita na walang mga puwang.
Hindi ito mahirap, ngunit sapat na upang maunawaan ang prinsipyo ng pagpapatakbo.

Kaya, sa seksyon ng filter ng grok plugin, dapat nating tukuyin ang isang pattern para sa pag-parse ng ating mga string.

Magiging ganito ang hitsura:

filter {
  grok {
    match => { "message" => ["%{INT:message_id} %{LOGLEVEL:message_type} %{WORD:message_text}"] }
   }
  } 

Sa pangkalahatan, ito ay isang regular na expression. Ginagamit ang mga handa na pattern, tulad ng INT, LOGLEVEL, WORD. Ang kanilang paglalarawan, pati na rin ang iba pang mga pattern, ay matatagpuan dito dito

Ngayon, sa pagdaan sa filter na ito, ang aming string ay magiging hash ng tatlong field: message_id, message_type, message_text.

Ipapakita ang mga ito sa seksyon ng output.

Pagruruta ng mga mensahe sa output section gamit ang if command

Sa seksyon ng output, tulad ng naaalala namin, hahatiin namin ang mga mensahe sa dalawang stream. Ang ilan - na iNFO, ay magiging output sa console, at may mga error, maglalabas kami sa isang file.

Paano natin pinaghihiwalay ang mga mensaheng ito? Ang kundisyon ng problema ay nagmumungkahi na ng solusyon - kung tutuusin, mayroon na tayong nakalaang message_type na field, na maaari lamang tumagal ng dalawang halaga: INFO at ERROR. Ito ay batayan na tayo ay gagawa ng pagpili gamit ang if statement.

if [message_type] == "ERROR" {
        # Π—Π΄Π΅ΡΡŒ Π²Ρ‹Π²ΠΎΠ΄ΠΈΠΌ Π² Ρ„Π°ΠΉΠ»
       } else
     {
      # Π—Π΄Π΅ΡΡŒ Π²Ρ‹Π²ΠΎΠ΄ΠΈΠΌ Π² stdout
    }

Ang isang paglalarawan ng pagtatrabaho sa mga field at operator ay matatagpuan sa seksyong ito opisyal na manwal.

Ngayon, tungkol sa mismong konklusyon.

Output ng console, malinaw ang lahat dito - stdout {}

Ngunit ang output sa isang file - tandaan na pinapatakbo namin ang lahat ng ito mula sa isang lalagyan at upang ang file kung saan isinusulat namin ang resulta ay ma-access mula sa labas, kailangan naming buksan ang direktoryo na ito sa docker-compose.yml.

Kabuuan:

Ang seksyon ng output ng aming file ay ganito ang hitsura:


output {
  if [message_type] == "ERROR" {
    file {
          path => "/usr/share/logstash/output/test.log"
          codec => line { format => "custom format: %{message}"}
         }
    } else
     {stdout {
             }
     }
  }

Sa docker-compose.yml nagdagdag kami ng isa pang volume para sa output:

version: '3'

networks:
  elk:

volumes:
  elasticsearch:
    driver: local

services:

  logstash:
    container_name: logstash_one_channel
    image: docker.elastic.co/logstash/logstash:6.3.2
    networks:
      - elk
    environment:
      XPACK_MONITORING_ENABLED: "false"
    ports:
      - 5046:5046
   volumes:
      - ./config/pipelines.yml:/usr/share/logstash/config/pipelines.yml:ro
      - ./config/pipelines:/usr/share/logstash/config/pipelines:ro
      - ./logs:/usr/share/logstash/input
      - ./output:/usr/share/logstash/output

Inilunsad namin ito, subukan ito, at makita ang isang dibisyon sa dalawang stream.

Pinagmulan: www.habr.com

Magdagdag ng komento